From 43c830469ad836d56db32a603b8536b26ec3a4fe Mon Sep 17 00:00:00 2001 From: Simon Shi Date: Thu, 21 May 2026 01:35:08 +0900 Subject: [PATCH 001/137] feat(cliproxy): add Qoder as a CLIProxy provider channel Add Qoder AI coding assistant as a supported CLIProxy provider: - Device code OAuth flow with --qoder-login flag - Plus-only backend (CLIProxyAPIPlus required) - No built-in token refresh (unsupported, like Kilo) - Auth URL: https://qoder.com/device/selectAccounts - Auth file prefix: qoder- - Token type: qoder --- src/cliproxy/auth/auth-types.ts | 7 +++++++ src/cliproxy/provider-capabilities.ts | 12 ++++++++++++ src/cliproxy/types/provider-types.ts | 4 +++- src/commands/command-catalog.ts | 1 + src/config/reserved-names.ts | 1 + tests/unit/auth/profile-detector.test.ts | 1 + tests/unit/commands/completion-backend.test.ts | 1 + tests/unit/commands/help-command-parity.test.ts | 1 + tests/unit/web-server/cliproxy-auth-routes.test.ts | 1 + 9 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index 231ab850..f4dc8adf 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -306,6 +306,13 @@ export const OAUTH_CONFIGS: Record = { scopes: [], authFlag: '--kilo-login', }, + qoder: { + provider: 'qoder', + displayName: 'Qoder', + authUrl: 'https://qoder.com/device/selectAccounts', + scopes: [], + authFlag: '--qoder-login', + }, }; /** diff --git a/src/cliproxy/provider-capabilities.ts b/src/cliproxy/provider-capabilities.ts index 1d020d77..eb942cd7 100644 --- a/src/cliproxy/provider-capabilities.ts +++ b/src/cliproxy/provider-capabilities.ts @@ -182,6 +182,18 @@ export const PROVIDER_CAPABILITIES: Record { expect(detector.detectProfileType('gitlab').provider).toBe('gitlab'); expect(detector.detectProfileType('codebuddy').provider).toBe('codebuddy'); expect(detector.detectProfileType('kilo').provider).toBe('kilo'); + expect(detector.detectProfileType('qoder').provider).toBe('qoder'); }); it('should detect settings-based profile from unified config', () => { diff --git a/tests/unit/commands/completion-backend.test.ts b/tests/unit/commands/completion-backend.test.ts index 4667dc76..1c598d4c 100644 --- a/tests/unit/commands/completion-backend.test.ts +++ b/tests/unit/commands/completion-backend.test.ts @@ -85,6 +85,7 @@ describe('completion backend', () => { expect(values).toContain('gitlab'); expect(values).toContain('codebuddy'); expect(values).toContain('kilo'); + expect(values).toContain('qoder'); expect(values).toContain('localglm'); expect(values).toContain('work'); expect(values).toContain('my-codex'); diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts index bd7f6692..739f4488 100644 --- a/tests/unit/commands/help-command-parity.test.ts +++ b/tests/unit/commands/help-command-parity.test.ts @@ -61,6 +61,7 @@ describe('help command parity', () => { expect(rendered.includes('gitlab')).toBe(true); expect(rendered.includes('codebuddy')).toBe(true); expect(rendered.includes('kilo')).toBe(true); + expect(rendered.includes('qoder')).toBe(true); expect(rendered.includes('--gitlab-token-login')).toBe(true); expect(rendered.includes('--token-login')).toBe(true); expect(rendered.includes('--gitlab-url ')).toBe(true); diff --git a/tests/unit/web-server/cliproxy-auth-routes.test.ts b/tests/unit/web-server/cliproxy-auth-routes.test.ts index b6cf9d82..ba02942b 100644 --- a/tests/unit/web-server/cliproxy-auth-routes.test.ts +++ b/tests/unit/web-server/cliproxy-auth-routes.test.ts @@ -17,6 +17,7 @@ describe('cliproxy-auth-routes start-url guard', () => { "Provider 'codebuddy' uses Device Code flow" ); expect(getStartUrlUnsupportedReason('kilo')).toContain("Provider 'kilo' uses Device Code flow"); + expect(getStartUrlUnsupportedReason('qoder')).toContain("Provider 'qoder' uses Device Code flow"); }); it('allows Cursor browser URL auth on start-url', () => { From 1e60fe186e700ab00bd739b29085635053cba822 Mon Sep 17 00:00:00 2001 From: Simon Shi Date: Thu, 21 May 2026 03:06:18 +0900 Subject: [PATCH 002/137] feat(cliproxy): add Qoder to channel catalog, model routing, and UI Add Qoder provider to integration layers and dashboard UI: - Channel-to-provider mapping in catalog-cache - Model routing owner hints - Provider editor owner map - Setup wizard provider list - Fallback visuals and brand color --- src/cliproxy/services/catalog-cache.ts | 1 + src/shared/cliproxy-model-routing.ts | 1 + ui/src/components/cliproxy/provider-editor/index.tsx | 1 + ui/src/components/setup/wizard/constants.ts | 1 + ui/src/lib/provider-config.ts | 2 ++ 5 files changed, 6 insertions(+) diff --git a/src/cliproxy/services/catalog-cache.ts b/src/cliproxy/services/catalog-cache.ts index 925d7370..6216d641 100644 --- a/src/cliproxy/services/catalog-cache.ts +++ b/src/cliproxy/services/catalog-cache.ts @@ -38,6 +38,7 @@ const CHANNEL_TO_PROVIDER: Record = { kimi: 'kimi', kiro: 'kiro', 'github-copilot': 'ghcp', + qoder: 'qoder', }; /** CCS provider → channel name mapping (reverse) */ diff --git a/src/shared/cliproxy-model-routing.ts b/src/shared/cliproxy-model-routing.ts index b6194ac4..9c12f52d 100644 --- a/src/shared/cliproxy-model-routing.ts +++ b/src/shared/cliproxy-model-routing.ts @@ -57,6 +57,7 @@ const PROVIDER_OWNER_HINTS: Record = { kimi: ['kimi', 'moonshot'], kiro: ['kiro', 'aws'], ghcp: ['github', 'copilot'], + qoder: ['qoder'], }; function normalize(value: string | null | undefined): string { diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index 65be5b3b..86f91126 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -92,6 +92,7 @@ export function ProviderEditor({ qwen: ['alibaba', 'qwen'], iflow: ['iflow'], kilo: ['kilo'], + qoder: ['qoder'], kiro: ['kiro', 'aws'], ghcp: ['github', 'copilot'], kimi: ['kimi', 'moonshot'], diff --git a/ui/src/components/setup/wizard/constants.ts b/ui/src/components/setup/wizard/constants.ts index f40b591e..1fca25f4 100644 --- a/ui/src/components/setup/wizard/constants.ts +++ b/ui/src/components/setup/wizard/constants.ts @@ -25,6 +25,7 @@ const WIZARD_PROVIDER_ORDER: CLIProxyProvider[] = [ 'iflow', 'kilo', 'kiro', + 'qoder', ]; export const PROVIDERS: ProviderOption[] = WIZARD_PROVIDER_ORDER.map((id) => ({ diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index b99bf270..d1552186 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -135,6 +135,7 @@ export const PROVIDER_FALLBACK_VISUALS: Record = { gitlab: '#FC6D26', codebuddy: '#2563EB', kilo: '#E11D48', + qoder: '#D97706', ghcp: '#43aa8b', claude: '#D97757', kimi: '#FF6B35', From dea4de45fd4d06f3f2c79d2fedcef18caa8fb045 Mon Sep 17 00:00:00 2001 From: Simon Shi Date: Thu, 21 May 2026 15:18:41 +0800 Subject: [PATCH 003/137] fix(test): add qoder to PLUS_EXTRA_CLIPROXY_PROVIDERS test expectation Co-Authored-By: Claude Opus 4.7 --- ui/tests/unit/ui/lib/provider-config.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/tests/unit/ui/lib/provider-config.test.ts b/ui/tests/unit/ui/lib/provider-config.test.ts index f4552276..7551c169 100644 --- a/ui/tests/unit/ui/lib/provider-config.test.ts +++ b/ui/tests/unit/ui/lib/provider-config.test.ts @@ -68,6 +68,7 @@ describe('provider presentation metadata', () => { 'gitlab', 'codebuddy', 'kilo', + 'qoder', ]); expect(getProviderSection('gitlab')?.id).toBe('plus-extra'); expect(getProviderSection('gemini')?.id).toBe('core'); From 85e76742fe322ec5205c66a42c500dd768c90d28 Mon Sep 17 00:00:00 2001 From: shiminhao964 Date: Thu, 21 May 2026 18:26:55 +0800 Subject: [PATCH 004/137] feat(cliproxy): add qoder base config template Co-Authored-By: Claude Opus 4.7 --- config/base-qoder.settings.json | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 config/base-qoder.settings.json diff --git a/config/base-qoder.settings.json b/config/base-qoder.settings.json new file mode 100644 index 00000000..39cf7554 --- /dev/null +++ b/config/base-qoder.settings.json @@ -0,0 +1,10 @@ +{ + "env": { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/qoder", + "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed", + "ANTHROPIC_MODEL": "qoder/auto", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "qoder/auto", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "qoder/auto", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "qoder/auto" + } +} From 9a8430f68beef326bf43aa3686952875a0e818fa Mon Sep 17 00:00:00 2001 From: shiminhao964 Date: Thu, 21 May 2026 18:29:41 +0800 Subject: [PATCH 005/137] fix(cliproxy): use unprefixed auto model for qoder base config Qoder executor passes model IDs as-is without stripping a provider prefix, unlike kilo which strips the prefix internally. Co-Authored-By: Claude Opus 4.7 --- config/base-qoder.settings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/config/base-qoder.settings.json b/config/base-qoder.settings.json index 39cf7554..d736c8a2 100644 --- a/config/base-qoder.settings.json +++ b/config/base-qoder.settings.json @@ -2,9 +2,9 @@ "env": { "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/qoder", "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed", - "ANTHROPIC_MODEL": "qoder/auto", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "qoder/auto", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "qoder/auto", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "qoder/auto" + "ANTHROPIC_MODEL": "auto", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "auto", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "auto", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "auto" } } From c5308ded3b08493036cfc279b04432576f76094e Mon Sep 17 00:00:00 2001 From: shiminhao964 Date: Thu, 21 May 2026 18:32:20 +0800 Subject: [PATCH 006/137] fix(cliproxy): revert to qoder/auto prefixed model in base config Qoder executor now strips the qoder/ provider prefix before model lookup, matching the convention used by kilo and other providers. Co-Authored-By: Claude Opus 4.7 --- config/base-qoder.settings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/config/base-qoder.settings.json b/config/base-qoder.settings.json index d736c8a2..39cf7554 100644 --- a/config/base-qoder.settings.json +++ b/config/base-qoder.settings.json @@ -2,9 +2,9 @@ "env": { "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/qoder", "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed", - "ANTHROPIC_MODEL": "auto", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "auto", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "auto", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "auto" + "ANTHROPIC_MODEL": "qoder/auto", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "qoder/auto", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "qoder/auto", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "qoder/auto" } } From 39656b98262e7f1d7b3e3c29e67fed7b9cae9c95 Mon Sep 17 00:00:00 2001 From: Simon Shi <9512067+simonsmh@users.noreply.github.com> Date: Thu, 21 May 2026 19:24:08 +0800 Subject: [PATCH 007/137] fix(config): Update Kiro model settings in default configuration Set to default valid configuration. "kiro" is not. --- config/base-kiro.settings.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/config/base-kiro.settings.json b/config/base-kiro.settings.json index e84d0b18..07ce1854 100644 --- a/config/base-kiro.settings.json +++ b/config/base-kiro.settings.json @@ -2,9 +2,9 @@ "env": { "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/kiro", "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed", - "ANTHROPIC_MODEL": "kiro", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "kiro", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "kiro", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "kiro" + "ANTHROPIC_MODEL": "kiro-auto", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "kiro-claude-opus-4-7", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "kiro-claude-sonnet-4-6", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "kiro-claude-haiku-4-5" } } From d82cc13afc68603befee85f8c886d06722fc44d3 Mon Sep 17 00:00:00 2001 From: shiminhao964 Date: Thu, 21 May 2026 19:37:15 +0800 Subject: [PATCH 008/137] feat(qoder): add provider logo asset and dashboard mapping Co-Authored-By: Claude Opus 4.7 --- ui/public/assets/providers/qoder.svg | 1 + ui/src/lib/provider-config.ts | 1 + ui/tests/unit/ui/lib/provider-config.test.ts | 1 + 3 files changed, 3 insertions(+) create mode 100644 ui/public/assets/providers/qoder.svg diff --git a/ui/public/assets/providers/qoder.svg b/ui/public/assets/providers/qoder.svg new file mode 100644 index 00000000..829ec79a --- /dev/null +++ b/ui/public/assets/providers/qoder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index d1552186..cda84a9b 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -105,6 +105,7 @@ export const PROVIDER_ASSETS: Partial> = { gitlab: '/assets/providers/gitlab.svg', codebuddy: '/assets/providers/codebuddy.png', kilo: '/assets/providers/kilo.png', + qoder: '/assets/providers/qoder.svg', ghcp: '/assets/providers/copilot.svg', claude: '/assets/providers/claude.svg', kimi: '/assets/providers/kimi.svg', diff --git a/ui/tests/unit/ui/lib/provider-config.test.ts b/ui/tests/unit/ui/lib/provider-config.test.ts index 7551c169..8d11c520 100644 --- a/ui/tests/unit/ui/lib/provider-config.test.ts +++ b/ui/tests/unit/ui/lib/provider-config.test.ts @@ -129,6 +129,7 @@ describe('provider presentation metadata', () => { '/assets/providers/codebuddy.png', ], ['kilo', 'Kilo AI', 'Kilo AI coding assistant', '/assets/providers/kilo.png'], + ['qoder', 'Qoder', 'Qoder AI coding assistant', '/assets/providers/qoder.svg'], ])('recognizes %s across dashboard display helpers', (provider, name, description, asset) => { expect(getProviderDisplayName(provider)).toBe(name); expect(getProviderDescription(provider)).toBe(description); From 4973f7a00f8d3d6e30efb0b59e3ed961216c26c7 Mon Sep 17 00:00:00 2001 From: shiminhao964 Date: Thu, 21 May 2026 19:40:18 +0800 Subject: [PATCH 009/137] test(qoder): add qoder to provider-capabilities test expectations Co-Authored-By: Claude Opus 4.7 --- src/cliproxy/__tests__/provider-capabilities.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/cliproxy/__tests__/provider-capabilities.test.ts b/src/cliproxy/__tests__/provider-capabilities.test.ts index 02fea0b0..16f15b21 100644 --- a/src/cliproxy/__tests__/provider-capabilities.test.ts +++ b/src/cliproxy/__tests__/provider-capabilities.test.ts @@ -46,6 +46,7 @@ describe('provider-capabilities', () => { 'gitlab', 'codebuddy', 'kilo', + 'qoder', ]); }); @@ -66,6 +67,7 @@ describe('provider-capabilities', () => { 'cursor', 'codebuddy', 'kilo', + 'qoder', ]); expect(getProvidersByOAuthFlow('authorization_code')).toEqual([ 'gemini', @@ -85,6 +87,7 @@ describe('provider-capabilities', () => { 'kimi', 'codebuddy', 'kilo', + 'qoder', ]); }); From b52c0627ca7fa72365161b655cfaa12ba4f2fbc6 Mon Sep 17 00:00:00 2001 From: Simon Shi Date: Thu, 21 May 2026 20:33:34 +0800 Subject: [PATCH 010/137] feat(qoder): add model catalog entries for backend and UI Add Qoder provider models to both backend MODEL_CATALOG and UI MODEL_CATALOGS with 11 models (5 auto-routing tiers + 6 frontier models). Frontier models use unified preset mappings except DeepSeek which routes haiku to Flash. Co-Authored-By: Claude Opus 4.7 --- src/cliproxy/model-catalog.ts | 62 +++++++++++++++++ ui/src/lib/model-catalogs.ts | 122 ++++++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+) diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 29f6052e..e4144394 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -265,6 +265,68 @@ export const MODEL_CATALOG: Partial> = }, ], }, + qoder: { + provider: 'qoder', + displayName: 'Qoder', + defaultModel: 'qoder/auto', + models: [ + { + id: 'qoder/auto', + name: 'Qoder Auto', + description: 'Auto selects the best Qoder model for your prompt', + }, + { + id: 'qoder/ultimate', + name: 'Qoder Ultimate', + description: 'Highest quality Qoder tier', + }, + { + id: 'qoder/performance', + name: 'Qoder Performance', + description: 'Balanced quality and speed', + }, + { + id: 'qoder/efficient', + name: 'Qoder Efficient', + description: 'Cost-efficient Qoder tier', + }, + { + id: 'qoder/lite', + name: 'Qoder Lite', + description: 'Fastest and most affordable Qoder tier', + }, + { + id: 'qoder/qmodel', + name: 'Qwen 3.6 Plus (via Qoder)', + description: 'Qwen 3.6 Plus frontier model', + }, + { + id: 'qoder/dmodel', + name: 'DeepSeek V4 Pro (via Qoder)', + description: 'DeepSeek V4 Pro frontier model', + }, + { + id: 'qoder/dfmodel', + name: 'DeepSeek V4 Flash (via Qoder)', + description: 'DeepSeek V4 Flash frontier model', + }, + { + id: 'qoder/gm51model', + name: 'GLM 5.1 (via Qoder)', + description: 'GLM 5.1 frontier model', + }, + { + id: 'qoder/kmodel', + name: 'Kimi K2.6 (via Qoder)', + description: 'Kimi K2.6 frontier model', + }, + { + id: 'qoder/mmodel', + name: 'MiniMax M2.7 (via Qoder)', + description: 'MiniMax M2.7 frontier model', + }, + ], + }, kimi: { provider: 'kimi', displayName: 'Kimi (Moonshot)', diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index df7d35dd..805120af 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -454,6 +454,128 @@ export const MODEL_CATALOGS: Record = { }, ], }, + qoder: { + provider: 'qoder', + displayName: 'Qoder', + defaultModel: 'qoder/auto', + models: [ + { + id: 'qoder/auto', + name: 'Qoder Auto', + description: 'Auto selects the best Qoder model for your prompt', + presetMapping: { + default: 'qoder/auto', + opus: 'qoder/auto', + sonnet: 'qoder/auto', + haiku: 'qoder/auto', + }, + }, + { + id: 'qoder/ultimate', + name: 'Qoder Ultimate', + description: 'Highest quality Qoder tier', + presetMapping: { + default: 'qoder/ultimate', + opus: 'qoder/ultimate', + sonnet: 'qoder/ultimate', + haiku: 'qoder/ultimate', + }, + }, + { + id: 'qoder/performance', + name: 'Qoder Performance', + description: 'Balanced quality and speed', + presetMapping: { + default: 'qoder/performance', + opus: 'qoder/performance', + sonnet: 'qoder/performance', + haiku: 'qoder/performance', + }, + }, + { + id: 'qoder/efficient', + name: 'Qoder Efficient', + description: 'Cost-efficient Qoder tier', + presetMapping: { + default: 'qoder/efficient', + opus: 'qoder/efficient', + sonnet: 'qoder/efficient', + haiku: 'qoder/efficient', + }, + }, + { + id: 'qoder/lite', + name: 'Qoder Lite', + description: 'Fastest and most affordable Qoder tier', + presetMapping: { + default: 'qoder/lite', + opus: 'qoder/lite', + sonnet: 'qoder/lite', + haiku: 'qoder/lite', + }, + }, + { + id: 'qoder/qmodel', + name: 'Qwen 3.6 Plus (via Qoder)', + description: 'Qwen 3.6 Plus frontier model', + presetMapping: { + default: 'qoder/qmodel', + opus: 'qoder/qmodel', + sonnet: 'qoder/qmodel', + haiku: 'qoder/qmodel', + }, + }, + { + id: 'qoder/dmodel', + name: 'DeepSeek V4 Pro (via Qoder)', + description: 'DeepSeek V4 Pro frontier model', + presetMapping: { + default: 'qoder/dmodel', + opus: 'qoder/dmodel', + sonnet: 'qoder/dmodel', + haiku: 'qoder/dfmodel', + }, + }, + { + id: 'qoder/dfmodel', + name: 'DeepSeek V4 Flash (via Qoder)', + description: 'DeepSeek V4 Flash frontier model', + }, + { + id: 'qoder/gm51model', + name: 'GLM 5.1 (via Qoder)', + description: 'GLM 5.1 frontier model', + presetMapping: { + default: 'qoder/gm51model', + opus: 'qoder/gm51model', + sonnet: 'qoder/gm51model', + haiku: 'qoder/gm51model', + }, + }, + { + id: 'qoder/kmodel', + name: 'Kimi K2.6 (via Qoder)', + description: 'Kimi K2.6 frontier model', + presetMapping: { + default: 'qoder/kmodel', + opus: 'qoder/kmodel', + sonnet: 'qoder/kmodel', + haiku: 'qoder/kmodel', + }, + }, + { + id: 'qoder/mmodel', + name: 'MiniMax M2.7 (via Qoder)', + description: 'MiniMax M2.7 frontier model', + presetMapping: { + default: 'qoder/mmodel', + opus: 'qoder/mmodel', + sonnet: 'qoder/mmodel', + haiku: 'qoder/mmodel', + }, + }, + ], + }, kimi: { provider: 'kimi', displayName: 'Kimi (Moonshot)', From de5b545ac35389c8e8878107dd70ff6e89ba6c5f Mon Sep 17 00:00:00 2001 From: Simon Shi <9512067+simonsmh@users.noreply.github.com> Date: Thu, 21 May 2026 23:45:14 +0800 Subject: [PATCH 011/137] feat(qoder): Change default models in base-qoder settings --- config/base-qoder.settings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/config/base-qoder.settings.json b/config/base-qoder.settings.json index 39cf7554..d4ade6d3 100644 --- a/config/base-qoder.settings.json +++ b/config/base-qoder.settings.json @@ -3,8 +3,8 @@ "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/qoder", "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed", "ANTHROPIC_MODEL": "qoder/auto", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "qoder/auto", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "qoder/auto", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "qoder/auto" + "ANTHROPIC_DEFAULT_OPUS_MODEL": "qoder/ultimate", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "qoder/performance", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "qoder/efficient" } } From a06eff61f03678f60fb055735375f53b29cc621a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 22 May 2026 11:15:06 -0400 Subject: [PATCH 012/137] fix(config): use valid Kiro default model --- config/base-kiro.settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/base-kiro.settings.json b/config/base-kiro.settings.json index 07ce1854..6243c415 100644 --- a/config/base-kiro.settings.json +++ b/config/base-kiro.settings.json @@ -2,7 +2,7 @@ "env": { "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/kiro", "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed", - "ANTHROPIC_MODEL": "kiro-auto", + "ANTHROPIC_MODEL": "kiro-claude-sonnet-4-6", "ANTHROPIC_DEFAULT_OPUS_MODEL": "kiro-claude-opus-4-7", "ANTHROPIC_DEFAULT_SONNET_MODEL": "kiro-claude-sonnet-4-6", "ANTHROPIC_DEFAULT_HAIKU_MODEL": "kiro-claude-haiku-4-5" From ba853b159b231bd97d3479701938846a83d45baa Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 22 May 2026 11:15:24 -0400 Subject: [PATCH 013/137] test: cover qoder base settings --- src/cliproxy/config/__tests__/base-config-loader.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cliproxy/config/__tests__/base-config-loader.test.ts b/src/cliproxy/config/__tests__/base-config-loader.test.ts index 88a74338..4a5bd42a 100644 --- a/src/cliproxy/config/__tests__/base-config-loader.test.ts +++ b/src/cliproxy/config/__tests__/base-config-loader.test.ts @@ -8,6 +8,7 @@ describe('base-config-loader new providers', () => { ['gitlab', '/api/provider/gitlab', 'gitlab-duo'], ['codebuddy', '/api/provider/codebuddy', 'auto'], ['kilo', '/api/provider/kilo', 'kilo/auto'], + ['qoder', '/api/provider/qoder', 'qoder/auto'], ] as const)('loads base settings for %s', (provider, baseUrlPath, defaultModel) => { const config = loadBaseConfig(provider); From ab379e9e1387d73f2672d92c5438f6421c94f4f5 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 21:03:24 -0400 Subject: [PATCH 014/137] fix: disable docker legacy API key auth by default (#1355) --- src/docker/docker-key-rotation.ts | 9 +++++++++ tests/unit/docker/docker-bootstrap.test.ts | 16 +++++++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/docker/docker-key-rotation.ts b/src/docker/docker-key-rotation.ts index 50f0f1b7..51b3fd6e 100644 --- a/src/docker/docker-key-rotation.ts +++ b/src/docker/docker-key-rotation.ts @@ -7,6 +7,7 @@ export const DOCKER_LEGACY_API_KEY = 'ccs-internal-managed'; export const DEFAULT_DOCKER_LEGACY_KEY_GRACE_DAYS = 14; export const DOCKER_LEGACY_KEY_GRACE_ENV = 'CCS_DOCKER_LEGACY_KEY_GRACE_DAYS'; export const DOCKER_RESTORE_LEGACY_KEY_ENV = 'CCS_DOCKER_RESTORE_LEGACY_API_KEY'; +export const DOCKER_ENABLE_LEGACY_KEY_AUTH_ENV = 'CCS_DOCKER_ENABLE_LEGACY_KEY_AUTH'; const DAY_MS = 24 * 60 * 60 * 1000; const STATE_VERSION = 1; @@ -67,6 +68,10 @@ export function shouldRestoreDockerLegacyApiKey(env = process.env): boolean { return env[DOCKER_RESTORE_LEGACY_KEY_ENV] === '1'; } +export function shouldEnableDockerLegacyKeyAuth(env = process.env): boolean { + return env[DOCKER_ENABLE_LEGACY_KEY_AUTH_ENV] === '1'; +} + export function readDockerBootstrapState(): DockerBootstrapStateReadResult { const statePath = getDockerBootstrapStatePath(); if (!fs.existsSync(statePath)) { @@ -135,6 +140,10 @@ export function isDockerLegacyKeyGraceActive( } export function getActiveDockerLegacyApiKeys(now = new Date()): string[] { + if (!shouldEnableDockerLegacyKeyAuth()) { + return []; + } + const { state } = readDockerBootstrapState(); if (!isDockerLegacyKeyGraceActive(state, now)) { return []; diff --git a/tests/unit/docker/docker-bootstrap.test.ts b/tests/unit/docker/docker-bootstrap.test.ts index cad19b1b..3877a4c8 100644 --- a/tests/unit/docker/docker-bootstrap.test.ts +++ b/tests/unit/docker/docker-bootstrap.test.ts @@ -23,6 +23,7 @@ import { const originalCcsHome = process.env.CCS_HOME; const originalGraceDays = process.env.CCS_DOCKER_LEGACY_KEY_GRACE_DAYS; const originalRestoreLegacyKey = process.env.CCS_DOCKER_RESTORE_LEGACY_API_KEY; +const originalEnableLegacyKeyAuth = process.env.CCS_DOCKER_ENABLE_LEGACY_KEY_AUTH; const tempDirs: string[] = []; function useTempCcsHome(): string { @@ -48,6 +49,11 @@ afterEach(() => { } else { process.env.CCS_DOCKER_RESTORE_LEGACY_API_KEY = originalRestoreLegacyKey; } + if (originalEnableLegacyKeyAuth === undefined) { + delete process.env.CCS_DOCKER_ENABLE_LEGACY_KEY_AUTH; + } else { + process.env.CCS_DOCKER_ENABLE_LEGACY_KEY_AUTH = originalEnableLegacyKeyAuth; + } for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); @@ -98,7 +104,7 @@ describe('docker bootstrap auth', () => { expect(readDockerBootstrapState().state?.legacyKeyGrace).toBeUndefined(); }); - it('preserves the legacy key during the default upgrade grace window', () => { + it('tracks legacy key grace but does not enable it in config by default', () => { useTempCcsHome(); mutateConfig((config) => { config.cliproxy.auth = { @@ -118,7 +124,7 @@ describe('docker bootstrap auth', () => { expect(state?.legacyKeyGrace?.legacyKey).toBe(CCS_INTERNAL_API_KEY); expect(state?.legacyKeyGrace?.replacementKey).toBe(config.cliproxy.auth?.api_key); expect(content).toContain(`"${config.cliproxy.auth?.api_key}"`); - expect(content).toContain(`"${CCS_INTERNAL_API_KEY}"`); + expect(content).not.toContain(`"${CCS_INTERNAL_API_KEY}"`); }); it('honors CCS_DOCKER_LEGACY_KEY_GRACE_DAYS for upgrade expiry', () => { @@ -188,7 +194,7 @@ describe('docker bootstrap auth', () => { expect(changed).toBe(true); expect(content).toContain(`"${generatedKey}"`); - expect(content).toContain(`"${CCS_INTERNAL_API_KEY}"`); + expect(content).not.toContain(`"${CCS_INTERNAL_API_KEY}"`); }); it('restores the legacy key after an earlier run wrote a no-grace marker', () => { @@ -212,7 +218,7 @@ describe('docker bootstrap auth', () => { expect(changed).toBe(true); expect(content).toContain(`"${generatedKey}"`); - expect(content).toContain(`"${CCS_INTERNAL_API_KEY}"`); + expect(content).not.toContain(`"${CCS_INTERNAL_API_KEY}"`); }); it('recovers safely from a corrupted marker file during broken-install recovery', () => { @@ -232,7 +238,7 @@ describe('docker bootstrap auth', () => { expect(getDockerBootstrapStatePath()).toContain(DOCKER_BOOTSTRAP_STATE_FILENAME); expect(readDockerBootstrapState().corrupted).toBe(false); - expect(content).toContain(`"${CCS_INTERNAL_API_KEY}"`); + expect(content).not.toContain(`"${CCS_INTERNAL_API_KEY}"`); }); it('does not treat human custom keys as broken Docker-generated keys', () => { From 1e834b8478cf8aaf61b64f3dc7496e6c904d773f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 24 May 2026 01:18:20 +0000 Subject: [PATCH 015/137] chore(release): 8.1.0-dev.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d8a507f0..39d37b63 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.1.0", + "version": "8.1.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From aef331d8efde63f9d9393f3d3a0d0adc16918dcb Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 21:19:31 -0400 Subject: [PATCH 016/137] fix(docker): pin runtime deps to committed bun lockfile (#1358) --- docker/Dockerfile | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/docker/Dockerfile b/docker/Dockerfile index ab5a4d9a..96710be9 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,4 +1,4 @@ -# syntax=docker/dockerfile:1 +# syntax=docker/dockerfile:1.7 # ============================================================================= # Build stage: compile TypeScript and build UI @@ -36,13 +36,10 @@ RUN bun run build:all # Validate build artifacts exist RUN test -d dist && test -d lib && echo "[OK] Build artifacts validated" -# Generate a fresh npm lockfile from package.json immediately after build. -# This lockfile is ephemeral — never committed; bun.lock remains the dev lockfile. -# --ignore-scripts: only lockfile generation, no install side-effects needed here. -# postinstall (scripts/postinstall.js) is a user-facing setup script; it must NOT -# run during the Docker build (it writes to ~/.ccs/ which does not exist here). -RUN --mount=type=cache,target=/root/.npm \ - npm install --package-lock-only --ignore-scripts +# Build runtime deps from the committed bun.lock to keep Docker installs pinned. +# --ignore-scripts avoids user-home side effects from postinstall during image build. +RUN --mount=type=cache,target=/root/.bun \ + bun install --frozen-lockfile --production --ignore-scripts # ============================================================================= # Runtime stage: Node-only, no Bun @@ -60,13 +57,9 @@ RUN apt-get update \ WORKDIR /app -# Copy the ephemeral lockfile generated in the build stage (never committed). -# --ignore-scripts: postinstall writes ~/.ccs/ config which is not needed inside -# the Docker image; runtime deps (bcrypt v6+, express, etc.) have no native -# compilation steps requiring postinstall. -COPY --from=build /app/package.json /app/package-lock.json ./ -RUN --mount=type=cache,target=/root/.npm \ - npm ci --omit=dev --ignore-scripts +# Copy pinned production dependencies resolved from the committed bun.lock. +COPY --from=build /app/package.json ./ +COPY --from=build /app/node_modules ./node_modules COPY docker/entrypoint.sh /usr/local/bin/ccs-entrypoint RUN chmod +x /usr/local/bin/ccs-entrypoint From 7606a31b8e60a455a1c56aa95c6036f815e65afc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 24 May 2026 01:23:22 +0000 Subject: [PATCH 017/137] chore(release): 8.1.0-dev.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 39d37b63..38bcdce3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.1.0-dev.1", + "version": "8.1.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From d9616447f9c3117fd4882357373c8d820774e3a7 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 21:24:18 -0400 Subject: [PATCH 018/137] fix(web-server): stop startup local prefix sync secret leak (#1362) --- src/web-server/index.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/web-server/index.ts b/src/web-server/index.ts index 21a0aaa2..7305eac1 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -19,8 +19,6 @@ import { isDashboardWebSocketUpgradeAllowed, } from './middleware/auth-middleware'; import { requestLoggingMiddleware } from './middleware/request-logging-middleware'; -import { ensureManagedModelPrefixes } from '../cliproxy/ai-providers/managed-model-prefixes'; -import { getProxyTarget } from '../cliproxy/proxy/proxy-target-resolver'; import { startAutoSyncWatcher, stopAutoSyncWatcher } from '../cliproxy/sync'; import { shutdownUsageAggregator } from './usage/aggregator'; import { createLogger } from '../services/logging'; @@ -172,14 +170,6 @@ export async function startServer(options: ServerOptions): Promise { - logger.warn('cliproxy.prefix_sync_failed', 'Managed model prefix repair failed', { - error: error instanceof Error ? error.message : String(error), - }); - }); - } - // Combined cleanup function const cleanup = () => { wsCleanup(); From 65ced77c4c220dcd9808f3d0df3ba302f5a6f599 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 24 May 2026 01:37:41 +0000 Subject: [PATCH 019/137] chore(release): 8.1.0-dev.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 38bcdce3..e9a67bd3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.1.0-dev.2", + "version": "8.1.0-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 9357f4df26d8ce8a07db9d52d3d5b9c307206d56 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 21:39:05 -0400 Subject: [PATCH 020/137] fix(cliproxy): redact generic token query params in oauth trace (#1364) --- src/cliproxy/auth/__tests__/oauth-trace-redactor.test.ts | 7 +++++++ src/cliproxy/auth/oauth-trace/redactor.ts | 1 + 2 files changed, 8 insertions(+) diff --git a/src/cliproxy/auth/__tests__/oauth-trace-redactor.test.ts b/src/cliproxy/auth/__tests__/oauth-trace-redactor.test.ts index 8b48261c..a60324ab 100644 --- a/src/cliproxy/auth/__tests__/oauth-trace-redactor.test.ts +++ b/src/cliproxy/auth/__tests__/oauth-trace-redactor.test.ts @@ -85,6 +85,13 @@ describe('redactString', () => { expect(out).not.toContain('AT_SECRET'); expect(out).toBe(`access_token=${REDACTED_PLACEHOLDER}&keep=1`); }); + + test('redacts ampersand-delimited generic token query params', () => { + const out = redactString('access_token=AT_SECRET&token=TOKEN_SECRET'); + expect(out).not.toContain('AT_SECRET'); + expect(out).not.toContain('TOKEN_SECRET'); + expect(out).toBe(`access_token=${REDACTED_PLACEHOLDER}&token=${REDACTED_PLACEHOLDER}`); + }); }); describe('redactUrl', () => { diff --git a/src/cliproxy/auth/oauth-trace/redactor.ts b/src/cliproxy/auth/oauth-trace/redactor.ts index 62c74902..40ead575 100644 --- a/src/cliproxy/auth/oauth-trace/redactor.ts +++ b/src/cliproxy/auth/oauth-trace/redactor.ts @@ -10,6 +10,7 @@ const SENSITIVE_QUERY_KEYS = [ 'code', 'state', + 'token', 'access_token', 'refresh_token', 'id_token', From 1065f68eae40de91f3d78cab11f8c7927a064405 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 24 May 2026 01:42:35 +0000 Subject: [PATCH 021/137] chore(release): 8.1.0-dev.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e9a67bd3..4149e18d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.1.0-dev.3", + "version": "8.1.0-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 0bd2e577b21401f586fb9cbee18de3baa2cfc817 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 21:43:51 -0400 Subject: [PATCH 022/137] fix(codex-auth): restrict symlink fallback and harden copy path (#1365) --- src/codex-auth/codex-config-symlink.ts | 29 ++++++++++++++++--- .../codex-auth/codex-config-symlink.test.ts | 13 +++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/codex-auth/codex-config-symlink.ts b/src/codex-auth/codex-config-symlink.ts index 7857677d..1fb3fc61 100644 --- a/src/codex-auth/codex-config-symlink.ts +++ b/src/codex-auth/codex-config-symlink.ts @@ -94,7 +94,7 @@ export function ensureSharedConfigSymlink( target: targetPath, }); } catch (err) { - copySharedConfigFallback(targetPath, linkPath, err); + handleSymlinkFailure(targetPath, linkPath, err); } } @@ -106,9 +106,30 @@ function isRegularConfigCopyUnmodified(linkPath: string, targetPath: string): bo } } -function copySharedConfigFallback(targetPath: string, linkPath: string, err: unknown): void { - fs.copyFileSync(targetPath, linkPath); - fs.chmodSync(linkPath, 0o600); +function handleSymlinkFailure(targetPath: string, linkPath: string, err: unknown): void { + const code = (err as NodeJS.ErrnoException | null)?.code; + if (code !== 'EPERM' && code !== 'EACCES' && code !== 'ENOSYS') { + throw err; + } + + const tmpPath = path.join( + path.dirname(linkPath), + `.config.toml.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}` + ); + + try { + fs.copyFileSync(targetPath, tmpPath, fs.constants.COPYFILE_EXCL); + fs.chmodSync(tmpPath, 0o600); + fs.renameSync(tmpPath, linkPath); + } catch (copyErr) { + try { + fs.rmSync(tmpPath, { force: true }); + } catch { + // best effort cleanup + } + throw copyErr; + } + process.stderr.write( `[!] codex-auth: symlink unavailable; copied shared config.toml to ${linkPath}. ` + `Config edits won't propagate automatically.\n` diff --git a/tests/unit/codex-auth/codex-config-symlink.test.ts b/tests/unit/codex-auth/codex-config-symlink.test.ts index fc94a188..03206fac 100644 --- a/tests/unit/codex-auth/codex-config-symlink.test.ts +++ b/tests/unit/codex-auth/codex-config-symlink.test.ts @@ -143,6 +143,19 @@ describe('ensureSharedConfigSymlink', () => { expect(stderrChunks.join('')).toContain('symlink unavailable'); }); + + it('rethrows symlink errors that are not fallback-safe', () => { + fs.writeFileSync(sharedConfigPath, 'model = "gpt-5.5"\n', { mode: 0o600 }); + const symlinkSpy = spyOn(fs, 'symlinkSync').mockImplementation(() => { + throw Object.assign(new Error('simulated race'), { code: 'EEXIST' }); + }); + + try { + expect(() => ensureSharedConfigSymlink(profileDir, sharedConfigPath)).toThrow(/simulated race/); + } finally { + symlinkSpy.mockRestore(); + } + }); it('preserves edited fallback copies on later repair attempts', () => { fs.writeFileSync(sharedConfigPath, 'model = "gpt-5.5"\n', { mode: 0o600 }); const linkPath = path.join(profileDir, 'config.toml'); From 21764d5b90c8db06f5c52a1747e2e41f65da478b Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 21:44:19 -0400 Subject: [PATCH 023/137] fix(web): harden localhost guard against DNS rebinding (#1357) * fix(web): harden localhost guard against DNS rebinding * style: apply prettier formatting --- src/web-server/middleware/auth-middleware.ts | 34 +++++++++++++-- .../codex-profiles-endpoint.test.ts | 42 +++++++++++++++++-- 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/src/web-server/middleware/auth-middleware.ts b/src/web-server/middleware/auth-middleware.ts index 204b4c97..296cb2cd 100644 --- a/src/web-server/middleware/auth-middleware.ts +++ b/src/web-server/middleware/auth-middleware.ts @@ -246,10 +246,36 @@ export function requireLocalAccessWhenAuthDisabled( return true; } - if (isLoopbackRemoteAddress(req.socket.remoteAddress)) { - return true; + if (!isLoopbackRemoteAddress(req.socket.remoteAddress)) { + res.status(403).json({ error }); + return false; } - res.status(403).json({ error }); - return false; + const host = parseHostHeader(getSingleHeader(req.headers.host)); + if (!host || !isLoopbackHostname(host.hostname)) { + res.status(403).json({ error }); + return false; + } + + const originHeader = getSingleHeader(req.headers.origin); + if (originHeader) { + let origin: URL; + try { + origin = new URL(originHeader); + } catch { + res.status(403).json({ error }); + return false; + } + + const isSameHost = origin.host.toLowerCase() === host.host.toLowerCase(); + const isLoopbackAlias = + isHttpOrigin(origin) && isLoopbackHostname(origin.hostname) && origin.port === host.port; + + if (!isSameHost && !isLoopbackAlias) { + res.status(403).json({ error }); + return false; + } + } + + return true; } diff --git a/tests/integration/web-server/codex-profiles-endpoint.test.ts b/tests/integration/web-server/codex-profiles-endpoint.test.ts index 44b3ce05..1c3a71a0 100644 --- a/tests/integration/web-server/codex-profiles-endpoint.test.ts +++ b/tests/integration/web-server/codex-profiles-endpoint.test.ts @@ -206,9 +206,8 @@ describe('GET /api/codex/profiles', () => { // 127.0.0.1 and the test client connects to 127.0.0.1, the built-in fetch // will always be loopback. We test the guard directly via a separate // Express app that injects a non-loopback remote address. - const { requireLocalAccessWhenAuthDisabled } = await import( - '../../../src/web-server/middleware/auth-middleware' - ); + const { requireLocalAccessWhenAuthDisabled } = + await import('../../../src/web-server/middleware/auth-middleware'); const { isDashboardAuthEnabled } = await import('../../../src/config/config-loader-facade'); if (!isDashboardAuthEnabled()) { @@ -245,6 +244,43 @@ describe('GET /api/codex/profiles', () => { } }); + it('returns 403 for loopback remote addresses when host/origin indicate non-local origin', async () => { + const { requireLocalAccessWhenAuthDisabled } = + await import('../../../src/web-server/middleware/auth-middleware'); + const { isDashboardAuthEnabled } = await import('../../../src/config/config-loader-facade'); + + if (!isDashboardAuthEnabled()) { + let guardResult: boolean | undefined; + + const testApp = express(); + testApp.get('/test', (req, res) => { + Object.defineProperty(req, 'socket', { + value: { remoteAddress: '127.0.0.1' }, + writable: true, + configurable: true, + }); + req.headers.host = 'attacker.example.test'; + req.headers.origin = 'http://attacker.example.test'; + + guardResult = requireLocalAccessWhenAuthDisabled(req, res, 'localhost only'); + if (guardResult) { + res.json({ ok: true }); + } + }); + + const testServer = await new Promise((resolve) => { + const s = testApp.listen(0, '127.0.0.1', () => resolve(s)); + }); + const testPort = (testServer.address() as { port: number }).port; + + const res = await fetch(`http://127.0.0.1:${testPort}/test`); + await new Promise((resolve) => testServer.close(() => resolve())); + + expect(res.status).toBe(403); + expect(guardResult).toBe(false); + } + }); + it('response body contains no token substrings for a valid profile', async () => { const instancesDir = path.join(ccsDir, 'codex-instances'); const workDir = path.join(instancesDir, 'work'); From 2ec23c6c3a277c9e901cbee588683f4ca7662837 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 21:44:32 -0400 Subject: [PATCH 024/137] fix(sse): make CRLF normalization chunk-boundary safe (#1359) * fix(sse): handle CRLF split across chunk boundaries * style: apply prettier formatting --- src/glmt/sse-parser.ts | 17 ++++++++++++++++- tests/unit/glmt/sse-parser.test.ts | 14 ++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/glmt/sse-parser.ts b/src/glmt/sse-parser.ts index d6fff5d6..a4cb12ba 100644 --- a/src/glmt/sse-parser.ts +++ b/src/glmt/sse-parser.ts @@ -35,12 +35,14 @@ export class SSEParser { private eventCount: number; private maxBufferSize: number; private throwOnMalformedJson: boolean; + private pendingCR: boolean; constructor(options: SSEParserOptions = {}) { this.buffer = ''; this.eventCount = 0; this.maxBufferSize = options.maxBufferSize || 1024 * 1024; // 1MB default this.throwOnMalformedJson = options.throwOnMalformedJson === true; + this.pendingCR = false; } /** @@ -49,7 +51,19 @@ export class SSEParser { * @returns Array of parsed events */ parse(chunk: Buffer | string): SSEEvent[] { - this.buffer += chunk.toString().replace(/\r\n?/g, '\n'); + let normalizedChunk = chunk.toString(); + + if (this.pendingCR) { + if (normalizedChunk.startsWith('\n')) { + normalizedChunk = normalizedChunk.slice(1); + } + this.pendingCR = false; + } + + const endsWithCR = normalizedChunk.endsWith('\r'); + normalizedChunk = normalizedChunk.replace(/\r\n?/g, '\n'); + this.pendingCR = endsWithCR; + this.buffer += normalizedChunk; // C-01 Fix: Prevent unbounded buffer growth (DoS protection) if (this.buffer.length > this.maxBufferSize) { @@ -127,5 +141,6 @@ export class SSEParser { reset(): void { this.buffer = ''; this.eventCount = 0; + this.pendingCR = false; } } diff --git a/tests/unit/glmt/sse-parser.test.ts b/tests/unit/glmt/sse-parser.test.ts index 740fc0e1..bfe35016 100644 --- a/tests/unit/glmt/sse-parser.test.ts +++ b/tests/unit/glmt/sse-parser.test.ts @@ -46,4 +46,18 @@ describe('SSEParser', () => { ?.delta?.content ).toBe('Legacy'); }); + + + it('does not split events when CRLF is split across chunks', () => { + const parser = new SSEParser({ throwOnMalformedJson: true }); + + expect(parser.parse('data: {"choices":[\r')).toEqual([]); + const events = parser.parse('\ndata: {"delta":{"content":"Hello"}}\r\ndata: ]}\r\n\r\n'); + + expect(events).toHaveLength(1); + expect( + (events[0]?.data as { choices?: Array<{ delta?: { content?: string } }> })?.choices?.[0] + ?.delta?.content + ).toBe('Hello'); + }); }); From e658e838f906bebebde8484df6f492bcda737abe Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 21:44:35 -0400 Subject: [PATCH 025/137] fix(cliproxy): scrub GitLab PAT env after token-login auth (#1360) * fix(cliproxy): scrub gitlab pat env var after auth handoff * style: apply prettier formatting --- src/cliproxy/auth/oauth-handler.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index 2622d8ff..a326722a 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -999,8 +999,8 @@ async function handleGitLabPatLogin( const baseUrl = normalizeGitLabBaseUrl(options?.gitlabBaseUrl); const knownTokenFiles = listProviderTokenSnapshots(provider, tokenDir); const suppliedToken = options?.gitlabPersonalAccessToken?.trim(); - const personalAccessToken = - suppliedToken || process.env['GITLAB_PERSONAL_ACCESS_TOKEN']?.trim() || undefined; + const envPersonalAccessToken = process.env['GITLAB_PERSONAL_ACCESS_TOKEN']?.trim() || undefined; + const personalAccessToken = suppliedToken || envPersonalAccessToken; let token = personalAccessToken; if (!token) { @@ -1015,6 +1015,11 @@ async function handleGitLabPatLogin( return null; } + // PAT provided via process env should never be forwarded to downstream runtime. + if (!suppliedToken && envPersonalAccessToken) { + delete process.env['GITLAB_PERSONAL_ACCESS_TOKEN']; + } + const response = await fetch(buildProxyUrl(target, '/v0/management/gitlab-auth-url'), { method: 'POST', headers: { From 2188cdc9907cf0934f074a9503501edd68f708e8 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 21:44:37 -0400 Subject: [PATCH 026/137] fix(cliproxy): clean up upstream proxy on response disconnect (#1361) * fix(cliproxy): clean up upstream proxy on response disconnect * style: apply prettier formatting --- src/web-server/routes/cliproxy-local-proxy.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/web-server/routes/cliproxy-local-proxy.ts b/src/web-server/routes/cliproxy-local-proxy.ts index 9c512e96..4454a297 100644 --- a/src/web-server/routes/cliproxy-local-proxy.ts +++ b/src/web-server/routes/cliproxy-local-proxy.ts @@ -134,13 +134,21 @@ export function createCliproxyLocalProxyRouter(deps: CliproxyLocalProxyDeps = {} } }); - // Clean up proxy connection only when the client aborts the request. - // Avoid res.on('close') here because Bun may emit it during local error - // responses before the JSON body is flushed, which can truncate 502 payloads. - req.on('aborted', () => { - if (!res.writableEnded) { + const cleanupProxyRequest = () => { + if (!proxyReq.destroyed) { proxyReq.destroy(); } + }; + + // Request-abort cleanup covers disconnects before the response starts. + req.on('aborted', cleanupProxyRequest); + + // Response close cleanup covers disconnects while streaming the proxied response. + // Guard on writableEnded/finished so successful proxy completions are untouched. + res.on('close', () => { + if (!res.writableEnded || !res.finished) { + cleanupProxyRequest(); + } }); if (bodyBuffer) { From 8b3ba5532e07af52f89cf012be63eafe2046e7b8 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 21:44:40 -0400 Subject: [PATCH 027/137] fix(browser): log only matched intercepted requests (#1366) --- lib/mcp/ccs-browser-server.cjs | 22 ++++++++++--------- ...browser-mcp-session-and-intercepts.test.ts | 3 +-- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index bfb0d2f2..a69c413f 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -5097,16 +5097,18 @@ async function ensureInterceptSession(page) { }) ); } - pushRecentRequest({ - requestId: String(paused.requestId || ''), - pageId: page.id, - url: String(paused.request?.url || ''), - method: String(paused.request?.method || ''), - resourceType: String(paused.resourceType || ''), - matchedRuleId: matchedRule ? matchedRule.ruleId : '', - action, - statusCode: action === 'fulfill' ? matchedRule.statusCode : 0, - }); + if (matchedRule) { + pushRecentRequest({ + requestId: String(paused.requestId || ''), + pageId: page.id, + url: String(paused.request?.url || ''), + method: String(paused.request?.method || ''), + resourceType: String(paused.resourceType || ''), + matchedRuleId: matchedRule.ruleId, + action, + statusCode: action === 'fulfill' ? matchedRule.statusCode : 0, + }); + } })(); activityChain = activityChain .catch(() => {}) diff --git a/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts b/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts index 5d324aa7..7f160f9e 100644 --- a/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts +++ b/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts @@ -780,8 +780,7 @@ describe('ccs-browser MCP server - session and interception', () => { expect(listText).toContain('requestId: req-1'); expect(listText).toContain('matchedRuleId: rule-1'); expect(listText).toContain('action: fail'); - expect(listText).toContain('requestId: req-2'); - expect(listText).toContain('action: continue'); + expect(listText).not.toContain('requestId: req-2'); }); it('removes rules and recent requests bound to a page after that page is closed', async () => { From c322829f44f8bf4edd43fe4d8fc97560b6fe180a Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 21:44:42 -0400 Subject: [PATCH 028/137] fix(browser): cap click and key repeat counts in MCP (#1367) * fix(browser): cap click and key repeat counts in MCP * style: apply prettier formatting --- lib/mcp/ccs-browser-server.cjs | 15 +++++-- .../browser-mcp-navigation-and-query.test.ts | 41 +++++++++++++++++++ ...browser-mcp-session-and-intercepts.test.ts | 6 +++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index a69c413f..96ea4cb5 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -159,6 +159,8 @@ const DEFAULT_WAIT_TIMEOUT_MS = 2000; const DEFAULT_WAIT_POLL_INTERVAL_MS = 100; const DEFAULT_DRAG_STEPS = 5; const MAX_POINTER_ACTIONS = 25; +const MAX_CLICK_COUNT = 25; +const MAX_KEY_REPEAT = 25; const SESSION_START_SETTLE_WINDOW_MS = 250; const MAX_ARTIFACT_FILE_BYTES = 5 * 1024 * 1024; const MAX_LOCAL_TRANSFER_FILE_BYTES = 10 * 1024 * 1024; @@ -454,6 +456,7 @@ function getTools() { clickCount: { type: 'integer', minimum: 1, + maximum: MAX_CLICK_COUNT, description: 'Optional click count. Defaults to 1.', }, }, @@ -519,6 +522,7 @@ function getTools() { repeat: { type: 'integer', minimum: 1, + maximum: MAX_KEY_REPEAT, description: 'Optional repeat count. Defaults to 1.', }, }, @@ -2305,10 +2309,13 @@ function requirePositiveIntegerOrDefault(value, label, fallback) { return value; } -function requirePositiveInteger(value, label) { +function requirePositiveInteger(value, label, maximum = undefined) { if (!Number.isInteger(value) || value <= 0) { throw new Error(`${label} must be a positive integer`); } + if (maximum !== undefined && value > maximum) { + throw new Error(`${label} must be less than or equal to ${maximum}`); + } return value; } @@ -3555,7 +3562,7 @@ async function handleClick(toolArgs) { const clickCount = toolArgs.clickCount === undefined ? 1 - : requirePositiveInteger(toolArgs.clickCount, 'clickCount'); + : requirePositiveInteger(toolArgs.clickCount, 'clickCount', MAX_CLICK_COUNT); const expression = `(() => { const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); @@ -3805,7 +3812,9 @@ async function handlePressKey(toolArgs) { 'Shift', ]); const repeat = - toolArgs.repeat === undefined ? 1 : requirePositiveInteger(toolArgs.repeat, 'repeat'); + toolArgs.repeat === undefined + ? 1 + : requirePositiveInteger(toolArgs.repeat, 'repeat', MAX_KEY_REPEAT); const modifierMask = (modifiers.includes('Alt') ? 1 : 0) | (modifiers.includes('Control') ? 2 : 0) | diff --git a/tests/unit/hooks/browser-mcp-navigation-and-query.test.ts b/tests/unit/hooks/browser-mcp-navigation-and-query.test.ts index bbd09250..5e38d7e8 100644 --- a/tests/unit/hooks/browser-mcp-navigation-and-query.test.ts +++ b/tests/unit/hooks/browser-mcp-navigation-and-query.test.ts @@ -722,6 +722,47 @@ describe('ccs-browser MCP server - navigation and query', () => { ); }); + + it('rejects excessive clickCount and repeat values', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Limits Page', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 713, + method: 'tools/call', + params: { + name: 'browser_click', + arguments: { + selector: '#submit', + clickCount: 26, + }, + }, + }, + { + jsonrpc: '2.0', + id: 714, + method: 'tools/call', + params: { + name: 'browser_press_key', + arguments: { + key: 'k', + repeat: 26, + }, + }, + }, + ] + ); + + const clickResponse = responses.find((message) => message.id === 713); + expect((clickResponse?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(clickResponse)).toContain('clickCount must be less than or equal to 25'); + + const keyResponse = responses.find((message) => message.id === 714); + expect((keyResponse?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(keyResponse)).toContain('repeat must be less than or equal to 25'); + }); + it('scrolls an element into view with browser_scroll', async () => { const responses = await runMcpRequests( [ diff --git a/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts b/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts index 7f160f9e..6c2aac6a 100644 --- a/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts +++ b/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts @@ -79,11 +79,17 @@ describe('ccs-browser MCP server - session and interception', () => { expect(clickTool?.inputSchema?.properties?.clickCount).toMatchObject({ type: 'integer', minimum: 1, + maximum: 25, }); const keyTool = tools.find((tool) => tool.name === 'browser_press_key'); expect(keyTool?.inputSchema?.properties?.key).toMatchObject({ type: 'string' }); expect(keyTool?.inputSchema?.properties?.modifiers).toMatchObject({ type: 'array' }); + expect(keyTool?.inputSchema?.properties?.repeat).toMatchObject({ + type: 'integer', + minimum: 1, + maximum: 25, + }); const scrollTool = tools.find((tool) => tool.name === 'browser_scroll'); expect(scrollTool?.inputSchema?.properties?.deltaX).toMatchObject({ type: 'number' }); From 4e7894a3daa5105d2b9c32d456748b1e485d78b7 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 21:44:44 -0400 Subject: [PATCH 029/137] fix: honor browser eval env override in attach config (#1368) --- src/utils/browser/browser-settings.ts | 15 +++++++-- .../unit/utils/browser/browser-status.test.ts | 31 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/utils/browser/browser-settings.ts b/src/utils/browser/browser-settings.ts index 89faa134..1c7fcc25 100644 --- a/src/utils/browser/browser-settings.ts +++ b/src/utils/browser/browser-settings.ts @@ -232,6 +232,8 @@ export function getEffectiveClaudeBrowserAttachConfig( resolveBrowserUserDataDir(config.claude.user_data_dir) ?? getRecommendedBrowserUserDataDir(); const configPort = normalizeDevtoolsPort(config.claude.devtools_port); const configEvalMode = config.claude.eval_mode ?? 'readonly'; + const envEvalMode = parseBrowserEvalMode(env.CCS_BROWSER_EVAL_MODE); + const effectiveEvalMode = envEvalMode ?? configEvalMode; if (override.userDataDir) { return { @@ -241,7 +243,7 @@ export function getEffectiveClaudeBrowserAttachConfig( userDataDir: override.userDataDir, devtoolsPort: override.devtoolsPort ?? configPort, hasExplicitDevtoolsPort: override.devtoolsPort !== undefined, - evalMode: configEvalMode, + evalMode: effectiveEvalMode, }; } @@ -252,7 +254,7 @@ export function getEffectiveClaudeBrowserAttachConfig( userDataDir: configUserDataDir, devtoolsPort: configPort, hasExplicitDevtoolsPort: true, - evalMode: configEvalMode, + evalMode: effectiveEvalMode, }; } @@ -304,6 +306,15 @@ function parseDevtoolsPort(value?: string): number | undefined { return normalizeDevtoolsPort(Number.parseInt(value.trim(), 10)); } +function parseBrowserEvalMode(value?: string): BrowserEvalMode | undefined { + const trimmed = value?.trim(); + if (trimmed === 'disabled' || trimmed === 'readonly' || trimmed === 'readwrite') { + return trimmed; + } + + return undefined; +} + function normalizeDevtoolsPort(value: number | undefined): number { if (!Number.isFinite(value)) { return 9222; diff --git a/tests/unit/utils/browser/browser-status.test.ts b/tests/unit/utils/browser/browser-status.test.ts index 9add8441..6033eecf 100644 --- a/tests/unit/utils/browser/browser-status.test.ts +++ b/tests/unit/utils/browser/browser-status.test.ts @@ -22,6 +22,7 @@ describe('browser status', () => { let originalBrowserUserDataDir: string | undefined; let originalBrowserProfileDir: string | undefined; let originalBrowserDevtoolsPort: string | undefined; + let originalBrowserEvalMode: string | undefined; beforeEach(() => { tempHome = mkdtempSync(join(tmpdir(), 'ccs-browser-status-')); @@ -29,11 +30,13 @@ describe('browser status', () => { originalBrowserUserDataDir = process.env.CCS_BROWSER_USER_DATA_DIR; originalBrowserProfileDir = process.env.CCS_BROWSER_PROFILE_DIR; originalBrowserDevtoolsPort = process.env.CCS_BROWSER_DEVTOOLS_PORT; + originalBrowserEvalMode = process.env.CCS_BROWSER_EVAL_MODE; process.env.CCS_HOME = tempHome; delete process.env.CCS_BROWSER_USER_DATA_DIR; delete process.env.CCS_BROWSER_PROFILE_DIR; delete process.env.CCS_BROWSER_DEVTOOLS_PORT; + delete process.env.CCS_BROWSER_EVAL_MODE; }); afterEach(() => { @@ -60,6 +63,11 @@ describe('browser status', () => { } else { delete process.env.CCS_BROWSER_DEVTOOLS_PORT; } + if (originalBrowserEvalMode !== undefined) { + process.env.CCS_BROWSER_EVAL_MODE = originalBrowserEvalMode; + } else { + delete process.env.CCS_BROWSER_EVAL_MODE; + } rmSync(tempHome, { recursive: true, force: true }); }); @@ -290,6 +298,29 @@ describe('browser status', () => { }); }); + it('honors CCS_BROWSER_EVAL_MODE over configured eval_mode', () => { + mutateUnifiedConfig((config) => { + config.browser = { + claude: { + enabled: true, + policy: 'manual', + user_data_dir: '/config-browser', + devtools_port: 9333, + eval_mode: 'readonly', + }, + codex: { + enabled: false, + policy: 'manual', + }, + }; + }); + process.env.CCS_BROWSER_EVAL_MODE = 'disabled'; + + const effective = getEffectiveClaudeBrowserAttachConfig(getBrowserConfig()); + + expect(effective.evalMode).toBe('disabled'); + }); + it('returns the same managed attach warning when the configured DevTools port is unreachable', async () => { const managedDir = join(tempHome, '.ccs', 'browser', 'chrome-user-data'); mkdirSync(managedDir, { recursive: true }); From 37fdf90e304507a3fc4e3e0066bb1856a05e74bc Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 21:44:47 -0400 Subject: [PATCH 030/137] fix(ci): constrain PR-Agent GitHub App token permissions (#1370) * fix(ci): constrain pr-agent app token permissions * style: apply prettier formatting --- .github/workflows/ai-review.yml | 4 ++++ tests/unit/scripts/github/ai-review-workflow.test.ts | 3 +++ 2 files changed, 7 insertions(+) diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml index 28b41d1f..ed6d044d 100644 --- a/.github/workflows/ai-review.yml +++ b/.github/workflows/ai-review.yml @@ -48,6 +48,7 @@ jobs: with: app-id: ${{ secrets.CCS_REVIEWER_APP_ID }} private-key: ${{ secrets.CCS_REVIEWER_PRIVATE_KEY }} + permission-issues: write - name: Post /review command to the PR uses: actions/github-script@v7 @@ -111,6 +112,9 @@ jobs: with: app-id: ${{ secrets.CCS_REVIEWER_APP_ID }} private-key: ${{ secrets.CCS_REVIEWER_PRIVATE_KEY }} + permission-issues: write + permission-pull-requests: write + permission-contents: read - name: Run PR-Agent uses: qodo-ai/pr-agent@v0.34 diff --git a/tests/unit/scripts/github/ai-review-workflow.test.ts b/tests/unit/scripts/github/ai-review-workflow.test.ts index 017112db..e454aea8 100644 --- a/tests/unit/scripts/github/ai-review-workflow.test.ts +++ b/tests/unit/scripts/github/ai-review-workflow.test.ts @@ -44,6 +44,9 @@ describe('PR-Agent review lane migration', () => { expect(workflow).toContain('CCS_REVIEWER_APP_ID'); expect(workflow).toContain('CCS_REVIEWER_PRIVATE_KEY'); expect(workflow).toContain('id: pr-agent-app-token'); + expect(workflow).toContain('permission-issues: write'); + expect(workflow).toContain('permission-pull-requests: write'); + expect(workflow).toContain('permission-contents: read'); expect(workflow).toContain('GITHUB_TOKEN: ${{ steps.pr-agent-app-token.outputs.token }}'); expect(workflow).not.toContain('GITHUB_TOKEN: ${{ github.token }}'); expect(workflow).not.toContain('uses: anthropics/claude-code-action@v1'); From e6f764c79b94209bcbeaabfd8ee94ae364f821e1 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 21:44:50 -0400 Subject: [PATCH 031/137] fix(cursor): block cross-origin runtime probe requests (#1371) --- src/web-server/routes/cursor-routes.ts | 8 +++++++- tests/unit/web-server/cursor-routes.test.ts | 11 +++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/web-server/routes/cursor-routes.ts b/src/web-server/routes/cursor-routes.ts index 220dae9a..f3c5eafc 100644 --- a/src/web-server/routes/cursor-routes.ts +++ b/src/web-server/routes/cursor-routes.ts @@ -18,6 +18,7 @@ import { import cursorSettingsRoutes from './cursor-settings-routes'; import { getCursorConfig } from '../../config/config-loader-facade'; +import { isDashboardWebSocketOriginAllowed } from '../middleware/auth-middleware'; const router = Router(); @@ -192,7 +193,12 @@ router.get('/models', async (_req: Request, res: Response): Promise => { /** * POST /api/cursor/probe - Run a live authenticated runtime probe */ -router.post('/probe', async (_req: Request, res: Response): Promise => { +router.post('/probe', async (req: Request, res: Response): Promise => { + if (!isDashboardWebSocketOriginAllowed(req)) { + res.status(403).json({ error: 'Cross-origin probe requests are not allowed.' }); + return; + } + try { const cursorConfig = getCursorConfig(); const result = await probeCursorRuntime(cursorConfig); diff --git a/tests/unit/web-server/cursor-routes.test.ts b/tests/unit/web-server/cursor-routes.test.ts index 5934ed9c..2ff96d7b 100644 --- a/tests/unit/web-server/cursor-routes.test.ts +++ b/tests/unit/web-server/cursor-routes.test.ts @@ -510,6 +510,17 @@ describe('Cursor Routes Logic', () => { expect(json.current).toBe('gpt-5.3-codex'); }); + it('POST /api/cursor/probe rejects cross-origin requests', async () => { + const res = await fetch(`${baseUrl}/api/cursor/probe`, { + method: 'POST', + headers: { Origin: 'https://attacker.example' }, + }); + expect(res.status).toBe(403); + + const json = (await res.json()) as { error?: string }; + expect(json.error).toContain('Cross-origin probe requests are not allowed.'); + }); + it('POST /api/cursor/probe returns auth failure when credentials are missing', async () => { const res = await fetch(`${baseUrl}/api/cursor/probe`, { method: 'POST' }); expect(res.status).toBe(401); From 6c0f7ac39c17b99bd4259eb4eca35792aea31dff Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 21:44:53 -0400 Subject: [PATCH 032/137] fix(dashboard): clear local control-panel management key (#1372) --- .../components/cliproxy/control-panel-embed.tsx | 16 ++++++++++++++++ .../cliproxy/control-panel-embed.test.tsx | 2 ++ 2 files changed, 18 insertions(+) diff --git a/ui/src/components/cliproxy/control-panel-embed.tsx b/ui/src/components/cliproxy/control-panel-embed.tsx index 1a5c6b1d..6a013a6e 100644 --- a/ui/src/components/cliproxy/control-panel-embed.tsx +++ b/ui/src/components/cliproxy/control-panel-embed.tsx @@ -57,6 +57,10 @@ function clearLocalControlPanelSession(): void { window.localStorage.removeItem(CONTROL_PANEL_LOGIN_FLAG_KEY); } +function clearPersistedLocalControlPanelSecret(): void { + window.localStorage.removeItem(CONTROL_PANEL_MANAGEMENT_KEY); +} + export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanelEmbedProps) { const { t } = useTranslation(); const iframeRef = useRef(null); @@ -162,7 +166,13 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel return; } + const handleBeforeUnload = () => { + clearLocalControlPanelSession(); + }; + window.addEventListener('beforeunload', handleBeforeUnload); + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); clearLocalControlPanelSession(); }; }, [isRemote]); @@ -266,9 +276,15 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel const handleIframeLoad = () => { setLoadedFrameKey(iframeKey); postRemoteAutoLoginCredentials(); + if (!isRemote) { + clearPersistedLocalControlPanelSecret(); + } }; const handleRefresh = () => { + if (!isRemote) { + clearLocalControlPanelSession(); + } setLoadedFrameKey(null); setIframeRevision((value) => value + 1); setError(null); diff --git a/ui/tests/unit/components/cliproxy/control-panel-embed.test.tsx b/ui/tests/unit/components/cliproxy/control-panel-embed.test.tsx index e70c0a52..c2463db5 100644 --- a/ui/tests/unit/components/cliproxy/control-panel-embed.test.tsx +++ b/ui/tests/unit/components/cliproxy/control-panel-embed.test.tsx @@ -94,6 +94,7 @@ describe('ControlPanelEmbed', () => { const iframe = await screen.findByTitle('CLIProxy Management Panel'); expect(iframe).toHaveAttribute('src', '/api/cliproxy-local/management.html'); + fireEvent.load(iframe); await waitFor(() => { expect(window.localStorage.removeItem).toHaveBeenCalledWith('cli-proxy-auth'); @@ -107,6 +108,7 @@ describe('ControlPanelEmbed', () => { ); expect(window.localStorage.setItem).toHaveBeenCalledWith('managementKey', 'custom-secret'); expect(window.localStorage.setItem).toHaveBeenCalledWith('isLoggedIn', 'true'); + expect(window.localStorage.removeItem).toHaveBeenCalledWith('managementKey'); }); vi.clearAllMocks(); From 03a30119d99753e52f4726f2f0a2078118690f3e Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 21:44:55 -0400 Subject: [PATCH 033/137] fix(cursor): authenticate local daemon health/runtime traffic (#1373) --- src/cursor/cursor-daemon-auth.ts | 24 +++++++++++++++++ src/cursor/cursor-daemon-entry.ts | 26 +++++++++++++++++++ src/cursor/cursor-daemon.ts | 11 +++++--- src/cursor/cursor-models.ts | 3 ++- src/cursor/cursor-profile-executor.ts | 11 +++++--- src/cursor/cursor-runtime-probe.ts | 6 +++-- src/cursor/types.ts | 1 + src/shared/claude-extension-setup.ts | 7 ++++- tests/unit/cursor/cursor-daemon.test.ts | 2 +- .../cursor/cursor-profile-executor.test.ts | 3 ++- 10 files changed, 82 insertions(+), 12 deletions(-) create mode 100644 src/cursor/cursor-daemon-auth.ts diff --git a/src/cursor/cursor-daemon-auth.ts b/src/cursor/cursor-daemon-auth.ts new file mode 100644 index 00000000..23b6bd39 --- /dev/null +++ b/src/cursor/cursor-daemon-auth.ts @@ -0,0 +1,24 @@ +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { getCcsDir } from '../utils/config-manager'; + +const DAEMON_TOKEN_FILE = 'cursor-daemon-token'; + +export function getCursorDaemonToken(): string { + const tokenPath = path.join(getCcsDir(), DAEMON_TOKEN_FILE); + try { + const token = fs.readFileSync(tokenPath, 'utf8').trim(); + if (token.length > 0) { + return token; + } + } catch { + // Token file missing or unreadable; regenerate below. + } + + const token = crypto.randomBytes(32).toString('hex'); + fs.mkdirSync(path.dirname(tokenPath), { recursive: true }); + fs.writeFileSync(tokenPath, token, { mode: 0o600 }); + return token; +} diff --git a/src/cursor/cursor-daemon-entry.ts b/src/cursor/cursor-daemon-entry.ts index 868121da..53e05645 100644 --- a/src/cursor/cursor-daemon-entry.ts +++ b/src/cursor/cursor-daemon-entry.ts @@ -108,6 +108,23 @@ function readJsonBody(req: http.IncomingMessage): Promise { }); } +function hasValidDaemonToken(req: http.IncomingMessage): boolean { + const expectedToken = process.env.CCS_CURSOR_DAEMON_TOKEN; + if (!expectedToken) { + return false; + } + + const provided = req.headers['x-ccs-cursor-token']; + if (typeof provided === 'string') { + return provided === expectedToken; + } + + if (Array.isArray(provided)) { + return provided.includes(expectedToken); + } + + return false; +} function normalizeMessages(raw: unknown): NormalizedOpenAIMessage[] { if (!Array.isArray(raw)) { throw new Error('messages must be an array'); @@ -202,6 +219,10 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser try { if (method === 'GET' && requestUrl === '/health') { + if (!hasValidDaemonToken(req)) { + writeJson(res, 401, { error: 'Unauthorized' }); + return; + } writeJson(res, 200, { ok: true, service: 'cursor-daemon' }); return; } @@ -234,6 +255,11 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser return; } + if (!hasValidDaemonToken(req)) { + writeJson(res, 401, { error: 'Unauthorized' }); + return; + } + const rawBody = await readJsonBody(req); const anthropicBody = isAnthropicRoute ? translateAnthropicRequest(rawBody) : undefined; const parsedBody = anthropicBody ?? ((rawBody as OpenAIChatRequest) || {}); diff --git a/src/cursor/cursor-daemon.ts b/src/cursor/cursor-daemon.ts index 5c58b712..b88a2b64 100644 --- a/src/cursor/cursor-daemon.ts +++ b/src/cursor/cursor-daemon.ts @@ -56,7 +56,7 @@ async function resolveDaemonEntrypoint(): Promise { * Check if cursor daemon is running on the specified port. * Uses 127.0.0.1 instead of localhost for more reliable local connections. */ -export async function isDaemonRunning(port: number): Promise { +export async function isDaemonRunning(port: number, daemonToken?: string): Promise { return new Promise((resolve) => { const req = http.request( { @@ -65,6 +65,7 @@ export async function isDaemonRunning(port: number): Promise { path: '/health', method: 'GET', timeout: 3000, + headers: daemonToken ? { 'x-ccs-cursor-token': daemonToken } : undefined, }, (res) => { let body = ''; @@ -127,7 +128,7 @@ export async function startDaemon( config: CursorDaemonConfig ): Promise<{ success: boolean; pid?: number; error?: string }> { // Check if already running - if (await isDaemonRunning(config.port)) { + if (await isDaemonRunning(config.port, config.daemon_token)) { logger.stage('dispatch', 'cursor.daemon.already_running', 'Cursor daemon already running', { provider: 'cursor', port: config.port, @@ -205,6 +206,10 @@ export async function startDaemon( proc = spawn(process.execPath, args, { stdio: 'ignore', detached: true, + env: { + ...process.env, + CCS_CURSOR_DAEMON_TOKEN: config.daemon_token || '', + }, }); // Unref so parent can exit @@ -220,7 +225,7 @@ export async function startDaemon( const pollHealth = async () => { attempts++; - if (await isDaemonRunning(config.port)) { + if (await isDaemonRunning(config.port, config.daemon_token)) { safeResolve({ success: true, pid: proc.pid }); } else if (attempts >= maxAttempts) { // Kill orphaned process diff --git a/src/cursor/cursor-models.ts b/src/cursor/cursor-models.ts index 292ce5dc..384e50ae 100644 --- a/src/cursor/cursor-models.ts +++ b/src/cursor/cursor-models.ts @@ -8,6 +8,7 @@ import * as http from 'http'; import type { CursorModel } from './types'; import type { CursorApiCredentials } from './cursor-protobuf-schema'; import { isDaemonRunning } from './cursor-daemon'; +import { getCursorDaemonToken } from './cursor-daemon-auth'; import { buildCursorModelsHeaders } from './cursor-client-policy'; import { DEFAULT_CURSOR_MODEL, @@ -285,7 +286,7 @@ export async function fetchModelsFromDaemon(port: number): Promise { - if (!(await isDaemonRunning(port))) { + if (!(await isDaemonRunning(port, getCursorDaemonToken()))) { return DEFAULT_CURSOR_MODELS; } return fetchModelsFromDaemon(port); diff --git a/src/cursor/cursor-profile-executor.ts b/src/cursor/cursor-profile-executor.ts index e2b9b531..03d4d139 100644 --- a/src/cursor/cursor-profile-executor.ts +++ b/src/cursor/cursor-profile-executor.ts @@ -15,6 +15,7 @@ import { getImageAnalysisHookEnv, resolveImageAnalysisRuntimeStatus } from '../u import { stripClaudeCodeEnv } from '../utils/shell-executor'; import { checkAuthStatus } from './cursor-auth'; import { isDaemonRunning, startDaemon } from './cursor-daemon'; +import { getCursorDaemonToken } from './cursor-daemon-auth'; import { getGlobalEnvConfig } from '../config/config-loader-facade'; interface CursorImageAnalysisResolution { @@ -31,6 +32,7 @@ interface CursorImageAnalysisDeps { export function generateCursorEnv( config: CursorConfig, + daemonToken: string, claudeConfigDir?: string ): Record { const opusModel = config.opus_model || config.model; @@ -39,7 +41,7 @@ export function generateCursorEnv( return { ANTHROPIC_BASE_URL: `http://127.0.0.1:${config.port}`, - ANTHROPIC_AUTH_TOKEN: 'cursor-managed', + ANTHROPIC_AUTH_TOKEN: daemonToken, ANTHROPIC_MODEL: config.model, ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel, ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel, @@ -130,13 +132,16 @@ export async function executeCursorProfile( return 1; } - let daemonRunning = await isDaemonRunning(config.port); + const daemonToken = getCursorDaemonToken(); + + let daemonRunning = await isDaemonRunning(config.port, daemonToken); if (!daemonRunning) { if (config.auto_start) { console.log(info('Starting cursor daemon...')); const result = await startDaemon({ port: config.port, ghost_mode: config.ghost_mode, + daemon_token: daemonToken, }); if (!result.success) { console.error(fail(`Failed to start cursor daemon: ${result.error}`)); @@ -154,7 +159,7 @@ export async function executeCursorProfile( } } - const cursorEnv = generateCursorEnv(config, claudeConfigDir); + const cursorEnv = generateCursorEnv(config, daemonToken, claudeConfigDir); const globalEnvConfig = getGlobalEnvConfig(); const globalEnv = globalEnvConfig.enabled ? globalEnvConfig.env : {}; const webSearchEnv = getWebSearchHookEnv(); diff --git a/src/cursor/cursor-runtime-probe.ts b/src/cursor/cursor-runtime-probe.ts index 302bbb75..65b8feb5 100644 --- a/src/cursor/cursor-runtime-probe.ts +++ b/src/cursor/cursor-runtime-probe.ts @@ -1,6 +1,7 @@ import type { CursorConfig } from '../config/unified-config-types'; import { checkAuthStatus } from './cursor-auth'; import { isDaemonRunning, startDaemon } from './cursor-daemon'; +import { getCursorDaemonToken } from './cursor-daemon-auth'; import { getModelsForDaemon, resolveCursorRequestModel } from './cursor-models'; export interface CursorProbeResult { @@ -136,7 +137,8 @@ export async function probeCursorRuntime(config: CursorConfig): Promise { if (!result.provider) { diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts index edfac3bd..3724bc39 100644 --- a/tests/unit/cursor/cursor-daemon.test.ts +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -197,7 +197,7 @@ describe('isDaemonRunning', () => { throw new Error('Unable to resolve test server port'); } - const result = await isDaemonRunning(address.port); + const result = await isDaemonRunning(address.port, "bad-token"); expect(result).toBe(false); } finally { await new Promise((resolve) => { diff --git a/tests/unit/cursor/cursor-profile-executor.test.ts b/tests/unit/cursor/cursor-profile-executor.test.ts index 4d7d6260..1f144896 100644 --- a/tests/unit/cursor/cursor-profile-executor.test.ts +++ b/tests/unit/cursor/cursor-profile-executor.test.ts @@ -46,11 +46,12 @@ describe('cursor-profile-executor', () => { sonnet_model: 'cursor-sonnet', haiku_model: 'cursor-haiku', }, + 'test-token', '/tmp/claude-config' ); expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:20129'); - expect(env.ANTHROPIC_AUTH_TOKEN).toBe('cursor-managed'); + expect(env.ANTHROPIC_AUTH_TOKEN).toBe('test-token'); expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('cursor-opus'); expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('cursor-sonnet'); From cba1414b7e5c038741f0e4a401280d0999afdf61 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 21:44:57 -0400 Subject: [PATCH 034/137] fix(ci): qualify docker release checkout refs (#1374) * fix(ci): qualify docker release checkout refs * style: apply prettier formatting --- .github/workflows/docker-release.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index c69cf972..7876b464 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -72,7 +72,7 @@ jobs: if: steps.tag.outputs.publish == 'true' uses: actions/checkout@v4 with: - ref: ${{ steps.target.outputs.tag }} + ref: ${{ format('refs/tags/{0}', steps.target.outputs.tag) }} fetch-depth: 0 persist-credentials: false @@ -207,7 +207,7 @@ jobs: if: steps.tag.outputs.publish == 'true' uses: actions/checkout@v4 with: - ref: ${{ steps.target.outputs.tag }} + ref: ${{ format('refs/tags/{0}', steps.target.outputs.tag) }} persist-credentials: false - name: Set up QEMU @@ -314,7 +314,7 @@ jobs: - name: Checkout release tag (for test scripts) uses: actions/checkout@v4 with: - ref: ${{ needs.publish-integrated.outputs.version != '' && format('v{0}', needs.publish-integrated.outputs.version) || github.ref }} + ref: ${{ needs.publish-integrated.outputs.version != '' && format('refs/tags/v{0}', needs.publish-integrated.outputs.version) || github.ref }} persist-credentials: false - name: Derive image reference From b0a754179ca7bc0c0d4b70ef119fca1eef827dfc Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 21:45:00 -0400 Subject: [PATCH 035/137] fix(codex): cap buffered upstream error response size (#1375) --- ...x-reasoning-proxy-extended-context.test.ts | 33 +++++++++++++++++++ .../ai-providers/codex-reasoning-proxy.ts | 17 +++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/cliproxy/ai-providers/__tests__/codex-reasoning-proxy-extended-context.test.ts b/src/cliproxy/ai-providers/__tests__/codex-reasoning-proxy-extended-context.test.ts index 9b156985..78e9056a 100644 --- a/src/cliproxy/ai-providers/__tests__/codex-reasoning-proxy-extended-context.test.ts +++ b/src/cliproxy/ai-providers/__tests__/codex-reasoning-proxy-extended-context.test.ts @@ -296,6 +296,39 @@ describe('CodexReasoningProxy extended-context compatibility', () => { expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBeUndefined(); }); + it('rejects oversized non-2xx upstream error bodies', async () => { + const upstream = http.createServer((_req, res) => { + res.writeHead(500, { 'Content-Type': 'application/json' }); + const chunk = 'x'.repeat(1024 * 1024); + for (let i = 0; i < 11; i += 1) { + res.write(chunk); + } + res.end(); + }); + cleanupServers.push(upstream); + + const upstreamPort = await listenOnRandomPort(upstream); + const proxy = new CodexReasoningProxy({ + upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`, + modelMap: { defaultModel: 'gpt-5.4' }, + defaultEffort: 'medium', + }); + + const proxyPort = await proxy.start(); + const response = await postJson( + `http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`, + { + model: 'gpt-5.4', + messages: [], + } + ); + + proxy.stop(); + + expect(response.statusCode).toBe(502); + expect(response.body.error).toContain('Upstream error response exceeded 10MB limit'); + }); + it('keeps fast service tier when disableEffort is enabled', async () => { let capturedBody: JsonRecord | null = null; diff --git a/src/cliproxy/ai-providers/codex-reasoning-proxy.ts b/src/cliproxy/ai-providers/codex-reasoning-proxy.ts index a8502a82..fe91e40b 100644 --- a/src/cliproxy/ai-providers/codex-reasoning-proxy.ts +++ b/src/cliproxy/ai-providers/codex-reasoning-proxy.ts @@ -680,9 +680,24 @@ export class CodexReasoningProxy { return; } + const maxErrorResponseSize = 10 * 1024 * 1024; // 10MB + let totalResponseBytes = 0; + let responseTooLarge = false; const chunks: Buffer[] = []; - upstreamRes.on('data', (chunk: Buffer) => chunks.push(chunk)); + upstreamRes.on('data', (chunk: Buffer) => { + totalResponseBytes += chunk.length; + if (totalResponseBytes > maxErrorResponseSize) { + responseTooLarge = true; + upstreamRes.destroy(new Error('Upstream error response exceeded 10MB limit')); + return; + } + chunks.push(chunk); + }); upstreamRes.on('end', async () => { + if (responseTooLarge) { + reject(new Error('Upstream error response exceeded 10MB limit')); + return; + } try { const responseBody = Buffer.concat(chunks).toString('utf8'); const unsupportedError = From 781c84b04b184cbb57853fa2d0cc892e45c980f2 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 21:45:02 -0400 Subject: [PATCH 036/137] fix: narrow legacy GLMT base URL normalization (#1376) * fix: narrow legacy glmt base url normalization * style: apply prettier formatting --- src/utils/glmt-deprecation.ts | 6 +----- tests/unit/utils/glmt-deprecation.test.ts | 3 +++ 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/utils/glmt-deprecation.ts b/src/utils/glmt-deprecation.ts index 8235aea8..38a3599f 100644 --- a/src/utils/glmt-deprecation.ts +++ b/src/utils/glmt-deprecation.ts @@ -25,11 +25,7 @@ export function isLegacyGlmtBaseUrl(baseUrl: string | null | undefined): boolean return false; } - return ( - normalized === LEGACY_GLMT_BASE_URL || - normalized.includes('/api/coding/paas/v4') || - normalized.endsWith('/chat/completions') - ); + return normalized === LEGACY_GLMT_BASE_URL || normalized.includes('/api/coding/paas/v4'); } export function normalizeDeprecatedGlmtEnv(env: Record): GlmtNormalizationResult { diff --git a/tests/unit/utils/glmt-deprecation.test.ts b/tests/unit/utils/glmt-deprecation.test.ts index d003a9e7..592b370a 100644 --- a/tests/unit/utils/glmt-deprecation.test.ts +++ b/tests/unit/utils/glmt-deprecation.test.ts @@ -20,6 +20,9 @@ describe('glmt deprecation helpers', () => { expect(isLegacyGlmtBaseUrl('https://api.z.ai/api/coding/paas/v4/chat/completions/')).toBe( true ); + expect(isLegacyGlmtBaseUrl('https://private.example.internal/v1/chat/completions')).toBe( + false + ); expect(isLegacyGlmtBaseUrl('https://api.z.ai/api/anthropic')).toBe(false); }); From 210ab761e2cbfcaf8d1e75757a517889479c6335 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 21:45:05 -0400 Subject: [PATCH 037/137] fix(cursor): enforce auth token for anthropic daemon route (#1377) --- src/cliproxy/quota/quota-manager.ts | 10 +++--- src/cursor/cursor-daemon-entry.ts | 33 +++++++++++++++++ src/management/checks/image-analysis-check.ts | 5 ++- .../cursor-daemon-lifecycle.test.ts | 35 +++++++++++++++++++ 4 files changed, 74 insertions(+), 9 deletions(-) diff --git a/src/cliproxy/quota/quota-manager.ts b/src/cliproxy/quota/quota-manager.ts index 8574b079..041e2748 100644 --- a/src/cliproxy/quota/quota-manager.ts +++ b/src/cliproxy/quota/quota-manager.ts @@ -483,9 +483,8 @@ export async function reconcileExhaustedRotationAccounts( return []; } - const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = await import( - '../accounts/account-safety' - ); + const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = + await import('../accounts/account-safety'); restoreExpiredQuotaPauses(); const config = loadOrCreateUnifiedConfig(); @@ -593,9 +592,8 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise 0) { + return xApiKey.trim(); + } + + const authorization = headers.authorization; + if (typeof authorization === 'string') { + const match = authorization.match(/^Bearer\s+(.+)$/i); + if (match && match[1].trim().length > 0) { + return match[1].trim(); + } + } + + return ''; +} + function writeJson(res: http.ServerResponse, statusCode: number, payload: unknown): void { res.writeHead(statusCode, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(payload)); @@ -309,6 +326,22 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser return; } + if (isAnthropicRoute) { + const expectedToken = (process.env.ANTHROPIC_AUTH_TOKEN || 'cursor-managed').trim(); + const requestToken = getAnthropicRequestToken(req.headers); + if (!expectedToken || requestToken !== expectedToken) { + await pipeWebResponseToNode( + createAnthropicErrorResponse( + 401, + 'authentication_error', + 'Invalid Anthropic auth token. Set ANTHROPIC_AUTH_TOKEN and send it via x-api-key or Authorization Bearer.' + ), + res + ); + return; + } + } + const daemonCredentials = { accessToken: authStatus.credentials.accessToken, machineId: authStatus.credentials.machineId, diff --git a/src/management/checks/image-analysis-check.ts b/src/management/checks/image-analysis-check.ts index 50e9ba99..d6d2dc05 100644 --- a/src/management/checks/image-analysis-check.ts +++ b/src/management/checks/image-analysis-check.ts @@ -112,9 +112,8 @@ export async function runImageAnalysisCheck(results: HealthCheck): Promise * Fix image analysis configuration issues */ export async function fixImageAnalysisConfig(): Promise { - const { updateConfig, loadOrCreateUnifiedConfig } = await import( - '../../config/config-loader-facade' - ); + const { updateConfig, loadOrCreateUnifiedConfig } = + await import('../../config/config-loader-facade'); const config = loadOrCreateUnifiedConfig(); let fixed = false; diff --git a/tests/integration/cursor-daemon-lifecycle.test.ts b/tests/integration/cursor-daemon-lifecycle.test.ts index 09acca70..c50aea68 100644 --- a/tests/integration/cursor-daemon-lifecycle.test.ts +++ b/tests/integration/cursor-daemon-lifecycle.test.ts @@ -31,6 +31,41 @@ afterEach(async () => { }); describe('cursor daemon lifecycle smoke', () => { + it('requires Anthropic caller auth token when credentials are present', async () => { + const port = 10000 + Math.floor(Math.random() * 50000); + + saveCredentials({ + accessToken: 'a'.repeat(60), + machineId: '1234567890abcdef1234567890abcdef', + authMethod: 'manual', + importedAt: new Date().toISOString(), + }); + + const result = await startDaemon({ port, ghost_mode: true }); + expect(result.success).toBe(true); + + const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: 'claude-sonnet-4.5', + max_tokens: 64, + messages: [{ role: 'user', content: 'hello' }], + }), + }); + + expect(response.status).toBe(401); + const body = (await response.json()) as { + type?: string; + error?: { type?: string; message?: string }; + }; + expect(body.type).toBe('error'); + expect(body.error?.type).toBe('authentication_error'); + expect(body.error?.message).toContain('Invalid Anthropic auth token'); + }); it('starts, serves expected routes, and stops cleanly', async () => { const port = 10000 + Math.floor(Math.random() * 50000); const result = await startDaemon({ port, ghost_mode: true }); From 0db9705d7509302f13816607ca055b9c387111b3 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 21:45:07 -0400 Subject: [PATCH 038/137] fix(claude-extension): enforce private permissions for settings writes (#1378) --- .../services/claude-extension-settings-service.ts | 6 +++++- tests/unit/web-server/claude-extension-routes.test.ts | 10 +++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/web-server/services/claude-extension-settings-service.ts b/src/web-server/services/claude-extension-settings-service.ts index aad9fe24..97d97474 100644 --- a/src/web-server/services/claude-extension-settings-service.ts +++ b/src/web-server/services/claude-extension-settings-service.ts @@ -231,8 +231,12 @@ function writeJsonDocument( const tempPath = `${filePath}.tmp.${uniqueFileNonce()}`; try { - fs.writeFileSync(tempPath, JSON.stringify(data, null, 2) + '\n', 'utf8'); + fs.writeFileSync(tempPath, JSON.stringify(data, null, 2) + '\n', { + encoding: 'utf8', + mode: 0o600, + }); fs.renameSync(tempPath, filePath); + fs.chmodSync(filePath, 0o600); } catch (error) { if (fs.existsSync(tempPath)) { fs.rmSync(tempPath, { force: true }); diff --git a/tests/unit/web-server/claude-extension-routes.test.ts b/tests/unit/web-server/claude-extension-routes.test.ts index edb4a1ee..27fc2d16 100644 --- a/tests/unit/web-server/claude-extension-routes.test.ts +++ b/tests/unit/web-server/claude-extension-routes.test.ts @@ -313,6 +313,12 @@ describe('web-server claude-extension-routes', () => { it('creates a binding and applies managed settings to shared + IDE targets', async () => { const ideSettingsPath = path.join(tempHome, 'ide', 'vscode', 'settings.json'); + const sharedSettingsPath = path.join(tempHome, '.claude', 'settings.json'); + fs.mkdirSync(path.dirname(ideSettingsPath), { recursive: true }); + fs.mkdirSync(path.dirname(sharedSettingsPath), { recursive: true }); + fs.writeFileSync(sharedSettingsPath, JSON.stringify({ env: {} }, null, 2) + '\n', { mode: 0o600 }); + fs.writeFileSync(ideSettingsPath, JSON.stringify({}, null, 2) + '\n', { mode: 0o600 }); + const createResponse = await fetch(`${baseUrl}/api/claude-extension/bindings`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -352,7 +358,6 @@ describe('web-server claude-extension-routes', () => { expect(applied.sharedSettings.state).toBe('applied'); expect(applied.ideSettings.state).toBe('applied'); - const sharedSettingsPath = path.join(tempHome, '.claude', 'settings.json'); const sharedSettings = JSON.parse(fs.readFileSync(sharedSettingsPath, 'utf8')) as { env?: Record; }; @@ -370,6 +375,9 @@ describe('web-server claude-extension-routes', () => { ) ).toBe(true); expect(ideSettings['claudeCode.disableLoginPrompt']).toBe(true); + + expect(fs.statSync(sharedSettingsPath).mode & 0o777).toBe(0o600); + expect(fs.statSync(ideSettingsPath).mode & 0o777).toBe(0o600); }); it('resets only managed keys and preserves unrelated shared + IDE settings', async () => { From 90d5ca2cbb07e46364d05e3558f177355ceed975 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 21:45:09 -0400 Subject: [PATCH 039/137] fix(proxy): honor backpressure in cliproxy local streaming (#1379) --- src/web-server/routes/cliproxy-local-proxy.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/web-server/routes/cliproxy-local-proxy.ts b/src/web-server/routes/cliproxy-local-proxy.ts index 4454a297..b03e2ff6 100644 --- a/src/web-server/routes/cliproxy-local-proxy.ts +++ b/src/web-server/routes/cliproxy-local-proxy.ts @@ -116,9 +116,22 @@ export function createCliproxyLocalProxyRouter(deps: CliproxyLocalProxyDeps = {} } res.writeHead(proxyStatus, proxyRes.headers); - // Manual streaming instead of pipe() for Bun runtime compatibility - proxyRes.on('data', (chunk: Buffer) => res.write(chunk)); - proxyRes.on('end', () => res.end()); + // Manual streaming instead of pipe() for Bun runtime compatibility. + // Explicitly honor downstream backpressure to avoid unbounded buffering. + const onDrain = () => proxyRes.resume(); + + proxyRes.on('data', (chunk: Buffer) => { + const canContinue = res.write(chunk); + if (!canContinue) { + proxyRes.pause(); + res.once('drain', onDrain); + } + }); + + proxyRes.on('end', () => { + res.off('drain', onDrain); + res.end(); + }); } ); From 55cd78bce47f219d03c7b8b7a43963f5eae182b0 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 21:45:12 -0400 Subject: [PATCH 040/137] fix: create explicit CODEX_HOME with secure permissions (#1380) --- src/targets/codex-adapter.ts | 2 +- tests/unit/targets/codex-runtime-integration.test.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/targets/codex-adapter.ts b/src/targets/codex-adapter.ts index 333dda86..ba22c63b 100644 --- a/src/targets/codex-adapter.ts +++ b/src/targets/codex-adapter.ts @@ -222,7 +222,7 @@ function prepareExplicitCodexHome( } try { - fs.mkdirSync(codexHome, { recursive: true }); + fs.mkdirSync(codexHome, { mode: 0o700, recursive: true }); } catch (err) { const error = err as NodeJS.ErrnoException; if (error.code !== 'EEXIST') { diff --git a/tests/unit/targets/codex-runtime-integration.test.ts b/tests/unit/targets/codex-runtime-integration.test.ts index a14dfed4..4018dd45 100644 --- a/tests/unit/targets/codex-runtime-integration.test.ts +++ b/tests/unit/targets/codex-runtime-integration.test.ts @@ -596,7 +596,9 @@ process.exit(0); expect(result.status).toBe(0); expect(fs.existsSync(freshCodexHome)).toBe(true); - expect(fs.statSync(freshCodexHome).isDirectory()).toBe(true); + const codexHomeStat = fs.statSync(freshCodexHome); + expect(codexHomeStat.isDirectory()).toBe(true); + expect(codexHomeStat.mode & 0o777).toBe(0o700); expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ ['-c', 'model="gpt-5"', '--version'], ['-c', 'model_reasoning_effort="high"', 'fix failing tests'], From 7ccea955ad1fcef8b880fafccac2b1a04cad564e Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 21:45:15 -0400 Subject: [PATCH 041/137] docs(docker): harden remote token migration staging (#1381) --- docker/README.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docker/README.md b/docker/README.md index 000d3583..312daf76 100644 --- a/docker/README.md +++ b/docker/README.md @@ -160,15 +160,20 @@ docker exec ccs-cliproxy supervisorctl -c /etc/supervisord.conf restart cliproxy For remote deployments via `ccs docker up --host`: ```bash -# Copy tokens into the running container (no root/sudo needed) -scp /path/to/auth/*.json my-server:/tmp/ccs-auth/ -ssh my-server 'for f in /tmp/ccs-auth/*.json; do docker cp "$f" ccs-cliproxy:/root/.ccs/cliproxy/auth/; done' +# Create a private staging directory (0700) and print its path +STAGE_DIR=$(ssh my-server 'umask 077 && mktemp -d "${HOME}/.ccs-auth.XXXXXX"') + +# Copy only JSON token files into the private staging directory +scp /path/to/auth/*.json "my-server:${STAGE_DIR}/" + +# Restrict file permissions and import each staged token into the container +ssh my-server "chmod 600 \"${STAGE_DIR}\"/*.json && for f in \"${STAGE_DIR}\"/*.json; do docker cp \"\$f\" ccs-cliproxy:/root/.ccs/cliproxy/auth/; done" # Restart CLIProxy to load new tokens ssh my-server "docker exec ccs-cliproxy supervisorctl -c /etc/supervisord.conf restart cliproxy" -# Clean up temp files -ssh my-server "rm -rf /tmp/ccs-auth" +# Clean up private staging files +ssh my-server "rm -rf \"${STAGE_DIR}\"" ``` > **Tip:** `docker cp` is preferred over writing directly to Docker volume mountpoints, which require root access. From e59ee6df7974964056f48759a3e68d130c580c0a Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 21:45:17 -0400 Subject: [PATCH 042/137] docs(docker): avoid exposing dashboard password in examples (#1382) --- docker/README.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docker/README.md b/docker/README.md index 312daf76..6c3ba275 100644 --- a/docker/README.md +++ b/docker/README.md @@ -113,10 +113,11 @@ environment: Running `ccs config auth setup` on the outer host shell updates that machine's own `~/.ccs`, not the Docker volume mounted into `ccs-cliproxy`. For the integrated stack, configure auth inside the container or provide the auth env vars in Compose. -Generate a bcrypt hash: +Generate a bcrypt hash without putting the password in shell history or process arguments: ```bash -docker exec ccs-cliproxy node -e "console.log(require('bcrypt').hashSync('your-password', 10))" +docker exec -i ccs-cliproxy node -e "const fs=require('fs'); const bcrypt=require('bcrypt'); const password=fs.readFileSync(0,'utf8').trimEnd(); console.log(bcrypt.hashSync(password, 10));" +# then type/paste the password followed by Enter (stdin is not exposed via argv) ``` > **Note:** Do not commit the password hash in `docker-compose.yml`. Use Docker secrets or a `.env` file (not tracked in git) for sensitive values like `CCS_DASHBOARD_PASSWORD_HASH`. @@ -196,10 +197,12 @@ docker exec ccs-cliproxy curl -fsS http://127.0.0.1:3000/api/health \ # 4. Verify auth tokens loaded (check client count) docker exec ccs-cliproxy grep "client load complete" /var/log/ccs/cliproxy.log -# 5. Test dashboard API (from remote -- requires auth) -curl -fsS -X POST http://:3000/api/auth/login \ +# 5. Test dashboard API (from remote -- requires auth + HTTPS) +read -r -s CCS_DASHBOARD_PASSWORD && echo +curl -fsS -X POST https://:3000/api/auth/login \ -H 'Content-Type: application/json' \ - -d '{"username":"admin","password":"your-password"}' + -d "{\"username\":\"admin\",\"password\":\"${CCS_DASHBOARD_PASSWORD}\"}" +unset CCS_DASHBOARD_PASSWORD ``` Expected healthy output: From f985cec3ff5de166592a06cfb9ee376436b10c1e Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 21:46:04 -0400 Subject: [PATCH 043/137] fix(browser): avoid implicit explicit devtools port for config attach (#1369) * fix(browser): avoid implicit explicit devtools port for config attach * style: apply prettier formatting --- src/cliproxy/quota/quota-manager.ts | 10 ++++++---- src/management/checks/image-analysis-check.ts | 5 +++-- src/utils/browser/browser-settings.ts | 2 +- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/cliproxy/quota/quota-manager.ts b/src/cliproxy/quota/quota-manager.ts index 041e2748..8574b079 100644 --- a/src/cliproxy/quota/quota-manager.ts +++ b/src/cliproxy/quota/quota-manager.ts @@ -483,8 +483,9 @@ export async function reconcileExhaustedRotationAccounts( return []; } - const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = - await import('../accounts/account-safety'); + const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = await import( + '../accounts/account-safety' + ); restoreExpiredQuotaPauses(); const config = loadOrCreateUnifiedConfig(); @@ -592,8 +593,9 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise * Fix image analysis configuration issues */ export async function fixImageAnalysisConfig(): Promise { - const { updateConfig, loadOrCreateUnifiedConfig } = - await import('../../config/config-loader-facade'); + const { updateConfig, loadOrCreateUnifiedConfig } = await import( + '../../config/config-loader-facade' + ); const config = loadOrCreateUnifiedConfig(); let fixed = false; diff --git a/src/utils/browser/browser-settings.ts b/src/utils/browser/browser-settings.ts index 1c7fcc25..7bf45d03 100644 --- a/src/utils/browser/browser-settings.ts +++ b/src/utils/browser/browser-settings.ts @@ -253,7 +253,7 @@ export function getEffectiveClaudeBrowserAttachConfig( overrideActive: false, userDataDir: configUserDataDir, devtoolsPort: configPort, - hasExplicitDevtoolsPort: true, + hasExplicitDevtoolsPort: false, evalMode: effectiveEvalMode, }; } From b63943eb450b335bbce00946dfd71f2535538cc4 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 21:55:02 -0400 Subject: [PATCH 044/137] fix(browser): track explicit devtools_port from config (#1383) #1369 unconditionally set hasExplicitDevtoolsPort=false for config-attach, which broke users who explicitly set devtools_port in config. Now track whether config.claude.devtools_port was actually provided. --- src/utils/browser/browser-settings.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/utils/browser/browser-settings.ts b/src/utils/browser/browser-settings.ts index 7bf45d03..edc6b383 100644 --- a/src/utils/browser/browser-settings.ts +++ b/src/utils/browser/browser-settings.ts @@ -230,6 +230,7 @@ export function getEffectiveClaudeBrowserAttachConfig( const override = getBrowserAttachOverride(env); const configUserDataDir = resolveBrowserUserDataDir(config.claude.user_data_dir) ?? getRecommendedBrowserUserDataDir(); + const configHasExplicitPort = config.claude.devtools_port !== undefined; const configPort = normalizeDevtoolsPort(config.claude.devtools_port); const configEvalMode = config.claude.eval_mode ?? 'readonly'; const envEvalMode = parseBrowserEvalMode(env.CCS_BROWSER_EVAL_MODE); @@ -253,7 +254,7 @@ export function getEffectiveClaudeBrowserAttachConfig( overrideActive: false, userDataDir: configUserDataDir, devtoolsPort: configPort, - hasExplicitDevtoolsPort: false, + hasExplicitDevtoolsPort: configHasExplicitPort, evalMode: effectiveEvalMode, }; } From b7d5ce178a46d4ade8c3cb58eb5fff7af44dc44f Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 22:07:15 -0400 Subject: [PATCH 045/137] fix(cursor-daemon): auto-generate token + expose for callers (#1384) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRs #1373/#1377 gated cursor daemon endpoints behind a token check, but startDaemon() did not generate or expose a token. Health-check probing during startup then 401'd against the new auth, so startDaemon waited 30s then reported failure. Same for tests asserting /404 and validation behavior — they couldn't reach the downstream logic past the 401 gate. - Auto-generate a 32-byte hex token in startDaemon when caller did not provide one - Return daemonToken in startDaemon result so callers/tests can attach it - Update integration tests to include x-ccs-cursor-token header --- src/cursor/cursor-daemon.ts | 25 ++++++++++++----- .../cursor-daemon-lifecycle.test.ts | 27 +++++++++++++------ 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/src/cursor/cursor-daemon.ts b/src/cursor/cursor-daemon.ts index b88a2b64..30e68817 100644 --- a/src/cursor/cursor-daemon.ts +++ b/src/cursor/cursor-daemon.ts @@ -6,6 +6,7 @@ */ import { spawn, ChildProcess } from 'child_process'; +import { randomBytes } from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; import * as http from 'http'; @@ -126,14 +127,26 @@ export async function getDaemonStatus(port: number): Promise */ export async function startDaemon( config: CursorDaemonConfig -): Promise<{ success: boolean; pid?: number; error?: string }> { +): Promise<{ success: boolean; pid?: number; error?: string; daemonToken?: string }> { + // Auto-generate daemon token if not provided so /health and protected routes + // can be probed by the caller during startup polling. + const daemonToken = + config.daemon_token && config.daemon_token.trim() + ? config.daemon_token + : randomBytes(32).toString('hex'); + const effectiveConfig: CursorDaemonConfig = { ...config, daemon_token: daemonToken }; + // Check if already running - if (await isDaemonRunning(config.port, config.daemon_token)) { + if (await isDaemonRunning(effectiveConfig.port, effectiveConfig.daemon_token)) { logger.stage('dispatch', 'cursor.daemon.already_running', 'Cursor daemon already running', { provider: 'cursor', port: config.port, }); - return { success: true, pid: getPidFromFile() ?? undefined }; + return { + success: true, + pid: getPidFromFile() ?? undefined, + daemonToken: effectiveConfig.daemon_token, + }; } // Validate port before interpolation (prevents injection) @@ -208,7 +221,7 @@ export async function startDaemon( detached: true, env: { ...process.env, - CCS_CURSOR_DAEMON_TOKEN: config.daemon_token || '', + CCS_CURSOR_DAEMON_TOKEN: effectiveConfig.daemon_token || '', }, }); @@ -225,8 +238,8 @@ export async function startDaemon( const pollHealth = async () => { attempts++; - if (await isDaemonRunning(config.port, config.daemon_token)) { - safeResolve({ success: true, pid: proc.pid }); + if (await isDaemonRunning(effectiveConfig.port, effectiveConfig.daemon_token)) { + safeResolve({ success: true, pid: proc.pid, daemonToken: effectiveConfig.daemon_token }); } else if (attempts >= maxAttempts) { // Kill orphaned process if (proc.pid) { diff --git a/tests/integration/cursor-daemon-lifecycle.test.ts b/tests/integration/cursor-daemon-lifecycle.test.ts index c50aea68..acb21499 100644 --- a/tests/integration/cursor-daemon-lifecycle.test.ts +++ b/tests/integration/cursor-daemon-lifecycle.test.ts @@ -43,12 +43,14 @@ describe('cursor daemon lifecycle smoke', () => { const result = await startDaemon({ port, ghost_mode: true }); expect(result.success).toBe(true); + const daemonToken = result.daemonToken as string; const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01', + 'x-ccs-cursor-token': daemonToken, }, body: JSON.stringify({ model: 'claude-sonnet-4.5', @@ -71,8 +73,9 @@ describe('cursor daemon lifecycle smoke', () => { const result = await startDaemon({ port, ghost_mode: true }); expect(result.success).toBe(true); expect(result.pid).toBeDefined(); + const daemonToken = result.daemonToken as string; - expect(await isDaemonRunning(port)).toBe(true); + expect(await isDaemonRunning(port, daemonToken)).toBe(true); const modelsResponse = await fetch(`http://127.0.0.1:${port}/v1/models`); expect(modelsResponse.status).toBe(200); @@ -82,7 +85,7 @@ describe('cursor daemon lifecycle smoke', () => { const chatResponse = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'x-ccs-cursor-token': daemonToken }, body: JSON.stringify({ model: 'gpt-4.1', messages: [{ role: 'user', content: 'hello' }], @@ -95,6 +98,7 @@ describe('cursor daemon lifecycle smoke', () => { headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01', + 'x-ccs-cursor-token': daemonToken, }, body: JSON.stringify({ model: 'claude-sonnet-4.5', @@ -113,15 +117,18 @@ describe('cursor daemon lifecycle smoke', () => { const stopResult = await stopDaemon(); expect(stopResult.success).toBe(true); - expect(await isDaemonRunning(port)).toBe(false); + expect(await isDaemonRunning(port, daemonToken)).toBe(false); }, 35000); it('returns 404 for unknown routes', async () => { const port = 10000 + Math.floor(Math.random() * 50000); const result = await startDaemon({ port, ghost_mode: true }); expect(result.success).toBe(true); + const daemonToken = result.daemonToken as string; - const response = await fetch(`http://127.0.0.1:${port}/unknown`); + const response = await fetch(`http://127.0.0.1:${port}/unknown`, { + headers: { 'x-ccs-cursor-token': daemonToken }, + }); expect(response.status).toBe(404); }); @@ -138,10 +145,11 @@ describe('cursor daemon lifecycle smoke', () => { const result = await startDaemon({ port, ghost_mode: true }); expect(result.success).toBe(true); + const daemonToken = result.daemonToken as string; const response = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'x-ccs-cursor-token': daemonToken }, body: JSON.stringify({ model: 'gpt-4.1', messages: [{ role: 'user', content: 'hello' }], @@ -157,17 +165,19 @@ describe('cursor daemon lifecycle smoke', () => { const port = 10000 + Math.floor(Math.random() * 50000); const result = await startDaemon({ port, ghost_mode: true }); expect(result.success).toBe(true); + const daemonToken = result.daemonToken as string; + const tokenHeader = { 'x-ccs-cursor-token': daemonToken }; const invalidJson = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...tokenHeader }, body: '{invalid-json', }); expect(invalidJson.status).toBe(400); const invalidSchema = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...tokenHeader }, body: JSON.stringify({ model: 'gpt-4.1', messages: { role: 'user', content: 'hello' }, @@ -180,6 +190,7 @@ describe('cursor daemon lifecycle smoke', () => { headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01', + ...tokenHeader, }, body: JSON.stringify({ model: 'claude-sonnet-4.5', @@ -198,7 +209,7 @@ describe('cursor daemon lifecycle smoke', () => { const oversized = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', ...tokenHeader }, body: JSON.stringify({ model: 'gpt-4.1', messages: [ From ba887e012ac29b9764806232b30f311c2f597cf8 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 22:09:53 -0400 Subject: [PATCH 046/137] fix(cursor-daemon): widen safeResolve type for daemonToken (#1385) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cursor-daemon): auto-generate token + expose for callers PRs #1373/#1377 gated cursor daemon endpoints behind a token check, but startDaemon() did not generate or expose a token. Health-check probing during startup then 401'd against the new auth, so startDaemon waited 30s then reported failure. Same for tests asserting /404 and validation behavior — they couldn't reach the downstream logic past the 401 gate. - Auto-generate a 32-byte hex token in startDaemon when caller did not provide one - Return daemonToken in startDaemon result so callers/tests can attach it - Update integration tests to include x-ccs-cursor-token header * fix(cursor-daemon): widen safeResolve type for daemonToken field --- src/cursor/cursor-daemon.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/cursor/cursor-daemon.ts b/src/cursor/cursor-daemon.ts index 30e68817..9c5a5047 100644 --- a/src/cursor/cursor-daemon.ts +++ b/src/cursor/cursor-daemon.ts @@ -173,7 +173,12 @@ export async function startDaemon( let proc: ChildProcess; let resolved = false; - const safeResolve = (result: { success: boolean; pid?: number; error?: string }) => { + const safeResolve = (result: { + success: boolean; + pid?: number; + error?: string; + daemonToken?: string; + }) => { if (resolved) return; resolved = true; if (checkTimeout) clearTimeout(checkTimeout); From 2237011b598b1f3516f26230a159770509fd6193 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 24 May 2026 02:14:01 +0000 Subject: [PATCH 047/137] chore(release): 8.1.0-dev.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4149e18d..fbc3a122 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.1.0-dev.4", + "version": "8.1.0-dev.5", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 4cf840ea0adeaee8370753f1bb20fb3c3cbd849f Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 22:37:39 -0400 Subject: [PATCH 048/137] fix(cliproxy): prevent management key leak on runtime proxy overrides (#1386) * fix(cliproxy): clear yaml management key on runtime overrides * style: apply prettier formatting --- .../__tests__/proxy-config-resolver.test.js | 27 +++++++++++++++++++ src/cliproxy/proxy/proxy-config-resolver.ts | 14 +++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/cliproxy/proxy/__tests__/proxy-config-resolver.test.js b/src/cliproxy/proxy/__tests__/proxy-config-resolver.test.js index 8c384651..3d424b96 100644 --- a/src/cliproxy/proxy/__tests__/proxy-config-resolver.test.js +++ b/src/cliproxy/proxy/__tests__/proxy-config-resolver.test.js @@ -294,6 +294,33 @@ describe('proxy-config-resolver', () => { expect(config.managementKey).toBe('remote-management-key'); }); + it('should clear YAML management key when CLI overrides remote host', () => { + const { config } = resolveProxyConfig(['--proxy-host', 'cli-host.example.com'], { + remote: { + host: 'yaml-host.example.com', + auth_token: 'remote-auth-token', + management_key: 'remote-management-key', + }, + }); + expect(config.mode).toBe('remote'); + expect(config.host).toBe('cli-host.example.com'); + expect(config.managementKey).toBeUndefined(); + }); + + it('should clear YAML management key when ENV overrides auth token', () => { + process.env.CCS_PROXY_AUTH_TOKEN = 'env-auth-token'; + const { config } = resolveProxyConfig([], { + remote: { + host: 'yaml-host.example.com', + auth_token: 'remote-auth-token', + management_key: 'remote-management-key', + }, + }); + expect(config.mode).toBe('remote'); + expect(config.authToken).toBe('env-auth-token'); + expect(config.managementKey).toBeUndefined(); + }); + it('should allow CLI --proxy-host to override YAML enabled:false', () => { const { config } = resolveProxyConfig(['--proxy-host', 'cli-host'], { remote: { enabled: false, host: 'yaml-host' }, diff --git a/src/cliproxy/proxy/proxy-config-resolver.ts b/src/cliproxy/proxy/proxy-config-resolver.ts index e4e229b2..a9cdded3 100644 --- a/src/cliproxy/proxy/proxy-config-resolver.ts +++ b/src/cliproxy/proxy/proxy-config-resolver.ts @@ -291,7 +291,19 @@ export function resolveProxyConfig( // Merge auth token: CLI > ENV > config.yaml resolved.authToken = cliFlags.authToken ?? envConfig.authToken ?? yamlConfig.remote?.auth_token; - resolved.managementKey = yamlConfig.remote?.management_key; + + // Keep YAML management key only when remote target/auth are not overridden via CLI/ENV. + // Prevents leaking a saved management key to a different runtime target. + const hasRuntimeRemoteOverride = + cliFlags.host !== undefined || + envConfig.host !== undefined || + cliFlags.port !== undefined || + envConfig.port !== undefined || + cliFlags.protocol !== undefined || + envConfig.protocol !== undefined || + cliFlags.authToken !== undefined || + envConfig.authToken !== undefined; + resolved.managementKey = hasRuntimeRemoteOverride ? undefined : yamlConfig.remote?.management_key; // Merge timeout: CLI > ENV > config.yaml > default (2000ms in executor) resolved.timeout = cliFlags.timeout ?? envConfig.timeout ?? yamlConfig.remote?.timeout; From b35a23a6e4a1d59ae3d1a3fb754d3a31db1e5855 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 22:37:44 -0400 Subject: [PATCH 049/137] fix: reject unsupported image analysis backends (#1387) * fix: reject unsupported image analysis backends * style: apply prettier formatting --- .../routes/image-analysis-routes.ts | 7 +++++ .../web-server/image-analysis-routes.test.ts | 27 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/web-server/routes/image-analysis-routes.ts b/src/web-server/routes/image-analysis-routes.ts index 25ec7fc9..db7a44f3 100644 --- a/src/web-server/routes/image-analysis-routes.ts +++ b/src/web-server/routes/image-analysis-routes.ts @@ -404,6 +404,9 @@ router.put('/', async (req: Request, res: Response): Promise => { if (!normalizedBackend || normalizedModel.length === 0) { return acc; } + if (!knownBackends.has(normalizedBackend)) { + throw new Error(`Unsupported provider backend "${backendId}".`); + } acc[normalizedBackend] = normalizedModel; return acc; }, @@ -470,6 +473,10 @@ router.put('/', async (req: Request, res: Response): Promise => { res.json(await buildDashboardPayload()); } catch (error) { + if (error instanceof Error && error.message.startsWith('Unsupported provider backend')) { + res.status(400).json({ error: error.message }); + return; + } res.status(500).json({ error: (error as Error).message }); } }); diff --git a/tests/unit/web-server/image-analysis-routes.test.ts b/tests/unit/web-server/image-analysis-routes.test.ts index 27a6ef6f..20e6ca76 100644 --- a/tests/unit/web-server/image-analysis-routes.test.ts +++ b/tests/unit/web-server/image-analysis-routes.test.ts @@ -340,4 +340,31 @@ describe('image-analysis routes', () => { error: 'Profile mapping for "codexProfile" references an unknown backend.', }); }); + + it('rejects unsupported provider backends and preserves existing configuration', async () => { + const putResponse = await fetch(`${baseUrl}/api/image-analysis`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + providerModels: { + gemini: 'gemini-3-flash-preview', + evil: 'vision-model', + }, + fallbackBackend: 'gemini', + }), + }); + + expect(putResponse.status).toBe(400); + expect(await putResponse.json()).toEqual({ + error: 'Unsupported provider backend "evil".', + }); + + const getResponse = await fetch(`${baseUrl}/api/image-analysis`); + expect(getResponse.status).toBe(200); + const payload = await getResponse.json(); + expect(payload.config.providerModels).toMatchObject({ + gemini: 'gemini-3-flash-preview', + ghcp: 'claude-haiku-4.5', + }); + }); }); From fec84417a61b6cf9a3a2a65a564eaf55e294388e Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 22:37:49 -0400 Subject: [PATCH 050/137] fix(web): avoid runtime proxy probes on public masked settings route (#1388) --- src/web-server/routes/settings-routes.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index 2c739fb9..323c9e76 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -22,7 +22,10 @@ import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils'; import { removeCcsImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-hook-utils'; import { resolveCliproxyBridgeMetadata } from '../../api/services'; -import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; +import { + isLoopbackRemoteAddress, + requireLocalAccessWhenAuthDisabled, +} from '../middleware/auth-middleware'; import type { Settings } from '../../types/config'; import type { CLIProxyProvider } from '../../cliproxy/types'; import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; @@ -43,6 +46,7 @@ import { resolveImageAnalysisRuntimeStatus } from '../../utils/hooks'; import { getCcsDir, getImageAnalysisConfig, + isDashboardAuthEnabled, loadConfigSafe, loadOrCreateUnifiedConfig, loadSettings, @@ -155,6 +159,14 @@ function requireSensitiveLocalAccess(req: Request, res: Response): boolean { ); } +function canResolveSensitiveRuntimeStatus(req: Request): boolean { + if (isDashboardAuthEnabled()) { + return true; + } + + return isLoopbackRemoteAddress(req.socket.remoteAddress); +} + function classifyConfigSaveFailure(error: unknown): { statusCode: number; message: string } { const message = error instanceof Error ? error.message.toLowerCase() : ''; @@ -498,17 +510,17 @@ router.get('/:profile', async (req: Request, res: Response): Promise => { const stat = fs.statSync(settingsPath); const masked = maskApiKeys(settings); + const imageAnalysisStatus = canResolveSensitiveRuntimeStatus(req) + ? await resolveImageAnalysisStatusForProfile(profile, settings, settingsPath) + : null; + res.json({ profile, settings: masked, mtime: stat.mtime.getTime(), path: settingsPath, cliproxyBridge: resolveCliproxyBridgeMetadata(settings), - imageAnalysisStatus: await resolveImageAnalysisStatusForProfile( - profile, - settings, - settingsPath - ), + imageAnalysisStatus, }); } catch (error) { respondInternalError(res, error, 'Internal server error.'); From 88fbac3a324e409a26bcbe7cf44810b94ef134e2 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 23 May 2026 22:37:55 -0400 Subject: [PATCH 051/137] fix(channels): avoid secrets in --set-token argv (#1389) --- src/channels/official-channels-runtime.ts | 2 +- src/commands/config-channels-command.ts | 55 ++++++++----------- .../commands/config-channels-command.test.ts | 15 ++--- 3 files changed, 31 insertions(+), 41 deletions(-) diff --git a/src/channels/official-channels-runtime.ts b/src/channels/official-channels-runtime.ts index 9846ef71..fe8de5b6 100644 --- a/src/channels/official-channels-runtime.ts +++ b/src/channels/official-channels-runtime.ts @@ -809,7 +809,7 @@ export function getOfficialChannelsLegacyEnableHelp(): string { } export function getOfficialChannelTokenHelp(): string { - return 'Use --set-token =. If no channel is provided, Discord is assumed for backward compatibility.'; + return 'Use --set-token and pass the token via that channel env var (for example TELEGRAM_BOT_TOKEN=... ccs config channels --set-token telegram).'; } export function getOfficialChannelClearTokenHelp(): string { diff --git a/src/commands/config-channels-command.ts b/src/commands/config-channels-command.ts index c31cc9ab..6fffd019 100644 --- a/src/commands/config-channels-command.ts +++ b/src/commands/config-channels-command.ts @@ -54,31 +54,13 @@ interface ChannelsCommandOptions { setSelectionMissing: boolean; clearTokenAll: boolean; clearTokenChannel?: OfficialChannelId; - setToken?: { channelId: OfficialChannelId; token: string }; + setTokenChannel?: OfficialChannelId; setTokenMissing: boolean; clearTokenInvalid?: string; setTokenInvalid?: string; help: boolean; } -function parseTokenAssignment(value: string): { - channelId: OfficialChannelId; - token: string; -} | null { - const separatorIndex = value.indexOf('='); - if (separatorIndex === -1) { - return value.trim() ? { channelId: 'discord', token: value.trim() } : null; - } - - const channelId = value.slice(0, separatorIndex).trim().toLowerCase(); - const token = value.slice(separatorIndex + 1).trim(); - if (!isOfficialChannelId(channelId) || !token) { - return null; - } - - return { channelId, token }; -} - export function parseChannelsCommandArgs(args: string[]): ChannelsCommandOptions { const setSelection = extractOption(args, ['--set']); const setToken = extractOption(args, ['--set-token']); @@ -100,11 +82,13 @@ export function parseChannelsCommandArgs(args: string[]): ChannelsCommandOptions } } - let parsedSetToken: { channelId: OfficialChannelId; token: string } | undefined; + let parsedSetTokenChannel: OfficialChannelId | undefined; let setTokenInvalid: string | undefined; if (setToken.found && !setToken.missingValue && setToken.value) { - parsedSetToken = parseTokenAssignment(setToken.value) ?? undefined; - if (!parsedSetToken) { + const channelId = setToken.value.trim().toLowerCase(); + if (isOfficialChannelId(channelId)) { + parsedSetTokenChannel = channelId; + } else { setTokenInvalid = setToken.value; } } @@ -120,7 +104,7 @@ export function parseChannelsCommandArgs(args: string[]): ChannelsCommandOptions clearTokenAll, clearTokenChannel, clearTokenInvalid, - setToken: parsedSetToken, + setTokenChannel: parsedSetTokenChannel, setTokenMissing: setToken.found && setToken.missingValue, setTokenInvalid, help: hasAnyFlag(args, ['--help', '-h']), @@ -153,7 +137,7 @@ function showHelp(): void { ` ${color('--unattended', 'command')} Also add --dangerously-skip-permissions` ); console.log(` ${color('--no-unattended', 'command')} Disable unattended runtime flag`); - console.log(` ${color('--set-token ', 'command')} ${getOfficialChannelTokenHelp()}`); + console.log(` ${color('--set-token ', 'command')} ${getOfficialChannelTokenHelp()}`); console.log( ` ${color('--clear-token [channel]', 'command')} ${getOfficialChannelClearTokenHelp()}` ); @@ -173,7 +157,7 @@ function showHelp(): void { ` $ ${color('ccs config channels --set all', 'command')} ${dim('# Enable all official channels')}` ); console.log( - ` $ ${color('ccs config channels --set-token telegram=123:abc', 'command')} ${dim('# Save TELEGRAM_BOT_TOKEN')}` + ` $ ${color('TELEGRAM_BOT_TOKEN=123:abc ccs config channels --set-token telegram', 'command')} ${dim('# Save TELEGRAM_BOT_TOKEN')}` ); console.log( ` $ ${color('ccs config channels --clear-token discord', 'command')} ${dim('# Clear one token')}` @@ -395,7 +379,9 @@ export async function handleConfigChannelsCommand(args: string[]): Promise } if (options.setTokenInvalid) { console.error( - fail(`Invalid --set-token value: ${options.setTokenInvalid} (use =)`) + fail( + `Invalid --set-token value: ${options.setTokenInvalid} (use ${getOfficialChannelChoices()})` + ) ); process.exitCode = 1; return; @@ -444,12 +430,19 @@ export async function handleConfigChannelsCommand(args: string[]): Promise updateConfig({ channels: nextConfig }); } - if (options.setToken) { - if (!getOfficialChannelTokenIds().includes(options.setToken.channelId)) { - throw new Error(`${options.setToken.channelId} does not use a bot token.`); + if (options.setTokenChannel) { + if (!getOfficialChannelTokenIds().includes(options.setTokenChannel)) { + throw new Error(`${options.setTokenChannel} does not use a bot token.`); } - setConfiguredOfficialChannelToken(options.setToken.channelId, options.setToken.token); - console.log(ok(`${getOfficialChannelDisplayName(options.setToken.channelId)} token saved`)); + const envKey = getOfficialChannelEnvKey(options.setTokenChannel); + const token = envKey ? process.env[envKey]?.trim() : ''; + if (!token) { + throw new Error( + `${getOfficialChannelDisplayName(options.setTokenChannel)} token missing. Set ${envKey} in your environment and rerun.` + ); + } + setConfiguredOfficialChannelToken(options.setTokenChannel, token); + console.log(ok(`${getOfficialChannelDisplayName(options.setTokenChannel)} token saved`)); console.log(''); } diff --git a/tests/unit/commands/config-channels-command.test.ts b/tests/unit/commands/config-channels-command.test.ts index a8ea3af8..b68d7557 100644 --- a/tests/unit/commands/config-channels-command.test.ts +++ b/tests/unit/commands/config-channels-command.test.ts @@ -2,35 +2,32 @@ import { describe, expect, it } from 'bun:test'; import { parseChannelsCommandArgs } from '../../../src/commands/config-channels-command'; describe('config channels command parser', () => { - it('parses selection, unattended mode, and token input', () => { + it('parses selection, unattended mode, and token channel input', () => { const result = parseChannelsCommandArgs([ '--set', 'telegram,discord', '--unattended', '--set-token', - 'telegram=telegram-secret', + 'telegram', ]); expect(result.setSelection).toBe('telegram,discord'); expect(result.unattended).toBe(true); - expect(result.setToken).toEqual({ - channelId: 'telegram', - token: 'telegram-secret', - }); + expect(result.setTokenChannel).toBe('telegram'); }); - it('supports inline token assignment, legacy flags, and clear-token variants', () => { + it('supports legacy flags and clear-token variants', () => { const result = parseChannelsCommandArgs([ '--disable', '--no-unattended', - '--set-token=abc', + '--set-token=discord', ]); const clearAll = parseChannelsCommandArgs(['--clear-token']); const clearOne = parseChannelsCommandArgs(['--clear-token', 'discord']); expect(result.disable).toBe(true); expect(result.noUnattended).toBe(true); - expect(result.setToken).toEqual({ channelId: 'discord', token: 'abc' }); + expect(result.setTokenChannel).toBe('discord'); expect(clearAll.clearTokenAll).toBe(true); expect(clearOne.clearTokenChannel).toBe('discord'); }); From 0a482bd2d354de57c8bed3bb098511b512ccf1da Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 24 May 2026 02:41:07 +0000 Subject: [PATCH 052/137] chore(release): 8.1.0-dev.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fbc3a122..5162f74a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.1.0-dev.5", + "version": "8.1.0-dev.6", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 9130c7d8698442d4bb3a7ca47502ece8c99336ff Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 23 May 2026 23:40:02 -0400 Subject: [PATCH 053/137] fix(analytics): collapse profile-scope disclaimer into tooltip icon The disclaimer paragraph rendered as a two-line block under the controls row, eating vertical real-estate while the cards stayed empty. Replace it with a small Info icon beside the profile dropdown that surfaces the same copy in a tooltip on hover/focus. - Same copy preserved verbatim; no informational loss - Icon only renders when a non-All profile is selected - Reclaims ~32px of vertical space on the analytics header Closes #1390 --- .../1390/after-disclaimer-as-tooltip.png | Bin 0 -> 163659 bytes .../1390/before-disclaimer-takes-space.png | Bin 0 -> 175238 bytes .../analytics/components/analytics-header.tsx | 60 +++++++++++------- 3 files changed, 38 insertions(+), 22 deletions(-) create mode 100644 .github/pr-assets/1390/after-disclaimer-as-tooltip.png create mode 100644 .github/pr-assets/1390/before-disclaimer-takes-space.png diff --git a/.github/pr-assets/1390/after-disclaimer-as-tooltip.png b/.github/pr-assets/1390/after-disclaimer-as-tooltip.png new file mode 100644 index 0000000000000000000000000000000000000000..2e5c8ce455f5e2e3079aff288f933d4e1fb6f419 GIT binary patch literal 163659 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%0D>L<90fzS!kziM@H9g8l)gF|B5xZKN6jU4vhjj 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 MS6wk*;lYdl1EFZXfB*mh literal 0 HcmV?d00001 diff --git a/.github/pr-assets/1390/before-disclaimer-takes-space.png b/.github/pr-assets/1390/before-disclaimer-takes-space.png new file mode 100644 index 0000000000000000000000000000000000000000..0c64f52996d635c843a0ade5fe3ee2a49e920e24 GIT binary patch literal 175238 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 z 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

4Pxmu5ZVFy3vgE}FBNFi>(Jw1!9+pEkw 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 CX^{c| literal 0 HcmV?d00001 diff --git a/ui/src/pages/analytics/components/analytics-header.tsx b/ui/src/pages/analytics/components/analytics-header.tsx index 51ea43dd..7f9e826f 100644 --- a/ui/src/pages/analytics/components/analytics-header.tsx +++ b/ui/src/pages/analytics/components/analytics-header.tsx @@ -15,8 +15,9 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { RefreshCw } from 'lucide-react'; +import { Info, RefreshCw } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import type { AnalyticsProfileOption } from '../hooks'; interface AnalyticsHeaderProps { @@ -53,21 +54,42 @@ export function AnalyticsHeader({

{t('analytics.subtitle')}

- +
+ + {selectedProfile !== 'all' && ( + + + + + + + Selected-profile analytics include only default/account data with stable profile + attribution. CLIProxy and native runtime snapshots remain in All profiles. + + + + )} +
- {selectedProfile !== 'all' && ( -

- Selected-profile analytics include only default/account data with stable profile - attribution. CLIProxy and native runtime snapshots remain in All profiles. -

- )} ); } From bfa4c704eb04b0b9b389c26dc77ba84fc2e832e8 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 24 May 2026 11:34:57 -0400 Subject: [PATCH 054/137] fix(analytics): tighten top-bar layout (single-line trigger, grouped cluster, compact timestamp) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three design taste fixes that ride on top of the disclaimer collapse: - Single-line profile trigger: render only `option.label` in the Select trigger; keep label+description in the open menu where the description earns its keep. Removes the two-line trigger that broke the row baseline (taller pill, vertical-center cascade). - Group Select + Info icon in an `inline-flex … shrink-0` cluster so they wrap as a single unit and the icon never orphans onto a second row under the refresh button. - Compact timestamp: "Updated less than a minute ago" → "1m ago" rendered in font-mono. Full phrase preserved via `title` attribute. Drops the localized prefix into a tooltip-only context. - Trigger width trimmed 190px → 170px since it no longer needs to fit a description line. --- .../analytics/components/analytics-header.tsx | 15 ++++++++++----- ui/src/pages/analytics/hooks.ts | 17 ++++++++++++++--- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/ui/src/pages/analytics/components/analytics-header.tsx b/ui/src/pages/analytics/components/analytics-header.tsx index 7f9e826f..620fe771 100644 --- a/ui/src/pages/analytics/components/analytics-header.tsx +++ b/ui/src/pages/analytics/components/analytics-header.tsx @@ -54,10 +54,12 @@ export function AnalyticsHeader({

{t('analytics.subtitle')}

-
+
onHideDashboardInternalsChange(e.target.checked)} className={cn('mt-0.5 h-4 w-4 cursor-pointer accent-foreground', FOCUS_RING)} - aria-label="Hide dashboard internals" + aria-label="Hide dashboard web-server logs" />
) : null} diff --git a/ui/src/hooks/use-logs.ts b/ui/src/hooks/use-logs.ts index 702db1f6..b27dc0ae 100644 --- a/ui/src/hooks/use-logs.ts +++ b/ui/src/hooks/use-logs.ts @@ -116,10 +116,10 @@ export function useLogsWorkspace() { const [limit, setLimit] = useState(DEFAULT_LIMIT); const [selectedEntryId, setSelectedEntryId] = useState(null); const [isPaused, setIsPaused] = useState(false); - // Default ON: dashboard self-polling generates 100s of identical entries - // per refresh which drown out real provider activity. Users can opt in to - // see internals via the advanced filter toggle. - const [hideDashboardInternals, setHideDashboardInternals] = useState(true); + // Default OFF: web-server:* entries include dashboard access and WebSocket + // audit evidence, so keep them visible unless the operator opts into noise + // reduction from the advanced filter toggle. + const [hideDashboardInternals, setHideDashboardInternals] = useState(false); const frozenIdsRef = useRef>(new Set()); const deferredSearch = useDeferredValue(search.trim()); @@ -196,8 +196,9 @@ export function useLogsWorkspace() { const ts = Date.parse(entry.timestamp); if (Number.isFinite(ts) && now - ts > cutoffMs) return false; } - // Hide dashboard self-polling unless user opted in. Preserves the - // signal-to-noise ratio for fresh-load investigations. + // Optional noise reduction only: web-server:* entries can contain + // security-relevant dashboard access and WebSocket audit evidence, so + // they remain visible by default. if (hideDashboardInternals && /^web-server:/i.test(entry.source)) return false; return true; }); @@ -273,7 +274,7 @@ export function useLogsWorkspace() { setStageFilter(''); setRequestIdFilter(''); setTimeWindow('all'); - setHideDashboardInternals(true); + setHideDashboardInternals(false); }, []); return { diff --git a/ui/tests/unit/hooks/use-logs.test.tsx b/ui/tests/unit/hooks/use-logs.test.tsx new file mode 100644 index 00000000..f34c6b7a --- /dev/null +++ b/ui/tests/unit/hooks/use-logs.test.tsx @@ -0,0 +1,120 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { ReactNode } from 'react'; +import { AllProviders } from '../../setup/test-utils'; +import { useLogsWorkspace } from '@/hooks/use-logs'; +import type { LogsEntry } from '@/lib/api-client'; + +function createJsonResponse(body: Record, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +const entries: LogsEntry[] = [ + { + id: 'http-audit-1', + timestamp: '2026-05-29T00:00:00.000Z', + level: 'error', + source: 'web-server:http', + event: 'request.completed', + message: 'Dashboard request completed', + processId: 1, + runId: null, + requestId: 'dashboard-audit', + }, + { + id: 'ws-audit-1', + timestamp: '2026-05-29T00:00:01.000Z', + level: 'warn', + source: 'web-server:websocket', + event: 'message.invalid', + message: 'WebSocket client sent invalid JSON', + processId: 1, + runId: null, + requestId: 'dashboard-audit', + }, + { + id: 'provider-1', + timestamp: '2026-05-29T00:00:02.000Z', + level: 'info', + source: 'provider:codex', + event: 'request.completed', + message: 'Provider request completed', + processId: 2, + runId: null, + requestId: 'provider-trace', + }, +]; + +function mockLogsApi(): void { + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith('/api/logs/config')) { + return createJsonResponse({ + logging: { + enabled: true, + level: 'info', + rotate_mb: 10, + retain_days: 7, + redact: true, + live_buffer_size: 150, + }, + }); + } + if (url.startsWith('/api/logs/sources')) { + return createJsonResponse({ + sources: entries.map((entry) => ({ + source: entry.source, + label: entry.source, + kind: 'native', + count: 1, + lastTimestamp: entry.timestamp, + })), + }); + } + if (url.startsWith('/api/logs/entries')) { + return createJsonResponse({ entries }); + } + return createJsonResponse({ error: `Unexpected URL: ${url}` }, 404); + }) + ); +} + +describe('useLogsWorkspace', () => { + it('keeps dashboard audit events visible by default and only hides them on opt-in', async () => { + mockLogsApi(); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useLogsWorkspace(), { wrapper }); + + await waitFor(() => { + expect(result.current.entriesQuery.data?.map((entry) => entry.id)).toEqual([ + 'http-audit-1', + 'ws-audit-1', + 'provider-1', + ]); + }); + + act(() => result.current.setHideDashboardInternals(true)); + + await waitFor(() => { + expect(result.current.entriesQuery.data?.map((entry) => entry.id)).toEqual(['provider-1']); + }); + + act(() => result.current.clearAdvancedFilters()); + + await waitFor(() => { + expect(result.current.entriesQuery.data?.map((entry) => entry.id)).toEqual([ + 'http-audit-1', + 'ws-audit-1', + 'provider-1', + ]); + }); + }); +}); From 457412277b1311decb660620ecdd0cdd78a90e1c Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:53:53 -0400 Subject: [PATCH 079/137] fix(cliproxy): sanitize Codex quota labels --- .../__tests__/quota-fetcher-codex.test.ts | 38 +++++++++++++++++++ src/cliproxy/quota/quota-fetcher-codex.ts | 11 ++---- src/cliproxy/quota/quota-label-sanitizer.ts | 25 ++++++++++++ src/commands/cliproxy/quota-subcommand.ts | 9 +++-- .../cliproxy-quota-subcommand.test.ts | 32 ++++++++++++++++ 5 files changed, 105 insertions(+), 10 deletions(-) create mode 100644 src/cliproxy/quota/quota-label-sanitizer.ts diff --git a/src/cliproxy/quota/__tests__/quota-fetcher-codex.test.ts b/src/cliproxy/quota/__tests__/quota-fetcher-codex.test.ts index 7a913713..d51e3af9 100644 --- a/src/cliproxy/quota/__tests__/quota-fetcher-codex.test.ts +++ b/src/cliproxy/quota/__tests__/quota-fetcher-codex.test.ts @@ -382,6 +382,44 @@ describe('Codex Quota Fetcher', () => { expect(windows[0].featureLabel).toBe('Custom-Feature'); expect(windows[0].usedPercent).toBe(50); }); + + it('should remove terminal control characters from additional limit labels', () => { + const response = { + additional_rate_limits: [ + { + limit_name: '\u001b[2JGPT-5.3-Codex-Spark\u001b]52;c;payload\u0007', + rate_limit: { + primary_window: { used_percent: 25, reset_after_seconds: 3600 }, + }, + }, + ], + }; + + const windows = buildCodexQuotaWindows(response); + + expect(windows).toHaveLength(1); + expect(windows[0].featureLabel).toBe('GPT-5.3-Codex-Spark'); + expect(windows[0].label).toBe('GPT-5.3-Codex-Spark (Primary)'); + }); + + it('should bound additional limit labels before storing them', () => { + const response = { + additional_rate_limits: [ + { + limit_name: `Feature-${'x'.repeat(120)}`, + rate_limit: { + primary_window: { used_percent: 25, reset_after_seconds: 3600 }, + }, + }, + ], + }; + + const windows = buildCodexQuotaWindows(response); + + expect(windows).toHaveLength(1); + expect(windows[0].featureLabel).toHaveLength(80); + expect(windows[0].label).toBe(`${windows[0].featureLabel} (Primary)`); + }); }); describe('buildCodexCoreUsageSummary', () => { diff --git a/src/cliproxy/quota/quota-fetcher-codex.ts b/src/cliproxy/quota/quota-fetcher-codex.ts index 78ab225b..c9d66a94 100644 --- a/src/cliproxy/quota/quota-fetcher-codex.ts +++ b/src/cliproxy/quota/quota-fetcher-codex.ts @@ -11,6 +11,7 @@ import { getAuthDir } from '../config/config-generator'; import { getAccount, getProviderAccounts, getPausedDir } from '../accounts/account-manager'; import { sanitizeEmail, isTokenExpired } from '../auth/auth-utils'; import type { CodexQuotaResult, CodexQuotaWindow, CodexCoreUsageSummary } from './quota-types'; +import { sanitizeCodexFeatureLabel } from './quota-label-sanitizer'; import { extractCanonicalEmailFromAccountId } from '../accounts/email-account-identity'; /** ChatGPT backend API base URL */ @@ -58,8 +59,8 @@ interface CodexRateLimitWindow { * Each entry surfaces its own primary/secondary windows under a feature-specific limit name. */ interface CodexAdditionalRateLimit { - limit_name?: string; - limitName?: string; + limit_name?: unknown; + limitName?: unknown; metered_feature?: string; meteredFeature?: string; rate_limit?: CodexRateLimitWindow; @@ -380,11 +381,7 @@ function buildCodexQuotaWindows(payload: CodexUsageResponse): CodexQuotaWindow[] const entryRateLimit = entry.rate_limit || entry.rateLimit; if (!entryRateLimit) continue; - const rawFeatureLabel = entry.limit_name ?? entry.limitName; - const featureLabel = - typeof rawFeatureLabel === 'string' && rawFeatureLabel.trim().length > 0 - ? rawFeatureLabel.trim() - : 'Additional'; + const featureLabel = sanitizeCodexFeatureLabel(entry.limit_name ?? entry.limitName); addWindow( `${featureLabel} (Primary)`, entryRateLimit.primary_window || entryRateLimit.primaryWindow, diff --git a/src/cliproxy/quota/quota-label-sanitizer.ts b/src/cliproxy/quota/quota-label-sanitizer.ts new file mode 100644 index 00000000..438527ea --- /dev/null +++ b/src/cliproxy/quota/quota-label-sanitizer.ts @@ -0,0 +1,25 @@ +const CODEX_FEATURE_LABEL_FALLBACK = 'Additional'; +const CODEX_FEATURE_LABEL_MAX_LENGTH = 80; +const TERMINAL_ESCAPE_SEQUENCE_REGEX = + /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?|\u001b\[[0-?]*[ -/]*[@-~]/g; +const TERMINAL_CONTROL_CHARS_REGEX = /[\u0000-\u001f\u007f-\u009f]/g; + +/** + * Sanitize upstream Codex feature labels before storing or rendering them. + * + * The quota API is not schema-validated at runtime, so additional-rate-limit + * labels must be constrained to safe, printable strings before they reach the + * terminal. + */ +export function sanitizeCodexFeatureLabel(value: unknown): string { + if (typeof value !== 'string') return CODEX_FEATURE_LABEL_FALLBACK; + + const sanitized = value + .replace(TERMINAL_ESCAPE_SEQUENCE_REGEX, '') + .replace(TERMINAL_CONTROL_CHARS_REGEX, '') + .trim() + .slice(0, CODEX_FEATURE_LABEL_MAX_LENGTH) + .trimEnd(); + + return sanitized.length > 0 ? sanitized : CODEX_FEATURE_LABEL_FALLBACK; +} diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index b53dd770..2efdbd05 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -18,6 +18,7 @@ import { } from '../../cliproxy/accounts/account-manager'; import { fetchAllProviderQuotas } from '../../cliproxy/quota/quota-fetcher'; import { fetchAllCodexQuotas } from '../../cliproxy/quota/quota-fetcher-codex'; +import { sanitizeCodexFeatureLabel } from '../../cliproxy/quota/quota-label-sanitizer'; import { fetchAllClaudeQuotas } from '../../cliproxy/quota/quota-fetcher-claude'; import { pickMostRestrictiveClaudeWeeklyWindow } from '../../cliproxy/quota/quota-fetcher-claude-normalizer'; import { fetchAllGeminiCliQuotas } from '../../cliproxy/quota/quota-fetcher-gemini-cli'; @@ -279,8 +280,8 @@ function inferCodeReviewCadence( * Strip a leading "GPT-X.Y-Codex-" prefix from a feature label and turn the * remainder into a Codex-prefixed display name. Other labels pass through unchanged. */ -function prettifyCodexFeatureLabel(featureLabel: string): string { - const trimmed = featureLabel.trim(); +function prettifyCodexFeatureLabel(featureLabel: unknown): string { + const trimmed = sanitizeCodexFeatureLabel(featureLabel); if (!trimmed) return 'Additional'; const stripped = trimmed.replace(/^GPT-[\d.]+-Codex-/i, ''); if (stripped !== trimmed && stripped.length > 0) { @@ -302,7 +303,7 @@ function getCodexWindowDisplayLabel( } if (window.category === 'additional') { - const pretty = prettifyCodexFeatureLabel(window.featureLabel || window.label || 'Additional'); + const pretty = prettifyCodexFeatureLabel(window.featureLabel ?? window.label); if (window.cadence === '5h') return `${pretty} (5h)`; if (window.cadence === 'weekly') return `${pretty} (weekly)`; return pretty; @@ -850,7 +851,9 @@ const QUOTA_PROVIDER_RUNTIME: Record { expect(resolveDisplayedTier('pro', 'unknown')).toBe('pro'); }); }); + +describe('cliproxy quota subcommand Codex label formatting', () => { + it('falls back for non-string cached Codex feature labels', async () => { + const { getCodexWindowDisplayLabel } = await loadQuotaCommandTestExports(); + + const label = getCodexWindowDisplayLabel({ + label: 'ignored', + resetAfterSeconds: 3600, + category: 'additional', + cadence: '5h', + featureLabel: { unexpected: true }, + } as never); + + expect(label).toBe('Additional (5h)'); + }); + + it('removes terminal control characters from cached Codex feature labels', async () => { + const { getCodexWindowDisplayLabel } = await loadQuotaCommandTestExports(); + + const label = getCodexWindowDisplayLabel({ + label: 'ignored', + resetAfterSeconds: 3600, + category: 'additional', + cadence: 'weekly', + featureLabel: '\u001b[2JGPT-5.3-Codex-Spark\u001b]52;c;payload\u0007', + }); + + expect(label).toBe('Codex Spark (weekly)'); + expect(label).not.toContain('\u001b'); + expect(label).not.toContain('\u0007'); + }); +}); From 920fba7e1333ff53b7826981bf61b238091dbcf6 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:53:59 -0400 Subject: [PATCH 080/137] fix: reject symlinked cleanup directories --- src/commands/cleanup-command.ts | 28 ++++++++++++--------- tests/unit/commands/cleanup-command.test.ts | 28 +++++++++++++++++++++ 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/src/commands/cleanup-command.ts b/src/commands/cleanup-command.ts index 47bc4ac2..1db86c7b 100644 --- a/src/commands/cleanup-command.ts +++ b/src/commands/cleanup-command.ts @@ -36,12 +36,21 @@ function formatBytes(bytes: number): string { return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`; } +/** Return entries for a real directory, rejecting symlinked directory targets. */ +function readRealDirectory(dirPath: string): string[] { + try { + const stats = fs.lstatSync(dirPath); + if (!stats.isDirectory() || stats.isSymbolicLink()) return []; + return fs.readdirSync(dirPath); + } catch { + return []; + } +} + /** Calculate total size of regular top-level files in a directory */ function getDirSize(dirPath: string): number { - if (!fs.existsSync(dirPath)) return 0; - let totalSize = 0; - const entries = fs.readdirSync(dirPath); + const entries = readRealDirectory(dirPath); for (const entry of entries) { const filePath = path.join(dirPath, entry); @@ -60,9 +69,8 @@ function getDirSize(dirPath: string): number { /** Count files in a directory */ function countFiles(dirPath: string): number { - if (!fs.existsSync(dirPath)) return 0; let count = 0; - const entries = fs.readdirSync(dirPath); + const entries = readRealDirectory(dirPath); for (const entry of entries) { const filePath = path.join(dirPath, entry); @@ -78,13 +86,11 @@ function countFiles(dirPath: string): number { return count; } -/** Delete all regular files in a directory (skips symlinks for safety) */ +/** Delete all regular files in a real directory (skips symlinks for safety) */ function cleanDirectory(dirPath: string): { deleted: number; freedBytes: number } { - if (!fs.existsSync(dirPath)) return { deleted: 0, freedBytes: 0 }; - let deleted = 0; let freedBytes = 0; - const files = fs.readdirSync(dirPath); + const files = readRealDirectory(dirPath); for (const file of files) { const filePath = path.join(dirPath, file); @@ -116,11 +122,9 @@ interface ErrorLogInfo { /** Get error log files with metadata */ function getErrorLogFiles(logsDir: string): ErrorLogInfo[] { - if (!fs.existsSync(logsDir)) return []; - const now = Date.now(); const files: ErrorLogInfo[] = []; - const entries = fs.readdirSync(logsDir); + const entries = readRealDirectory(logsDir); for (const entry of entries) { // Only process error-*.log files diff --git a/tests/unit/commands/cleanup-command.test.ts b/tests/unit/commands/cleanup-command.test.ts index 7f234dae..1e09e680 100644 --- a/tests/unit/commands/cleanup-command.test.ts +++ b/tests/unit/commands/cleanup-command.test.ts @@ -53,4 +53,32 @@ describe('cleanup command', () => { logSpy.mockRestore(); } }); + + it('does not follow a symlinked CCS archive directory during cleanup', async () => { + if (process.platform === 'win32') return; + + const ccsLogsDir = getNativeLogsDir(); + const archiveDir = getLogArchiveDir(); + const victimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cleanup-victim-')); + + fs.mkdirSync(ccsLogsDir, { recursive: true }); + fs.writeFileSync(path.join(victimDir, 'keepme.log'), 'do not delete'); + fs.symlinkSync(victimDir, archiveDir, 'dir'); + + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + + try { + await handleCleanupCommand(['--force']); + + const output = logSpy.mock.calls + .flatMap((call) => call.map((value) => String(value))) + .join('\n'); + + expect(output).toContain('No CCS or CLIProxy logs found.'); + expect(fs.existsSync(path.join(victimDir, 'keepme.log'))).toBe(true); + } finally { + logSpy.mockRestore(); + fs.rmSync(victimDir, { recursive: true, force: true }); + } + }); }); From 5807328cb58e6a60637a797b046afa06262043bf Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:54:27 -0400 Subject: [PATCH 081/137] fix: guard self-signed routing request setup --- .../__tests__/routing-strategy-http.test.ts | 70 +++++++++++++++- src/cliproxy/routing/routing-strategy-http.ts | 82 +++++++++++-------- 2 files changed, 115 insertions(+), 37 deletions(-) diff --git a/src/cliproxy/routing/__tests__/routing-strategy-http.test.ts b/src/cliproxy/routing/__tests__/routing-strategy-http.test.ts index 3950665a..5f6682b9 100644 --- a/src/cliproxy/routing/__tests__/routing-strategy-http.test.ts +++ b/src/cliproxy/routing/__tests__/routing-strategy-http.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'bun:test'; +import * as https from 'https'; +import { describe, expect, it, mock, spyOn } from 'bun:test'; import type { ProxyTarget } from '../../proxy/proxy-target-resolver'; async function loadRoutingHttpModule() { @@ -22,6 +23,73 @@ describe('routing-strategy-http', () => { ); }); + it('rejects malformed self-signed HTTPS URLs before arming the request timeout', async () => { + const { fetchCliproxyRoutingResponse } = await loadRoutingHttpModule(); + const originalSetTimeout = globalThis.setTimeout; + globalThis.setTimeout = mock(((handler: TimerHandler, timeout?: number) => { + void handler; + void timeout; + return 1 as unknown as ReturnType; + }) as typeof setTimeout); + + const target: ProxyTarget = { + host: 'bad host', + port: 443, + protocol: 'https', + allowSelfSigned: true, + isRemote: true, + }; + + try { + await expect(fetchCliproxyRoutingResponse(target, 'GET')).rejects.toThrow(); + expect(globalThis.setTimeout).not.toHaveBeenCalled(); + } finally { + globalThis.setTimeout = originalSetTimeout; + } + }); + + it('clears the request timeout when https.request throws synchronously', async () => { + const { fetchCliproxyRoutingResponse } = await loadRoutingHttpModule(); + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + const activeTimers = new Set(); + + globalThis.setTimeout = mock(((handler: TimerHandler, timeout?: number) => { + void handler; + void timeout; + const timerId = activeTimers.size + 1; + activeTimers.add(timerId); + return timerId as unknown as ReturnType; + }) as typeof setTimeout); + globalThis.clearTimeout = mock(((timerId?: ReturnType) => { + activeTimers.delete(timerId as unknown as number); + }) as typeof clearTimeout); + const requestSpy = spyOn(https, 'request').mockImplementation(() => { + throw new Error('sync request failure'); + }); + + const target: ProxyTarget = { + host: 'proxy.example.com', + port: 443, + protocol: 'https', + allowSelfSigned: true, + isRemote: true, + }; + + try { + await expect(fetchCliproxyRoutingResponse(target, 'GET')).rejects.toThrow( + 'sync request failure' + ); + expect(globalThis.setTimeout).toHaveBeenCalledTimes(1); + expect(globalThis.clearTimeout).toHaveBeenCalledTimes(1); + expect(activeTimers.size).toBe(0); + } finally { + requestSpy.mockRestore(); + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; + } + }); + it('builds the remote management URL for routing strategy writes', async () => { const { getCliproxyRoutingManagementUrl } = await loadRoutingHttpModule(); const target: ProxyTarget = { diff --git a/src/cliproxy/routing/routing-strategy-http.ts b/src/cliproxy/routing/routing-strategy-http.ts index 6fdaa37d..f361eb7e 100644 --- a/src/cliproxy/routing/routing-strategy-http.ts +++ b/src/cliproxy/routing/routing-strategy-http.ts @@ -40,6 +40,8 @@ export async function fetchCliproxyRoutingResponse( } } + const requestUrl = new URL(url); + return new Promise((resolve, reject) => { const agent = new https.Agent({ rejectUnauthorized: false }); let settled = false; @@ -51,57 +53,65 @@ export async function fetchCliproxyRoutingResponse( callback(); }; + let req: ReturnType | undefined; const timeoutId = setTimeout(() => { const error = new Error('Request timeout'); - req.destroy(error); + req?.destroy(error); settle(() => reject(error)); }, ROUTING_TIMEOUT_MS); - const req = https.request( - url, - { - method, - headers, - agent, - timeout: ROUTING_TIMEOUT_MS, - }, - (res) => { - let payload = ''; - res.setEncoding('utf8'); - res.on('data', (chunk) => { - payload += chunk; - }); - res.on('end', () => { - settle(() => - resolve( - new Response(payload, { - status: res.statusCode || 500, - statusText: res.statusMessage ?? '', - headers: - typeof res.headers['content-type'] === 'string' - ? { 'Content-Type': res.headers['content-type'] } - : undefined, - }) - ) - ); - }); - } - ); + try { + req = https.request( + requestUrl, + { + method, + headers, + agent, + timeout: ROUTING_TIMEOUT_MS, + }, + (res) => { + let payload = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { + payload += chunk; + }); + res.on('end', () => { + settle(() => + resolve( + new Response(payload, { + status: res.statusCode || 500, + statusText: res.statusMessage ?? '', + headers: + typeof res.headers['content-type'] === 'string' + ? { 'Content-Type': res.headers['content-type'] } + : undefined, + }) + ) + ); + }); + } + ); + } catch (error) { + settle(() => reject(error)); + return; + } - req.on('error', (error) => { + const request = req; + + request.on('error', (error) => { settle(() => reject(error)); }); - req.on('timeout', () => { + request.on('timeout', () => { const error = new Error('Request timeout'); - req.destroy(error); + request.destroy(error); settle(() => reject(error)); }); if (body) { - req.write(JSON.stringify(body)); + request.write(JSON.stringify(body)); } - req.end(); + request.end(); }); } From dd62027d3f4e11274e4ada2872d942a20c4ef0ea Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:54:33 -0400 Subject: [PATCH 082/137] fix(ui): blur account identity badges in privacy mode --- .../account/shared/account-surface-card.tsx | 12 +++- .../setup/wizard/steps/account-step.tsx | 7 ++- .../setup/wizard/steps/variant-step.tsx | 7 ++- .../setup/account-identity-privacy.test.tsx | 55 +++++++++++++++++++ .../shared/account-surface-card.test.tsx | 38 +++++++++++++ 5 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 ui/tests/unit/components/setup/account-identity-privacy.test.tsx diff --git a/ui/src/components/account/shared/account-surface-card.tsx b/ui/src/components/account/shared/account-surface-card.tsx index ad61b231..6641068d 100644 --- a/ui/src/components/account/shared/account-surface-card.tsx +++ b/ui/src/components/account/shared/account-surface-card.tsx @@ -192,6 +192,7 @@ export function AccountSurfaceCard({ @@ -204,6 +205,7 @@ export function AccountSurfaceCard({ title={effectiveCodexBadge.label} className={cn( 'text-[8px] font-semibold px-1.5 py-0.5 rounded-md border shrink-0', + privacyMode && PRIVACY_BLUR_CLASS, getCompactDetailBadgeClass(effectiveCodexBadge.audience) )} > @@ -215,6 +217,7 @@ export function AccountSurfaceCard({ title={identity.audienceLabel} className={cn( 'text-[8px] font-semibold px-1.5 py-0.5 rounded-md shrink-0', + privacyMode && PRIVACY_BLUR_CLASS, getAudienceBadgeClass(identity.audience) )} > @@ -226,6 +229,7 @@ export function AccountSurfaceCard({ title={identity.detailLabel ?? identity.compactDetailLabel} className={cn( 'text-[8px] font-semibold px-1.5 py-0.5 rounded-md border shrink-0', + privacyMode && PRIVACY_BLUR_CLASS, getCompactDetailBadgeClass(identity.audience) )} > @@ -260,6 +264,7 @@ export function AccountSurfaceCard({ @@ -303,6 +309,7 @@ export function AccountSurfaceCard({ variant="outline" className={cn( 'text-[10px] h-4 px-1.5 border-transparent', + privacyMode && PRIVACY_BLUR_CLASS, getAudienceBadgeClass(identity.audience) )} > @@ -310,7 +317,10 @@ export function AccountSurfaceCard({ )} {!isCompact && normalizedProvider !== 'codex' && identity.detailLabel && ( - + {identity.detailLabel} )} diff --git a/ui/src/components/setup/wizard/steps/account-step.tsx b/ui/src/components/setup/wizard/steps/account-step.tsx index dc9b5930..fc0c194a 100644 --- a/ui/src/components/setup/wizard/steps/account-step.tsx +++ b/ui/src/components/setup/wizard/steps/account-step.tsx @@ -53,6 +53,7 @@ export function AccountStep({ variant="outline" className={cn( 'text-[10px] h-4 px-1.5 border-transparent', + privacyMode && PRIVACY_BLUR_CLASS, codexBadge.audience === 'business' ? 'bg-sky-500/12 text-sky-700 dark:text-sky-300' : codexBadge.audience === 'free' @@ -67,6 +68,7 @@ export function AccountStep({ variant="outline" className={cn( 'text-[10px] h-4 px-1.5 border-transparent', + privacyMode && PRIVACY_BLUR_CLASS, identity.audience === 'business' ? 'bg-sky-500/12 text-sky-700 dark:text-sky-300' : identity.audience === 'free' @@ -78,7 +80,10 @@ export function AccountStep({ ) : null} {!codexBadge?.label && identity.detailLabel && ( - + {identity.detailLabel} )} diff --git a/ui/src/components/setup/wizard/steps/variant-step.tsx b/ui/src/components/setup/wizard/steps/variant-step.tsx index 394212de..396cfe2b 100644 --- a/ui/src/components/setup/wizard/steps/variant-step.tsx +++ b/ui/src/components/setup/wizard/steps/variant-step.tsx @@ -89,6 +89,7 @@ export function VariantStep({ variant="outline" className={cn( 'text-[10px] h-4 px-1.5 border-transparent', + privacyMode && PRIVACY_BLUR_CLASS, selectedCodexBadge.audience === 'business' ? 'bg-sky-500/12 text-sky-700 dark:text-sky-300' : selectedCodexBadge.audience === 'free' @@ -103,6 +104,7 @@ export function VariantStep({ variant="outline" className={cn( 'text-[10px] h-4 px-1.5 border-transparent', + privacyMode && PRIVACY_BLUR_CLASS, selectedAccountIdentity.audience === 'business' ? 'bg-sky-500/12 text-sky-700 dark:text-sky-300' : selectedAccountIdentity.audience === 'free' @@ -114,7 +116,10 @@ export function VariantStep({ ) : null} {!selectedCodexBadge?.label && selectedAccountIdentity?.detailLabel && ( - + {selectedAccountIdentity.detailLabel} )} diff --git a/ui/tests/unit/components/setup/account-identity-privacy.test.tsx b/ui/tests/unit/components/setup/account-identity-privacy.test.tsx new file mode 100644 index 00000000..0dc704fa --- /dev/null +++ b/ui/tests/unit/components/setup/account-identity-privacy.test.tsx @@ -0,0 +1,55 @@ +import { render, screen } from '@tests/setup/test-utils'; +import { describe, expect, it, vi } from 'vitest'; +import { AccountStep } from '@/components/setup/wizard/steps/account-step'; +import { VariantStep } from '@/components/setup/wizard/steps/variant-step'; +import type { OAuthAccount } from '@/lib/api-client'; + +const businessAccount: OAuthAccount = { + id: 'victim@example.com#04a0f049-team', + email: 'victim@example.com', + provider: 'gemini', + isDefault: false, + tokenFile: 'gemini-victim@example.com-04a0f049-team.json', + createdAt: '2026-01-01T00:00:00.000Z', +}; + +describe('setup account identity privacy', () => { + it('blurs account metadata badges in the account selection step', () => { + render( + + ); + + expect(screen.getByText('victim@example.com')).toHaveClass('blur-[4px]'); + expect(screen.getByText('Business')).toHaveClass('blur-[4px]'); + expect(screen.getByText('Workspace 04a0f049')).toHaveClass('blur-[4px]'); + }); + + it('blurs account metadata badges in the variant step', () => { + render( + + ); + + expect(screen.getByText('victim@example.com')).toHaveClass('blur-[4px]'); + expect(screen.getByText('Business')).toHaveClass('blur-[4px]'); + expect(screen.getByText('Workspace 04a0f049')).toHaveClass('blur-[4px]'); + }); +}); diff --git a/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx b/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx index 64d88fd0..dce54089 100644 --- a/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx +++ b/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx @@ -146,4 +146,42 @@ describe('AccountSurfaceCard', () => { expect(screen.queryByText('Pers')).not.toBeInTheDocument(); expect(screen.queryByTitle('Free')).not.toBeInTheDocument(); }); + + it('blurs detailed account identity metadata when privacy mode is enabled', () => { + render( + + ); + + expect(screen.getByText('victim@example.com')).toHaveClass('blur-[4px]'); + expect(screen.getByText('Business')).toHaveClass('blur-[4px]'); + expect(screen.getByText('Workspace 04a0f049')).toHaveClass('blur-[4px]'); + }); + + it('blurs compact account identity metadata when privacy mode is enabled', () => { + render( + + ); + + expect(screen.getByText('victim@example.com')).toHaveClass('blur-[4px]'); + expect(screen.getByText('Biz')).toHaveClass('blur-[4px]'); + expect(screen.getByText('04a0f049')).toHaveClass('blur-[4px]'); + }); }); From 54f3b6860a8e81b11c705bf0cbe05eeb8728e668 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:54:54 -0400 Subject: [PATCH 083/137] fix: tolerate null retryable check --- src/utils/__tests__/retry-strategy.test.ts | 20 ++++++++++++++++++++ src/utils/retry-strategy.ts | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/utils/__tests__/retry-strategy.test.ts b/src/utils/__tests__/retry-strategy.test.ts index ad398b81..1e24bc34 100644 --- a/src/utils/__tests__/retry-strategy.test.ts +++ b/src/utils/__tests__/retry-strategy.test.ts @@ -25,6 +25,26 @@ describe('withRetry', () => { expect(fn).toHaveBeenCalledTimes(3); }); + it('uses the default retryability check when retryableCheck is null at runtime', async () => { + let attempt = 0; + const fn = mock(() => { + attempt++; + if (attempt < 2) { + return Promise.reject(new RetryableError('transient failure')); + } + return Promise.resolve('ok'); + }); + + const result = await withRetry(fn, { + maxRetries: 3, + baseDelayMs: 1, + retryableCheck: null, + } as unknown as RetryOptions); + + expect(result).toBe('ok'); + expect(fn).toHaveBeenCalledTimes(2); + }); + it('throws after max retries exhausted', async () => { const fn = mock(() => Promise.reject(new RetryableError('always fails'))); await expect(withRetry(fn, { maxRetries: 2, baseDelayMs: 1 })).rejects.toThrow('always fails'); diff --git a/src/utils/retry-strategy.ts b/src/utils/retry-strategy.ts index 33ebeee3..50489b6b 100644 --- a/src/utils/retry-strategy.ts +++ b/src/utils/retry-strategy.ts @@ -99,7 +99,7 @@ export async function withRetry(fn: () => Promise, options: RetryOptions): throw new Error('withRetry: baseDelayMs must be >= 0'); } - const isRetryable = retryableCheck; + const isRetryable = retryableCheck ?? defaultRetryableCheck; let lastError: unknown; for (let attempt = 0; attempt <= maxRetries; attempt++) { From 4ce6669e26ba4c55e8ca3bc3a9590e128eee2f30 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:55:56 -0400 Subject: [PATCH 084/137] fix(ui): guard invalid log detail timestamps --- ui/src/components/logs/logs-detail-panel.tsx | 3 ++- ui/src/components/logs/utils.ts | 13 +++++++++ .../ui/components/logs-detail-panel.test.tsx | 27 +++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 ui/tests/unit/ui/components/logs-detail-panel.test.tsx diff --git a/ui/src/components/logs/logs-detail-panel.tsx b/ui/src/components/logs/logs-detail-panel.tsx index 09c68564..4ff0d38d 100644 --- a/ui/src/components/logs/logs-detail-panel.tsx +++ b/ui/src/components/logs/logs-detail-panel.tsx @@ -9,6 +9,7 @@ import { LogLevelBadge } from './log-level-badge'; import { LogsEmpty } from './logs-empty'; import { formatJson, + formatLogTimestampIso, getDisplayLatency, getDisplayModule, getDisplayRequestId, @@ -34,7 +35,7 @@ interface OverviewRow { function buildOverviewRows(entry: LogsEntry, sourceLabel?: string): OverviewRow[] { // Use shared accessors so this panel and the list row never diverge. return [ - { label: 'Time', value: new Date(entry.timestamp).toISOString(), mono: true }, + { label: 'Time', value: formatLogTimestampIso(entry.timestamp), mono: true }, { label: 'Level', value: entry.level }, { label: 'Module', value: getDisplayModule(entry, sourceLabel) }, { label: 'Stage', value: getDisplayStage(entry) }, diff --git a/ui/src/components/logs/utils.ts b/ui/src/components/logs/utils.ts index 1c77dbf3..11180fc5 100644 --- a/ui/src/components/logs/utils.ts +++ b/ui/src/components/logs/utils.ts @@ -21,6 +21,19 @@ export function formatLogTimestamp(timestamp: string | null | undefined) { }).format(date); } +export function formatLogTimestampIso(timestamp: string | null | undefined) { + if (!timestamp) { + return 'No activity yet'; + } + + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) { + return timestamp; + } + + return date.toISOString(); +} + export function formatRelativeLogTime(timestamp: string | null | undefined) { if (!timestamp) { return 'No activity yet'; diff --git a/ui/tests/unit/ui/components/logs-detail-panel.test.tsx b/ui/tests/unit/ui/components/logs-detail-panel.test.tsx new file mode 100644 index 00000000..4e57da9c --- /dev/null +++ b/ui/tests/unit/ui/components/logs-detail-panel.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from '@tests/setup/test-utils'; +import { describe, expect, it } from 'vitest'; +import { LogsDetailPanel } from '@/components/logs/logs-detail-panel'; +import type { LogsEntry } from '@/lib/api-client'; + +function buildEntry(overrides: Partial = {}): LogsEntry { + return { + id: 'entry-1', + timestamp: '2026-04-07T11:00:00.000Z', + level: 'info', + source: 'dashboard', + event: 'logs.bootstrap', + message: 'Dashboard log entry', + processId: 25582, + runId: 'run-1', + ...overrides, + }; +} + +describe('LogsDetailPanel', () => { + it('falls back to the raw timestamp when the selected log entry has an invalid timestamp', () => { + render(); + + expect(screen.getByText('not-a-date')).toBeInTheDocument(); + expect(screen.getByTitle('not-a-date')).toHaveTextContent('not-a-date'); + }); +}); From 480f3ba46949b4d44b1a36fa5a6dcc2699a311d5 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:56:17 -0400 Subject: [PATCH 085/137] fix: harden provider alias normalization --- src/cliproxy/quota/quota-manager.ts | 10 +++--- src/management/checks/image-analysis-check.ts | 5 ++- src/web-server/models-dev/pricing-resolver.ts | 28 +++++++-------- tests/unit/data-aggregator.test.ts | 34 +++++++++++++++++++ 4 files changed, 54 insertions(+), 23 deletions(-) diff --git a/src/cliproxy/quota/quota-manager.ts b/src/cliproxy/quota/quota-manager.ts index 8574b079..041e2748 100644 --- a/src/cliproxy/quota/quota-manager.ts +++ b/src/cliproxy/quota/quota-manager.ts @@ -483,9 +483,8 @@ export async function reconcileExhaustedRotationAccounts( return []; } - const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = await import( - '../accounts/account-safety' - ); + const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = + await import('../accounts/account-safety'); restoreExpiredQuotaPauses(); const config = loadOrCreateUnifiedConfig(); @@ -593,9 +592,8 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise * Fix image analysis configuration issues */ export async function fixImageAnalysisConfig(): Promise { - const { updateConfig, loadOrCreateUnifiedConfig } = await import( - '../../config/config-loader-facade' - ); + const { updateConfig, loadOrCreateUnifiedConfig } = + await import('../../config/config-loader-facade'); const config = loadOrCreateUnifiedConfig(); let fixed = false; diff --git a/src/web-server/models-dev/pricing-resolver.ts b/src/web-server/models-dev/pricing-resolver.ts index 715e7600..9be07d1b 100644 --- a/src/web-server/models-dev/pricing-resolver.ts +++ b/src/web-server/models-dev/pricing-resolver.ts @@ -18,19 +18,19 @@ export interface ModelsDevPricingLookupOptions { provider?: string; } -const PROVIDER_ALIASES: Record = { - agy: 'google', - antigravity: 'google', - claude: 'anthropic', - codex: 'openai', - copilot: 'github-copilot', - gemini: 'google', - ghcp: 'github-copilot', - github: 'github-copilot', - kimi: 'moonshotai', - moonshot: 'moonshotai', - qwen: 'alibaba', -}; +const PROVIDER_ALIASES = new Map([ + ['agy', 'google'], + ['antigravity', 'google'], + ['claude', 'anthropic'], + ['codex', 'openai'], + ['copilot', 'github-copilot'], + ['gemini', 'google'], + ['ghcp', 'github-copilot'], + ['github', 'github-copilot'], + ['kimi', 'moonshotai'], + ['moonshot', 'moonshotai'], + ['qwen', 'alibaba'], +]); function normalizeId(value: string): string { return value.trim().toLowerCase(); @@ -45,7 +45,7 @@ export function normalizeModelsDevProviderId( ): string | undefined { if (!provider) return undefined; const normalized = normalizeId(provider); - return PROVIDER_ALIASES[normalized] ?? normalized; + return PROVIDER_ALIASES.get(normalized) ?? normalized; } function splitProviderPrefix(model: string): { provider?: string; model: string } { diff --git a/tests/unit/data-aggregator.test.ts b/tests/unit/data-aggregator.test.ts index e15d468c..5aa90210 100644 --- a/tests/unit/data-aggregator.test.ts +++ b/tests/unit/data-aggregator.test.ts @@ -150,6 +150,40 @@ describe('aggregateDailyUsage', () => { expect(result[0].modelBreakdowns[0].provider).toBe('github-copilot'); expect(result[0].modelBreakdowns[0].inputTokens).toBe(3000); }); + + test('handles provider names inherited from Object.prototype', () => { + const entries: RawUsageEntry[] = [ + createEntry({ model: 'model-a', target: '__proto__' }), + createEntry({ model: 'model-b', target: 'constructor' }), + createEntry({ model: 'model-c', target: 'toString' }), + ]; + + const daily = aggregateDailyUsage(entries); + const hourly = aggregateHourlyUsage(entries); + const monthly = aggregateMonthlyUsage(entries); + const session = aggregateSessionUsage(entries); + + expect(daily[0].modelBreakdowns.map((item) => item.provider)).toEqual([ + '__proto__', + 'constructor', + 'tostring', + ]); + expect(hourly[0].modelBreakdowns.map((item) => item.provider)).toEqual([ + '__proto__', + 'constructor', + 'tostring', + ]); + expect(monthly[0].modelBreakdowns.map((item) => item.provider)).toEqual([ + '__proto__', + 'constructor', + 'tostring', + ]); + expect(session[0].modelBreakdowns.map((item) => item.provider)).toEqual([ + '__proto__', + 'constructor', + 'tostring', + ]); + }); }); // ============================================================================ From 65adab5bec2814a2f7deb681067892def77b03ad Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:56:18 -0400 Subject: [PATCH 086/137] fix: normalize cliproxy v3 usage snapshots --- src/cliproxy/quota/quota-manager.ts | 10 +- src/management/checks/image-analysis-check.ts | 5 +- src/web-server/usage/cliproxy-usage-syncer.ts | 18 ++- .../usage/cliproxy-usage-transformer.ts | 147 ++++++++++++++++-- .../web-server/cliproxy-usage-syncer.test.ts | 76 ++++++++- .../cliproxy-usage-transformer.test.ts | 59 ++++++- 6 files changed, 279 insertions(+), 36 deletions(-) diff --git a/src/cliproxy/quota/quota-manager.ts b/src/cliproxy/quota/quota-manager.ts index 8574b079..041e2748 100644 --- a/src/cliproxy/quota/quota-manager.ts +++ b/src/cliproxy/quota/quota-manager.ts @@ -483,9 +483,8 @@ export async function reconcileExhaustedRotationAccounts( return []; } - const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = await import( - '../accounts/account-safety' - ); + const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = + await import('../accounts/account-safety'); restoreExpiredQuotaPauses(); const config = loadOrCreateUnifiedConfig(); @@ -593,9 +592,8 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise * Fix image analysis configuration issues */ export async function fixImageAnalysisConfig(): Promise { - const { updateConfig, loadOrCreateUnifiedConfig } = await import( - '../../config/config-loader-facade' - ); + const { updateConfig, loadOrCreateUnifiedConfig } = + await import('../../config/config-loader-facade'); const config = loadOrCreateUnifiedConfig(); let fixed = false; diff --git a/src/web-server/usage/cliproxy-usage-syncer.ts b/src/web-server/usage/cliproxy-usage-syncer.ts index 83d15a69..74a8d928 100644 --- a/src/web-server/usage/cliproxy-usage-syncer.ts +++ b/src/web-server/usage/cliproxy-usage-syncer.ts @@ -15,6 +15,7 @@ import { buildCliproxyUsageHistoryAggregates, extractCliproxyUsageHistoryDetails, mergeCliproxyUsageHistoryDetails, + normalizeCliproxyUsageHistoryDetail, pruneCliproxyUsageHistoryDetails, type CliproxyUsageHistoryDetail, } from './cliproxy-usage-transformer'; @@ -197,14 +198,27 @@ function readSnapshot(emitWarnings = true): CliproxyUsageSnapshot | null { return null; } - if (!Array.isArray((snapshot as CliproxyUsageSnapshot).details)) { + const details = (snapshot as CliproxyUsageSnapshot).details; + if (!Array.isArray(details)) { if (emitWarnings) { console.log(info('CLIProxy snapshot details missing, will refresh on next sync')); } return null; } - return snapshot as CliproxyUsageSnapshot; + const normalizedDetails = details + .map((detail) => normalizeCliproxyUsageHistoryDetail(detail)) + .filter((detail): detail is CliproxyUsageHistoryDetail => detail !== null); + const { daily, hourly, monthly } = buildCliproxyUsageHistoryAggregates(normalizedDetails); + + return { + version: SNAPSHOT_VERSION, + timestamp: Number(snapshot.timestamp), + details: normalizedDetails, + daily, + hourly, + monthly, + }; } if (snapshot.version === 1 || snapshot.version === 2) { diff --git a/src/web-server/usage/cliproxy-usage-transformer.ts b/src/web-server/usage/cliproxy-usage-transformer.ts index 5f96d632..48e496e3 100644 --- a/src/web-server/usage/cliproxy-usage-transformer.ts +++ b/src/web-server/usage/cliproxy-usage-transformer.ts @@ -64,30 +64,112 @@ function createHistoryDetail( detail: CliproxyRequestDetail ): CliproxyUsageHistoryDetail { const pricingProvider = normalizeUsageProvider(provider) ?? provider.trim().toLowerCase(); + const inputTokens = detail.tokens?.input_tokens ?? 0; + const outputTokens = detail.tokens?.output_tokens ?? 0; + const cacheReadTokens = detail.tokens?.cached_tokens ?? 0; + return { model, provider: pricingProvider, timestamp: detail.timestamp, source: detail.source, authIndex: String(detail.auth_index), - inputTokens: detail.tokens?.input_tokens ?? 0, - outputTokens: detail.tokens?.output_tokens ?? 0, - cacheReadTokens: detail.tokens?.cached_tokens ?? 0, + inputTokens, + outputTokens, + cacheReadTokens, requestCount: 1, - cost: calculateCost( - { - inputTokens: detail.tokens?.input_tokens ?? 0, - outputTokens: detail.tokens?.output_tokens ?? 0, - cacheCreationTokens: 0, - cacheReadTokens: detail.tokens?.cached_tokens ?? 0, - }, + cost: calculateHistoryDetailCost( model, - { provider: pricingProvider } + pricingProvider, + inputTokens, + outputTokens, + cacheReadTokens ), failed: detail.failed, }; } +function calculateHistoryDetailCost( + model: string, + provider: string | undefined, + inputTokens: number, + outputTokens: number, + cacheReadTokens: number +): number { + return calculateCost( + { + inputTokens, + outputTokens, + cacheCreationTokens: 0, + cacheReadTokens, + }, + model, + provider ? { provider } : undefined + ); +} + +function normalizePersistedNumber(value: unknown, fallback = 0): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +} + +function normalizePersistedProvider(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + + const trimmed = value.trim(); + if (trimmed.length === 0) return undefined; + + return normalizeUsageProvider(trimmed) ?? trimmed.toLowerCase(); +} + +export function normalizeCliproxyUsageHistoryDetail( + detail: unknown +): CliproxyUsageHistoryDetail | null { + if (!detail || typeof detail !== 'object') return null; + + const candidate = detail as Record; + if ( + typeof candidate.model !== 'string' || + typeof candidate.timestamp !== 'string' || + typeof candidate.source !== 'string' || + !Number.isFinite(Date.parse(candidate.timestamp)) + ) { + return null; + } + + const provider = normalizePersistedProvider(candidate.provider); + const inputTokens = normalizePersistedNumber(candidate.inputTokens); + const outputTokens = normalizePersistedNumber(candidate.outputTokens); + const cacheReadTokens = normalizePersistedNumber(candidate.cacheReadTokens); + const requestCount = Math.max(1, normalizePersistedNumber(candidate.requestCount, 1)); + const cost = normalizePersistedNumber( + candidate.cost, + calculateHistoryDetailCost( + candidate.model, + provider, + inputTokens, + outputTokens, + cacheReadTokens + ) + ); + + return { + model: candidate.model, + ...(provider && { provider }), + timestamp: candidate.timestamp, + source: candidate.source, + authIndex: + typeof candidate.authIndex === 'string' + ? candidate.authIndex + : String(candidate.authIndex ?? ''), + inputTokens, + outputTokens, + cacheReadTokens, + requestCount, + cost, + failed: candidate.failed === true, + }; +} + // ============================================================================ // FLATTEN // ============================================================================ @@ -143,12 +225,53 @@ function createHistorySignature(detail: CliproxyUsageHistoryDetail): string { ].join('|'); } +function createProviderlessHistorySignature(detail: CliproxyUsageHistoryDetail): string { + return [ + detail.model, + detail.timestamp, + detail.source, + detail.authIndex, + detail.inputTokens, + detail.outputTokens, + detail.cacheReadTokens, + detail.requestCount, + detail.failed ? '1' : '0', + ].join('|'); +} + +function hydrateProviderlessHistoryDetails( + existing: CliproxyUsageHistoryDetail[], + incoming: CliproxyUsageHistoryDetail[] +): CliproxyUsageHistoryDetail[] { + const incomingByProviderlessSignature = new Map(); + for (const detail of incoming) { + if (!detail.provider) continue; + + const signature = createProviderlessHistorySignature(detail); + incomingByProviderlessSignature.set(signature, [ + ...(incomingByProviderlessSignature.get(signature) ?? []), + detail, + ]); + } + + return existing.map((detail) => { + if (detail.provider) return detail; + + const matches = incomingByProviderlessSignature.get(createProviderlessHistorySignature(detail)); + const providers = new Set(matches?.map((match) => match.provider).filter(Boolean)); + if (!matches || providers.size !== 1) return detail; + + return { ...detail, provider: matches[0].provider, cost: matches[0].cost }; + }); +} + export function mergeCliproxyUsageHistoryDetails( existing: CliproxyUsageHistoryDetail[], incoming: CliproxyUsageHistoryDetail[] ): CliproxyUsageHistoryDetail[] { + const hydratedExisting = hydrateProviderlessHistoryDetails(existing, incoming); const existingCounts = new Map(); - for (const detail of existing) { + for (const detail of hydratedExisting) { const signature = createHistorySignature(detail); const entry = existingCounts.get(signature); if (entry) { diff --git a/tests/unit/web-server/cliproxy-usage-syncer.test.ts b/tests/unit/web-server/cliproxy-usage-syncer.test.ts index db9dffa1..9f12cb07 100644 --- a/tests/unit/web-server/cliproxy-usage-syncer.test.ts +++ b/tests/unit/web-server/cliproxy-usage-syncer.test.ts @@ -20,7 +20,10 @@ function fetchRawResponse(): Promise { return Promise.resolve(rawResponse); } -function buildResponse(inputTokens: number, timestamp = '2026-03-02T12:00:00.000Z'): CliproxyUsageApiResponse { +function buildResponse( + inputTokens: number, + timestamp = '2026-03-02T12:00:00.000Z' +): CliproxyUsageApiResponse { return { usage: { apis: { @@ -175,6 +178,69 @@ describe('cliproxy usage syncer', () => { }); }); + it('normalizes old v3 snapshot details before loading and merging history', async () => { + await runWithScopedConfigDir(ccsDir, async () => { + const snapshotPath = path.join(ccsDir, 'cache', 'cliproxy-usage', 'latest.json'); + fs.mkdirSync(path.dirname(snapshotPath), { recursive: true }); + fs.writeFileSync( + snapshotPath, + JSON.stringify({ + version: 3, + timestamp: Date.now() - 60_000, + details: [ + { + model: 'gemini-2.5-pro', + timestamp: '2026-03-02T12:00:00.000Z', + source: 'account-a', + authIndex: '0', + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 10, + failed: false, + }, + ], + daily: [ + { + date: '2026-03-02', + source: 'cliproxy', + inputTokens: 100, + outputTokens: 20, + cacheCreationTokens: 0, + cacheReadTokens: 10, + cost: null, + totalCost: null, + modelsUsed: ['gemini-2.5-pro'], + modelBreakdowns: [], + }, + ], + hourly: [], + monthly: [], + }), + 'utf-8' + ); + + const loaded = await loadCachedCliproxyData(); + expect(loaded.daily[0].inputTokens).toBe(100); + expect(Number.isFinite(loaded.daily[0].totalCost)).toBe(true); + expect(loaded.hourly[0].requestCount).toBe(1); + + await syncCliproxyUsage(fetchRawResponse); + + const cached = await loadCachedCliproxyData(); + expect(cached.daily).toHaveLength(1); + expect(cached.daily[0].inputTokens).toBe(100); + expect(cached.hourly[0].requestCount).toBe(1); + + const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8')) as { + details: Array<{ requestCount?: number; cost?: number; provider?: string }>; + }; + expect(snapshot.details).toHaveLength(1); + expect(snapshot.details[0].requestCount).toBe(1); + expect(Number.isFinite(snapshot.details[0].cost)).toBe(true); + expect(snapshot.details[0].provider).toBe('google'); + }); + }); + it('migrates legacy v1 and v2 snapshots forward before merging new history', async () => { for (const version of [1, 2]) { await runWithScopedConfigDir(ccsDir, async () => { @@ -264,7 +330,9 @@ describe('cliproxy usage syncer', () => { const cached = await loadCachedCliproxyData(); expect(cached.daily.map((entry) => entry.date)).toEqual(['2026-03-02', '2026-03-01']); - expect(cached.hourly.find((entry) => entry.hour === '2026-03-01 12:00')?.requestCount).toBe(7); + expect(cached.hourly.find((entry) => entry.hour === '2026-03-01 12:00')?.requestCount).toBe( + 7 + ); }); } }); @@ -274,9 +342,7 @@ describe('cliproxy usage syncer', () => { await syncCliproxyUsage(() => Promise.resolve(buildResponse(100, '2024-01-01T12:00:00.000Z')) ); - await syncCliproxyUsage(() => - Promise.resolve(buildResponse(200, new Date().toISOString())) - ); + await syncCliproxyUsage(() => Promise.resolve(buildResponse(200, new Date().toISOString()))); const cached = await loadCachedCliproxyData(); expect(cached.daily.some((entry) => entry.date === '2024-01-01')).toBe(false); diff --git a/tests/unit/web-server/cliproxy-usage-transformer.test.ts b/tests/unit/web-server/cliproxy-usage-transformer.test.ts index 4e590aa6..3bea1553 100644 --- a/tests/unit/web-server/cliproxy-usage-transformer.test.ts +++ b/tests/unit/web-server/cliproxy-usage-transformer.test.ts @@ -11,6 +11,7 @@ import { buildCliproxyUsageHistoryAggregates, extractCliproxyUsageHistoryDetails, mergeCliproxyUsageHistoryDetails, + normalizeCliproxyUsageHistoryDetail, transformCliproxyToDailyUsage, transformCliproxyToHourlyUsage, transformCliproxyToMonthlyUsage, @@ -111,18 +112,12 @@ describe('cliproxy usage transformer', () => { expect(flat[0].provider).toBe('google'); expect( flat.some( - (entry) => - entry.failed === true && - entry.inputTokens === 40 && - entry.outputTokens === 10 + (entry) => entry.failed === true && entry.inputTokens === 40 && entry.outputTokens === 10 ) ).toBe(true); expect( flat.some( - (entry) => - entry.failed === true && - entry.inputTokens === 0 && - entry.outputTokens === 0 + (entry) => entry.failed === true && entry.inputTokens === 0 && entry.outputTokens === 0 ) ).toBe(false); }); @@ -134,6 +129,54 @@ describe('cliproxy usage transformer', () => { expect(merged).toHaveLength(details.length); }); + it('normalizes old v3 history details before merging with provider-aware entries', () => { + const [incoming] = extractCliproxyUsageHistoryDetails({ + usage: { + apis: { + gemini: { + models: { + 'gemini-2.5-pro': { + details: [ + { + timestamp: '2026-03-01T10:15:00.000Z', + source: 'account-a', + auth_index: 0, + tokens: { + input_tokens: 100, + output_tokens: 50, + reasoning_tokens: 0, + cached_tokens: 20, + total_tokens: 170, + }, + failed: false, + }, + ], + }, + }, + }, + }, + }, + }); + const legacyDetail = normalizeCliproxyUsageHistoryDetail({ + model: incoming.model, + timestamp: incoming.timestamp, + source: incoming.source, + authIndex: incoming.authIndex, + inputTokens: incoming.inputTokens, + outputTokens: incoming.outputTokens, + cacheReadTokens: incoming.cacheReadTokens, + failed: incoming.failed, + }); + + expect(legacyDetail?.requestCount).toBe(1); + expect(Number.isFinite(legacyDetail?.cost)).toBe(true); + + const merged = mergeCliproxyUsageHistoryDetails([legacyDetail!], [incoming]); + expect(merged).toHaveLength(1); + expect(merged[0].provider).toBe(incoming.provider); + expect(merged[0].cost).toBe(incoming.cost); + }); + it('preserves legitimate duplicate requests when the incoming batch has more occurrences', () => { const details = extractCliproxyUsageHistoryDetails(sampleResponse); const repeated = [details[0], { ...details[0] }]; From e09e930c38ef17dfecf000804504ab6ed4f38367 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:56:21 -0400 Subject: [PATCH 087/137] fix(codex): gate ccsxp config overrides --- src/cliproxy/quota/quota-manager.ts | 10 +- src/management/checks/image-analysis-check.ts | 5 +- src/targets/codex-adapter.ts | 13 +- tests/unit/bin/ccsxp-runtime.test.ts | 15 +- tests/unit/targets/codex-adapter.test.ts | 29 ++- .../targets/codex-runtime-integration.test.ts | 209 ++++++++++-------- 6 files changed, 170 insertions(+), 111 deletions(-) diff --git a/src/cliproxy/quota/quota-manager.ts b/src/cliproxy/quota/quota-manager.ts index 8574b079..041e2748 100644 --- a/src/cliproxy/quota/quota-manager.ts +++ b/src/cliproxy/quota/quota-manager.ts @@ -483,9 +483,8 @@ export async function reconcileExhaustedRotationAccounts( return []; } - const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = await import( - '../accounts/account-safety' - ); + const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = + await import('../accounts/account-safety'); restoreExpiredQuotaPauses(); const config = loadOrCreateUnifiedConfig(); @@ -593,9 +592,8 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise * Fix image analysis configuration issues */ export async function fixImageAnalysisConfig(): Promise { - const { updateConfig, loadOrCreateUnifiedConfig } = await import( - '../../config/config-loader-facade' - ); + const { updateConfig, loadOrCreateUnifiedConfig } = + await import('../../config/config-loader-facade'); const config = loadOrCreateUnifiedConfig(); let fixed = false; diff --git a/src/targets/codex-adapter.ts b/src/targets/codex-adapter.ts index 333dda86..c1a02bed 100644 --- a/src/targets/codex-adapter.ts +++ b/src/targets/codex-adapter.ts @@ -289,20 +289,19 @@ export class CodexAdapter implements TargetAdapter { const runtimeConfigOverrides = creds?.runtimeConfigOverrides ?? []; if (profileType === 'default') { - const modelFlagNormalization = isCcsxpCliproxyShortcut() + const isCcsxpShortcut = isCcsxpCliproxyShortcut(); + const modelFlagNormalization = isCcsxpShortcut ? normalizeCcsxpCodexModelFlagAliases(userArgs) : { args: userArgs, overrides: [] }; const overrides = [...runtimeConfigOverrides, ...modelFlagNormalization.overrides]; if (reasoningOverride) { overrides.push(`model_reasoning_effort=${formatTomlString(reasoningOverride)}`); } - if (overrides.length === 0) { - return modelFlagNormalization.args; + const needsConfigOverrideSupport = isCcsxpShortcut || overrides.length > 0; + if (needsConfigOverrideSupport && !codexBinarySupportsConfigOverrides(options?.binaryInfo)) { + throw buildConfigOverrideSupportError(hydrateCodexBinaryVersion(options?.binaryInfo)); } - if (!codexBinarySupportsConfigOverrides(options?.binaryInfo)) { - if (reasoningOverride || modelFlagNormalization.overrides.length > 0) { - throw buildConfigOverrideSupportError(hydrateCodexBinaryVersion(options?.binaryInfo)); - } + if (overrides.length === 0) { return modelFlagNormalization.args; } return [...buildConfigOverrideArgs(overrides), ...modelFlagNormalization.args]; diff --git a/tests/unit/bin/ccsxp-runtime.test.ts b/tests/unit/bin/ccsxp-runtime.test.ts index f853f114..b2a7a5a9 100644 --- a/tests/unit/bin/ccsxp-runtime.test.ts +++ b/tests/unit/bin/ccsxp-runtime.test.ts @@ -1,16 +1,18 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import * as os from 'os'; import * as path from 'path'; +import { CCSXP_CLIPROXY_SHORTCUT_ENV } from '../../../src/targets/codex-cliproxy-provider-config'; const wrapperPath = require.resolve('../../../src/bin/ccsxp-runtime.ts'); const ccsPath = require.resolve('../../../src/ccs.ts'); describe('ccsxp runtime wrapper', () => { const originalArgv = process.argv; - const originalEntryTarget = process.env.CCS_INTERNAL_ENTRY_TARGET; - const originalCodexHome = process.env.CODEX_HOME; - const originalCcsCodexProfile = process.env.CCS_CODEX_PROFILE; - const originalCcsxpCodexHome = process.env.CCSXP_CODEX_HOME; + const originalEntryTarget = process.env.CCS_INTERNAL_ENTRY_TARGET; + const originalShortcut = process.env[CCSXP_CLIPROXY_SHORTCUT_ENV]; + const originalCodexHome = process.env.CODEX_HOME; + const originalCcsCodexProfile = process.env.CCS_CODEX_PROFILE; + const originalCcsxpCodexHome = process.env.CCSXP_CODEX_HOME; beforeEach(() => { delete require.cache[wrapperPath]; @@ -25,6 +27,11 @@ describe('ccsxp runtime wrapper', () => { } else { process.env.CCS_INTERNAL_ENTRY_TARGET = originalEntryTarget; } + if (originalShortcut === undefined) { + delete process.env[CCSXP_CLIPROXY_SHORTCUT_ENV]; + } else { + process.env[CCSXP_CLIPROXY_SHORTCUT_ENV] = originalShortcut; + } if (originalCodexHome === undefined) { delete process.env.CODEX_HOME; } else { diff --git a/tests/unit/targets/codex-adapter.test.ts b/tests/unit/targets/codex-adapter.test.ts index 5a48af16..6d4927d9 100644 --- a/tests/unit/targets/codex-adapter.test.ts +++ b/tests/unit/targets/codex-adapter.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { CodexAdapter } from '../../../src/targets/codex-adapter'; +import { CCSXP_CLIPROXY_SHORTCUT_ENV } from '../../../src/targets/codex-cliproxy-provider-config'; import { buildCodexBrowserMcpOverrides, getCodexBrowserMcpServerName, @@ -97,6 +98,30 @@ describe('CodexAdapter', () => { ).toThrow(/does not advertise --config overrides/); }); + test('rejects ccsxp default launches when codex lacks config override support', () => { + const originalShortcut = process.env[CCSXP_CLIPROXY_SHORTCUT_ENV]; + process.env[CCSXP_CLIPROXY_SHORTCUT_ENV] = '1'; + try { + expect(() => + adapter.buildArgs('default', ['--config', 'model_provider="cliproxy"', '--version'], { + profileType: 'default', + binaryInfo: { + path: '/tmp/codex', + needsShell: false, + version: 'codex-cli 0.1.0', + features: [], + }, + }) + ).toThrow(/does not advertise --config overrides/); + } finally { + if (originalShortcut === undefined) { + delete process.env[CCSXP_CLIPROXY_SHORTCUT_ENV]; + } else { + process.env[CCSXP_CLIPROXY_SHORTCUT_ENV] = originalShortcut; + } + } + }); + test('injects transient config overrides for CCS-backed launches', () => { const runtimeConfigOverrides = buildCodexBrowserMcpOverrides(); const args = adapter.buildArgs('codex', ['--search'], { @@ -120,7 +145,9 @@ describe('CodexAdapter', () => { expect(args).toContain('model_provider="ccs_runtime"'); expect(args).toContain('model_providers.ccs_runtime.env_key="CCS_CODEX_API_KEY"'); expect(args).toContain('model="gpt-5.4"'); - expect(args).toContain(`mcp_servers.${getCodexBrowserMcpServerName()}.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`); + expect(args).toContain( + `mcp_servers.${getCodexBrowserMcpServerName()}.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}` + ); expect(args).toContain('model_reasoning_effort="high"'); expect(args[args.length - 1]).toBe('--search'); }); diff --git a/tests/unit/targets/codex-runtime-integration.test.ts b/tests/unit/targets/codex-runtime-integration.test.ts index a14dfed4..75e28197 100644 --- a/tests/unit/targets/codex-runtime-integration.test.ts +++ b/tests/unit/targets/codex-runtime-integration.test.ts @@ -714,8 +714,10 @@ process.exit(0); expect(result.status).toBe(0); expect(result.stdout).toContain('codex-cli 9.9.9-test'); - expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ - ['--config', 'model_provider="cliproxy"', '--version'], + expect(readLoggedCodexCalls(codexArgsLogPath).at(-1)).toEqual([ + '--config', + 'model_provider="cliproxy"', + '--version', ]); }); @@ -737,19 +739,17 @@ process.exit(0); }); expect(result.status).toBe(0); - expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ - { - CODEX_HOME: path.join(tmpHome, '.codex'), - CODEX_CI: undefined, - CODEX_MANAGED_BY_BUN: undefined, - CODEX_THREAD_ID: undefined, - ANTHROPIC_BASE_URL: undefined, - CLIPROXY_API_KEY: 'ccs-internal-managed', - CCS_BROWSER_USER_DATA_DIR: undefined, - CCS_BROWSER_PROFILE_DIR: undefined, - CCS_BROWSER_DEVTOOLS_WS_URL: undefined, - }, - ]); + expect(readLoggedCodexEnv(codexEnvLogPath).at(-1)).toEqual({ + CODEX_HOME: path.join(tmpHome, '.codex'), + CODEX_CI: undefined, + CODEX_MANAGED_BY_BUN: undefined, + CODEX_THREAD_ID: undefined, + ANTHROPIC_BASE_URL: undefined, + CLIPROXY_API_KEY: 'ccs-internal-managed', + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, + }); }); it('routes default ccsxp launches through native Codex with the cliproxy provider override', () => { @@ -768,24 +768,50 @@ process.exit(0); }); expect(result.status).toBe(0); - expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ - ['--config', 'model_provider="cliproxy"', 'fix failing tests'], + expect(readLoggedCodexCalls(codexArgsLogPath).at(-1)).toEqual([ + '--config', + 'model_provider="cliproxy"', + 'fix failing tests', ]); const codexConfig = fs.readFileSync(path.join(tmpHome, '.codex', 'config.toml'), 'utf8'); expect(codexConfig).toContain('[model_providers.cliproxy]'); expect(codexConfig).toContain('env_key = "CLIPROXY_API_KEY"'); - expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ - { - CODEX_HOME: path.join(tmpHome, '.codex'), - CODEX_CI: undefined, - CODEX_MANAGED_BY_BUN: undefined, - CODEX_THREAD_ID: undefined, - ANTHROPIC_BASE_URL: undefined, - CLIPROXY_API_KEY: 'ccs-internal-managed', - CCS_BROWSER_USER_DATA_DIR: undefined, - CCS_BROWSER_PROFILE_DIR: undefined, - CCS_BROWSER_DEVTOOLS_WS_URL: undefined, - }, + expect(readLoggedCodexEnv(codexEnvLogPath).at(-1)).toEqual({ + CODEX_HOME: path.join(tmpHome, '.codex'), + CODEX_CI: undefined, + CODEX_MANAGED_BY_BUN: undefined, + CODEX_THREAD_ID: undefined, + ANTHROPIC_BASE_URL: undefined, + CLIPROXY_API_KEY: 'ccs-internal-managed', + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, + }); + }); + + it('fails fast when ccsxp needs unsupported Codex config overrides', () => { + if (process.platform === 'win32') return; + + const result = runCcsxpAlias(['--version'], { + ...process.env, + CI: '1', + NO_COLOR: '1', + HOME: tmpHome, + CCS_HOME: tmpHome, + CCS_CODEX_PATH: fakeCodexPath, + CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, + CCS_TEST_CODEX_CONFIG_OVERRIDE_STATUS: 'unsupported', + CCS_TEST_CODEX_VERSION: 'codex-cli 9.9.9-test', + CCS_TEST_CODEX_HELP: ' -p, --profile \n', + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain('Codex CLI (codex-cli 9.9.9-test)'); + expect(result.stderr).toContain('does not advertise --config overrides'); + expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ + ['-c', 'model="gpt-5"', '--version'], + ['--help'], + ['--version'], ]); }); @@ -808,8 +834,10 @@ process.exit(0); }); expect(result.status).toBe(0); - expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ - ['--config', 'model_provider="cliproxy"', 'fix failing tests'], + expect(readLoggedCodexCalls(codexArgsLogPath).at(-1)).toEqual([ + '--config', + 'model_provider="cliproxy"', + 'fix failing tests', ]); const codexConfig = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); expect(codexConfig).toContain('model = "gpt-5.5"'); @@ -903,24 +931,24 @@ supports_websockets = false }); expect(result.status).toBe(0); - expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ - ['--config', 'model_provider="cliproxy"', 'fix failing tests'], + expect(readLoggedCodexCalls(codexArgsLogPath).at(-1)).toEqual([ + '--config', + 'model_provider="cliproxy"', + 'fix failing tests', ]); const codexConfig = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); expect(codexConfig).toContain('env_key = "CCS_CUSTOM_CLIPROXY_TOKEN"'); - expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ - { - CODEX_HOME: codexHome, - CODEX_CI: undefined, - CODEX_MANAGED_BY_BUN: undefined, - CODEX_THREAD_ID: undefined, - ANTHROPIC_BASE_URL: undefined, - CCS_CUSTOM_CLIPROXY_TOKEN: 'ccs-internal-managed', - CCS_BROWSER_USER_DATA_DIR: undefined, - CCS_BROWSER_PROFILE_DIR: undefined, - CCS_BROWSER_DEVTOOLS_WS_URL: undefined, - }, - ]); + expect(readLoggedCodexEnv(codexEnvLogPath).at(-1)).toEqual({ + CODEX_HOME: codexHome, + CODEX_CI: undefined, + CODEX_MANAGED_BY_BUN: undefined, + CODEX_THREAD_ID: undefined, + ANTHROPIC_BASE_URL: undefined, + CCS_CUSTOM_CLIPROXY_TOKEN: 'ccs-internal-managed', + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, + }); }); it('preserves a custom cliproxy provider base_url for ccsxp launches', () => { @@ -954,27 +982,27 @@ supports_websockets = false }); expect(result.status).toBe(0); - expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ - ['--config', 'model_provider="cliproxy"', 'fix failing tests'], + expect(readLoggedCodexCalls(codexArgsLogPath).at(-1)).toEqual([ + '--config', + 'model_provider="cliproxy"', + 'fix failing tests', ]); const codexConfig = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'); expect(codexConfig).toContain( 'base_url = "https://cliproxy.example.com/api/provider/codex/responses"' ); expect(codexConfig).toContain('env_key = "CCS_REMOTE_CLIPROXY_TOKEN"'); - expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ - { - CODEX_HOME: codexHome, - CODEX_CI: undefined, - CODEX_MANAGED_BY_BUN: undefined, - CODEX_THREAD_ID: undefined, - ANTHROPIC_BASE_URL: undefined, - CCS_REMOTE_CLIPROXY_TOKEN: 'ccs-internal-managed', - CCS_BROWSER_USER_DATA_DIR: undefined, - CCS_BROWSER_PROFILE_DIR: undefined, - CCS_BROWSER_DEVTOOLS_WS_URL: undefined, - }, - ]); + expect(readLoggedCodexEnv(codexEnvLogPath).at(-1)).toEqual({ + CODEX_HOME: codexHome, + CODEX_CI: undefined, + CODEX_MANAGED_BY_BUN: undefined, + CODEX_THREAD_ID: undefined, + ANTHROPIC_BASE_URL: undefined, + CCS_REMOTE_CLIPROXY_TOKEN: 'ccs-internal-managed', + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, + }); }); it('keeps ccsxp native when the CCS default profile is a Claude account', () => { @@ -1005,22 +1033,22 @@ supports_websockets = false }); expect(result.status).toBe(0); - expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([ - ['--config', 'model_provider="cliproxy"', 'fix failing tests'], - ]); - expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ - { - CODEX_HOME: path.join(tmpHome, '.codex'), - CODEX_CI: undefined, - CODEX_MANAGED_BY_BUN: undefined, - CODEX_THREAD_ID: undefined, - ANTHROPIC_BASE_URL: undefined, - CLIPROXY_API_KEY: 'ccs-internal-managed', - CCS_BROWSER_USER_DATA_DIR: undefined, - CCS_BROWSER_PROFILE_DIR: undefined, - CCS_BROWSER_DEVTOOLS_WS_URL: undefined, - }, + expect(readLoggedCodexCalls(codexArgsLogPath).at(-1)).toEqual([ + '--config', + 'model_provider="cliproxy"', + 'fix failing tests', ]); + expect(readLoggedCodexEnv(codexEnvLogPath).at(-1)).toEqual({ + CODEX_HOME: path.join(tmpHome, '.codex'), + CODEX_CI: undefined, + CODEX_MANAGED_BY_BUN: undefined, + CODEX_THREAD_ID: undefined, + ANTHROPIC_BASE_URL: undefined, + CLIPROXY_API_KEY: 'ccs-internal-managed', + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, + }); }); it('keeps implicit ccs --target codex launches native when the CCS default is a Claude account', () => { @@ -1045,6 +1073,7 @@ supports_websockets = false HOME: tmpHome, CCS_HOME: tmpHome, CCS_CODEX_PATH: fakeCodexPath, + CODEX_HOME: undefined, CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath, CCS_THINKING: '8192', @@ -1135,7 +1164,9 @@ supports_websockets = false }); expect(result.status).toBe(0); - expect(result.stderr).not.toContain('Codex CLI does not support Claude account-based profiles.'); + expect(result.stderr).not.toContain( + 'Codex CLI does not support Claude account-based profiles.' + ); expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([['fix failing tests']]); expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ { @@ -1205,19 +1236,17 @@ supports_websockets = false }); expect(result.status).toBe(0); - expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ - { - CODEX_HOME: explicitCodexHome, - CODEX_CI: undefined, - CODEX_MANAGED_BY_BUN: undefined, - CODEX_THREAD_ID: undefined, - ANTHROPIC_BASE_URL: undefined, - CLIPROXY_API_KEY: 'ccs-internal-managed', - CCS_BROWSER_USER_DATA_DIR: undefined, - CCS_BROWSER_PROFILE_DIR: undefined, - CCS_BROWSER_DEVTOOLS_WS_URL: undefined, - }, - ]); + expect(readLoggedCodexEnv(codexEnvLogPath).at(-1)).toEqual({ + CODEX_HOME: explicitCodexHome, + CODEX_CI: undefined, + CODEX_MANAGED_BY_BUN: undefined, + CODEX_THREAD_ID: undefined, + ANTHROPIC_BASE_URL: undefined, + CLIPROXY_API_KEY: 'ccs-internal-managed', + CCS_BROWSER_USER_DATA_DIR: undefined, + CCS_BROWSER_PROFILE_DIR: undefined, + CCS_BROWSER_DEVTOOLS_WS_URL: undefined, + }); }); it('fails with a clean CLI error when ccsxp receives a malformed --target flag', () => { From 6671423d79d7841cc2414caee252fbc67b426189 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:56:30 -0400 Subject: [PATCH 088/137] test: restrict claudecode CCS_HOME cleanup --- .../utils/claudecode-env-stripping.test.ts | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts index d3f819cd..ddb3ab71 100644 --- a/tests/unit/utils/claudecode-env-stripping.test.ts +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -145,8 +145,16 @@ function registerChildProcessMock(): void { })); } +const tempCcsHomes = new Set(); + +function createTempCcsHome(prefix: string): string { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempCcsHomes.add(tempHome); + return tempHome; +} + function writeConfigWithAutoUpdatePreference(enabled: boolean): void { - const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-auto-update-pref-')); + const tempHome = createTempCcsHome('ccs-auto-update-pref-'); process.env.CCS_HOME = tempHome; const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); @@ -158,7 +166,7 @@ preferences: } function writeConfigWithWebSearchSettings(yamlBody: string): void { - const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-websearch-env-')); + const tempHome = createTempCcsHome('ccs-websearch-env-'); process.env.CCS_HOME = tempHome; const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); @@ -225,10 +233,8 @@ describe('CLAUDECODE environment stripping', () => { }); afterEach(async () => { - const tempCcsHome = process.env.CCS_HOME?.startsWith(os.tmpdir()) - ? process.env.CCS_HOME - : undefined; - if (tempCcsHome) { + const tempCcsHome = process.env.CCS_HOME; + if (tempCcsHome && tempCcsHomes.has(tempCcsHome)) { await stopOpenAICompatProxy(); } @@ -277,9 +283,10 @@ describe('CLAUDECODE environment stripping', () => { } } - if (tempCcsHome) { - fs.rmSync(tempCcsHome, { recursive: true, force: true }); + for (const tempHome of tempCcsHomes) { + fs.rmSync(tempHome, { recursive: true, force: true }); } + tempCcsHomes.clear(); }); it('stripClaudeCodeEnv removes CLAUDECODE case-insensitively', () => { From 216fae7d577423e6fa13c49efe836df23dbb8af4 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:56:46 -0400 Subject: [PATCH 089/137] fix(proxy): load pid-only profile daemon state --- src/proxy/proxy-daemon.ts | 2 +- .../proxy/daemon-lifecycle.test.ts | 57 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/proxy/proxy-daemon.ts b/src/proxy/proxy-daemon.ts index 999376d5..19cab649 100644 --- a/src/proxy/proxy-daemon.ts +++ b/src/proxy/proxy-daemon.ts @@ -285,7 +285,7 @@ function getOpenAICompatProxyStateForProfile(profileName: string): OpenAICompatP }; } - return { pid: null, session: null, source: 'profile' }; + return { pid: getOpenAICompatProxyPid(profileName), session: null, source: 'profile' }; } export async function listOpenAICompatProxyStatuses(): Promise { diff --git a/tests/integration/proxy/daemon-lifecycle.test.ts b/tests/integration/proxy/daemon-lifecycle.test.ts index 0efcad03..194919ae 100644 --- a/tests/integration/proxy/daemon-lifecycle.test.ts +++ b/tests/integration/proxy/daemon-lifecycle.test.ts @@ -6,6 +6,7 @@ import * as path from 'path'; import getPort from 'get-port'; import { getOpenAICompatProxyStatus, + isOpenAICompatProxyRunning, listOpenAICompatProxyStatuses, startOpenAICompatProxy, stopOpenAICompatProxy, @@ -919,6 +920,62 @@ describe('openai proxy daemon lifecycle', () => { expect(secondStart.authToken).not.toBe('stale-token-a'); }); + it('stops pid-only profile daemons instead of only deleting their state', async () => { + const port = await getPort(); + const settingsPath = path.join(tempDir, 'pid-only-stop.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'sk-pid-only-stop', + ANTHROPIC_MODEL: 'gpt-4.1', + }, + }), + 'utf8' + ); + + const profile = resolveOpenAICompatProfileConfig('pid-only-stop', settingsPath, { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'sk-pid-only-stop', + ANTHROPIC_MODEL: 'gpt-4.1', + }); + if (!profile) { + throw new Error('Expected pid-only-stop OpenAI-compatible profile'); + } + + const started = await startOpenAICompatProxy(profile, { port }); + expect(started.success).toBe(true); + expect(started.pid).toBeDefined(); + + const pid = started.pid; + if (!pid) { + throw new Error('Expected pid-only-stop daemon pid'); + } + + try { + fs.unlinkSync(getOpenAICompatProxySessionPath('pid-only-stop')); + + const statuses = await listOpenAICompatProxyStatuses(); + expect(statuses).toContainEqual({ + running: false, + profileName: 'pid-only-stop', + pid, + }); + + const stopped = await stopOpenAICompatProxy(); + expect(stopped.success).toBe(true); + expect(fs.existsSync(getOpenAICompatProxyPidPath('pid-only-stop'))).toBe(false); + expect(await isOpenAICompatProxyRunning(port, 'pid-only-stop')).toBe(false); + } finally { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // Already stopped. + } + } + }, 35000); + it('replaces pid-only proxy state before starting a new daemon', async () => { const firstPort = await getPort(); const replacementPort = await getPort(); From 92252189b1c2b70b71c104a9ce87d6b2fe293a7d Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:56:49 -0400 Subject: [PATCH 090/137] fix(browser): handle DevTools close endpoint response --- lib/mcp/ccs-browser-server.cjs | 9 ++++- ...browser-mcp-session-and-intercepts.test.ts | 39 +++++++++++++++++++ tests/unit/hooks/browser-mcp-test-harness.ts | 13 +++++++ 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index bfb0d2f2..3799c74b 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -1230,11 +1230,16 @@ function getTools() { ]; } -async function fetchJson(url, options = undefined) { +async function fetchOk(url, options = undefined) { const response = await fetch(url, options); if (!response.ok) { throw new Error(`HTTP ${response.status} for ${url}`); } + return response; +} + +async function fetchJson(url, options = undefined) { + const response = await fetchOk(url, options); return await response.json(); } @@ -5193,7 +5198,7 @@ async function handleClosePage(toolArgs) { activeRecordingSession = null; } - await fetchJson(`${getHttpUrl()}/json/close/${encodeURIComponent(page.id)}`); + await fetchOk(`${getHttpUrl()}/json/close/${encodeURIComponent(page.id)}`, { method: 'PUT' }); const interceptSession = interceptSessionsByPageId.get(page.id); if (interceptSession) { closeSocket(interceptSession.ws); diff --git a/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts b/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts index 5d324aa7..67ea287e 100644 --- a/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts +++ b/tests/unit/hooks/browser-mcp-session-and-intercepts.test.ts @@ -433,6 +433,45 @@ describe('ccs-browser MCP server - session and interception', () => { expect(listText).not.toContain('Docs'); }); + it('closes a page when Chrome DevTools requires PUT and returns text', async () => { + const responses = await runMcpRequests( + [ + { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, + { id: 'page-2', title: 'Docs', currentUrl: 'https://example.com/docs' }, + ], + [ + { + jsonrpc: '2.0', + id: 8331, + method: 'tools/call', + params: { name: 'browser_select_page', arguments: { pageIndex: 1 } }, + }, + { + jsonrpc: '2.0', + id: 8332, + method: 'tools/call', + params: { name: 'browser_close_page', arguments: {} }, + }, + { + jsonrpc: '2.0', + id: 8333, + method: 'tools/call', + params: { name: 'browser_get_session_info', arguments: {} }, + }, + ], + { requirePutForClosePage: true, closePageRespondsWithText: true } + ); + + const closeText = getResponseText(responses.find((message) => message.id === 8332)); + expect(closeText).toContain('status: closed'); + expect(closeText).toContain('selectedPageId: page-1'); + + const listText = getResponseText(responses.find((message) => message.id === 8333)); + expect(listText).toContain('0. Home'); + expect(listText).toContain('selected: true'); + expect(listText).not.toContain('Docs'); + }); + it('keeps the selected page when closing a different page', async () => { const responses = await runMcpRequests( [ diff --git a/tests/unit/hooks/browser-mcp-test-harness.ts b/tests/unit/hooks/browser-mcp-test-harness.ts index 52dd8cea..087f1956 100644 --- a/tests/unit/hooks/browser-mcp-test-harness.ts +++ b/tests/unit/hooks/browser-mcp-test-harness.ts @@ -350,6 +350,8 @@ type RunMcpRequestsOptions = { childEnv?: NodeJS.ProcessEnv; responseTimeoutMs?: number; requirePutForNewPage?: boolean; + requirePutForClosePage?: boolean; + closePageRespondsWithText?: boolean; }; function encodeMessage(message: unknown): string { @@ -746,6 +748,12 @@ function createMockBrowser(pagesInput: MockPageState[]) { } if (req.url?.startsWith('/json/close/')) { + if (options.requirePutForClosePage && req.method !== 'PUT') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'method not allowed' })); + return; + } + const targetId = decodeURIComponent(req.url.slice('/json/close/'.length)); const entry = Array.from(pageStates.entries()).find(([, page]) => page.id === targetId); if (!entry) { @@ -754,6 +762,11 @@ function createMockBrowser(pagesInput: MockPageState[]) { return; } pageStates.delete(entry[0]); + if (options.closePageRespondsWithText) { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Target is closing'); + return; + } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ id: targetId })); return; From 1f7ed656b2b99ed8c25909bf6a66c1dde744855e Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:56:55 -0400 Subject: [PATCH 091/137] fix(ui): preserve Gemini tier fallback without entitlement --- ui/src/components/shared/quota-tooltip-content.tsx | 3 ++- .../shared/quota-tooltip-content.test.tsx | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/ui/src/components/shared/quota-tooltip-content.tsx b/ui/src/components/shared/quota-tooltip-content.tsx index 8fd4f5c0..f11774dc 100644 --- a/ui/src/components/shared/quota-tooltip-content.tsx +++ b/ui/src/components/shared/quota-tooltip-content.tsx @@ -378,7 +378,8 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro if (isGeminiQuotaResult(quota)) { const hasBucketResetTime = quota.buckets.some((bucket) => !!bucket.resetTime); const hasEntitlementTier = - !!quota.entitlement?.rawTierLabel || quota.entitlement?.normalizedTier !== 'unknown'; + !!quota.entitlement?.rawTierLabel || + (!!quota.entitlement && quota.entitlement.normalizedTier !== 'unknown'); const distinctTokenTypes = Array.from( new Set( quota.buckets diff --git a/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx b/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx index bf600694..6780fc7a 100644 --- a/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx +++ b/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx @@ -83,6 +83,20 @@ describe('QuotaTooltipContent', () => { expect(screen.getByText(expectedReset)).toBeInTheDocument(); }); + it('renders the Gemini tier label when entitlement evidence is absent', () => { + const quota = createGeminiQuotaResult({ + entitlement: undefined, + tierLabel: 'Legacy Pro', + tierId: null, + creditBalance: null, + }); + + render(); + + expect(screen.getByText('Tier')).toBeInTheDocument(); + expect(screen.getByText('Legacy Pro')).toBeInTheDocument(); + }); + it('falls back to the shared reset indicator when Gemini buckets omit reset timestamps', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-01-29T00:00:00Z')); From f9dec01cfdc976442eddb60bd092abadf443d1b4 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:57:02 -0400 Subject: [PATCH 092/137] fix: keep live catalog defaults visible --- .../__tests__/model-catalog-compat.test.ts | 12 +++++ src/cliproxy/quota/quota-manager.ts | 10 ++-- src/cliproxy/services/catalog-cache.ts | 10 ++-- src/management/checks/image-analysis-check.ts | 5 +- ui/src/lib/model-catalogs.ts | 10 +++- ui/src/lib/preset-utils.ts | 9 ++-- ui/tests/unit/ui/lib/preset-utils.test.ts | 49 +++++++++++++++++++ 7 files changed, 87 insertions(+), 18 deletions(-) diff --git a/src/cliproxy/__tests__/model-catalog-compat.test.ts b/src/cliproxy/__tests__/model-catalog-compat.test.ts index 4ec11c1f..9fe98a30 100644 --- a/src/cliproxy/__tests__/model-catalog-compat.test.ts +++ b/src/cliproxy/__tests__/model-catalog-compat.test.ts @@ -55,6 +55,18 @@ describe('model-catalog compatibility lookups', () => { expect(catalog?.models.map((model) => model.id)).toEqual(['gemini-2.5-pro']); }); + it('falls back to a visible live model when the static default is absent', () => { + const catalog = mergeCatalog('gemini', [ + { + id: 'gemini-live-only', + display_name: 'Gemini Live Only', + }, + ]); + + expect(catalog?.defaultModel).toBe('gemini-live-only'); + expect(catalog?.models.map((model) => model.id)).toContain(catalog?.defaultModel); + }); + it('preserves static maxLevel when live thinking metadata omits it', () => { const catalog = mergeCatalog('claude', [ { diff --git a/src/cliproxy/quota/quota-manager.ts b/src/cliproxy/quota/quota-manager.ts index 8574b079..041e2748 100644 --- a/src/cliproxy/quota/quota-manager.ts +++ b/src/cliproxy/quota/quota-manager.ts @@ -483,9 +483,8 @@ export async function reconcileExhaustedRotationAccounts( return []; } - const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = await import( - '../accounts/account-safety' - ); + const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = + await import('../accounts/account-safety'); restoreExpiredQuotaPauses(); const config = loadOrCreateUnifiedConfig(); @@ -593,9 +592,8 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise(); @@ -260,14 +259,11 @@ export function mergeCatalog( } // Process remote models: merge with static entries - const mergedIds = new Set(); const mergedModels: ModelEntry[] = []; for (const remote of filteredRemoteModels) { const remoteEntry = mapRemoteToModelEntry(remote); const staticEntry = staticMap.get(remote.id.toLowerCase()); - mergedIds.add(remote.id.toLowerCase()); - if (staticEntry) { const mergedThinking = remoteEntry.thinking ? { @@ -292,6 +288,12 @@ export function mergeCatalog( } } + const staticDefaultModel = staticCatalog?.defaultModel; + const hasStaticDefaultModel = + typeof staticDefaultModel === 'string' && + mergedModels.some((model) => model.id.toLowerCase() === staticDefaultModel.toLowerCase()); + const defaultModel = hasStaticDefaultModel ? staticDefaultModel : (mergedModels[0]?.id ?? ''); + return { provider, displayName, diff --git a/src/management/checks/image-analysis-check.ts b/src/management/checks/image-analysis-check.ts index 50e9ba99..d6d2dc05 100644 --- a/src/management/checks/image-analysis-check.ts +++ b/src/management/checks/image-analysis-check.ts @@ -112,9 +112,8 @@ export async function runImageAnalysisCheck(results: HealthCheck): Promise * Fix image analysis configuration issues */ export async function fixImageAnalysisConfig(): Promise { - const { updateConfig, loadOrCreateUnifiedConfig } = await import( - '../../config/config-loader-facade' - ); + const { updateConfig, loadOrCreateUnifiedConfig } = + await import('../../config/config-loader-facade'); const config = loadOrCreateUnifiedConfig(); let fixed = false; diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index df7d35dd..74fec42c 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -792,11 +792,19 @@ export function buildUiCatalog( availableModels.some( (model) => normalizeModelId(model.id) === normalizeModelId(fallbackDefaultModel) ); + const hasLiveDefaultModel = availableModels.some( + (model) => normalizeModelId(model.id) === normalizeModelId(liveCatalog.defaultModel) + ); + const defaultModel = hasFallbackDefaultModel + ? fallbackDefaultModel + : hasLiveDefaultModel + ? liveCatalog.defaultModel + : (models[0]?.id ?? ''); return { provider: liveCatalog.provider, displayName: liveCatalog.displayName || staticCatalog?.displayName || provider, - defaultModel: hasFallbackDefaultModel ? fallbackDefaultModel : liveCatalog.defaultModel, + defaultModel, models, }; } diff --git a/ui/src/lib/preset-utils.ts b/ui/src/lib/preset-utils.ts index 04adc14c..7e4d4082 100644 --- a/ui/src/lib/preset-utils.ts +++ b/ui/src/lib/preset-utils.ts @@ -66,11 +66,12 @@ export async function applyDefaultPreset( const defaultModelEntry = resolvedCatalog.models.find((model) => model.id === resolvedCatalog.defaultModel) || resolvedCatalog.models[0]; + const selectedModelId = defaultModelEntry?.id ?? resolvedCatalog.defaultModel; const mapping = defaultModelEntry?.presetMapping || { - default: resolvedCatalog.defaultModel, - opus: resolvedCatalog.defaultModel, - sonnet: resolvedCatalog.defaultModel, - haiku: resolvedCatalog.defaultModel, + default: selectedModelId, + opus: selectedModelId, + sonnet: selectedModelId, + haiku: selectedModelId, }; // Fetch effective API key (respects user customization) diff --git a/ui/tests/unit/ui/lib/preset-utils.test.ts b/ui/tests/unit/ui/lib/preset-utils.test.ts index ecc472ff..4199e8b9 100644 --- a/ui/tests/unit/ui/lib/preset-utils.test.ts +++ b/ui/tests/unit/ui/lib/preset-utils.test.ts @@ -116,6 +116,55 @@ describe('claude preset utils', () => { ); }); + it('builds UI catalogs with a visible default when the live default is stale', () => { + const liveCatalogs = { + gemini: { + provider: 'gemini', + displayName: 'Gemini', + defaultModel: 'gemini-2.5-pro', + models: [{ id: 'gemini-live-only', name: 'Gemini Live Only' }], + }, + }; + + const catalogs = buildUiCatalogs(liveCatalogs); + + expect(catalogs.gemini?.defaultModel).toBe('gemini-live-only'); + expect(catalogs.gemini?.models.map((model) => model.id)).toContain( + catalogs.gemini?.defaultModel + ); + }); + + it('uses the selected visible model for quick setup fallback mappings', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ apiKey: { value: 'managed-key' } }), + }) + .mockResolvedValueOnce({ ok: true }); + + vi.stubGlobal('fetch', fetchMock); + + const result = await applyDefaultPreset('gemini', undefined, { + provider: 'gemini', + displayName: 'Gemini', + defaultModel: 'gemini-2.5-pro', + models: [{ id: 'gemini-live-only', name: 'Gemini Live Only' }], + }); + + expect(result).toEqual({ success: true, presetName: 'Gemini Live Only' }); + + const [, requestInit] = fetchMock.mock.calls[1] ?? []; + const body = JSON.parse(String(requestInit?.body)); + + expect(body.settings.env).toMatchObject({ + ANTHROPIC_MODEL: 'gemini-live-only', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-live-only', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-live-only', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-live-only', + }); + }); + it('keeps Gemini presets on 3.1 Pro while resolving 3/3.1 alias variants', () => { const geminiCatalog = MODEL_CATALOGS.gemini; const latestPro = geminiCatalog.models.find((model) => model.id === 'gemini-3.1-pro-preview'); From 27e16944808cf43210d4505993c9846fdd7309e4 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:57:04 -0400 Subject: [PATCH 093/137] fix(cliproxy): prefer explicit Codex free fallbacks --- .../__tests__/codex-plan-compatibility.test.ts | 15 +++++++++++++++ .../ai-providers/codex-plan-compatibility.ts | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/cliproxy/ai-providers/__tests__/codex-plan-compatibility.test.ts b/src/cliproxy/ai-providers/__tests__/codex-plan-compatibility.test.ts index 28201039..45548c1d 100644 --- a/src/cliproxy/ai-providers/__tests__/codex-plan-compatibility.test.ts +++ b/src/cliproxy/ai-providers/__tests__/codex-plan-compatibility.test.ts @@ -78,6 +78,21 @@ describe('codex plan compatibility', () => { ).toBe('gpt-5.4-mini'); }); + it('prefers a rejected model explicit free-plan fallback over saved paid-only models', () => { + expect( + resolveRuntimeCodexFallbackModel({ + requestedModel: 'gpt-5.3-codex-spark', + modelMap: { + defaultModel: 'gpt-5.3-codex', + opusModel: 'gpt-5.3-codex', + sonnetModel: 'gpt-5.3-codex', + haikuModel: 'gpt-5.3-codex-spark', + }, + excludeModels: ['gpt-5.3-codex-spark'], + }) + ).toBe('gpt-5.4-mini'); + }); + it('tracks Codex thinking caps for current safe defaults, paid models, and legacy aliases', () => { expect(getModelMaxLevel('codex', 'gpt-5.5')).toBe('xhigh'); expect(getModelMaxLevel('codex', 'gpt-5.4')).toBe('xhigh'); diff --git a/src/cliproxy/ai-providers/codex-plan-compatibility.ts b/src/cliproxy/ai-providers/codex-plan-compatibility.ts index 291b396e..dad29a02 100644 --- a/src/cliproxy/ai-providers/codex-plan-compatibility.ts +++ b/src/cliproxy/ai-providers/codex-plan-compatibility.ts @@ -114,8 +114,8 @@ export function resolveRuntimeCodexFallbackModel(options: { (options.excludeModels ?? []).map((model) => normalizeCodexModelId(model)).filter(Boolean) ); const candidates = [ - options.modelMap.defaultModel, getFreePlanFallbackCodexModel(requestedModel), + options.modelMap.defaultModel, options.modelMap.opusModel, options.modelMap.sonnetModel, options.modelMap.haikuModel, From 04dc97aaa4690433e1ee0409cf3edc225eaab02c Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:57:07 -0400 Subject: [PATCH 094/137] fix: persist codex target selections --- docs/system-architecture/target-adapters.md | 2 +- src/cliproxy/quota/quota-manager.ts | 10 ++++------ src/commands/api-command/help.ts | 2 +- src/management/checks/image-analysis-check.ts | 5 ++--- src/targets/target-metadata.ts | 2 +- tests/unit/api/profile-reader.test.ts | 12 ++++++------ tests/unit/commands/api-command-args.test.ts | 8 ++++---- tests/unit/commands/cliproxy-variant-args.test.ts | 6 +++--- tests/unit/targets/target-resolver.test.ts | 4 ++-- tests/unit/web-server/target-parse-routes.test.ts | 4 ++-- 10 files changed, 26 insertions(+), 29 deletions(-) diff --git a/docs/system-architecture/target-adapters.md b/docs/system-architecture/target-adapters.md index 6d785128..a2b11eb4 100644 --- a/docs/system-architecture/target-adapters.md +++ b/docs/system-architecture/target-adapters.md @@ -136,7 +136,7 @@ export function resolveTargetType( // 4. Check profile config if (profileConfig?.target) { - // Persisted targets intentionally exclude runtime-only codex. + // Persisted targets are validated before profile configuration is saved. return profileConfig.target; } diff --git a/src/cliproxy/quota/quota-manager.ts b/src/cliproxy/quota/quota-manager.ts index 8574b079..041e2748 100644 --- a/src/cliproxy/quota/quota-manager.ts +++ b/src/cliproxy/quota/quota-manager.ts @@ -483,9 +483,8 @@ export async function reconcileExhaustedRotationAccounts( return []; } - const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = await import( - '../accounts/account-safety' - ); + const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = + await import('../accounts/account-safety'); restoreExpiredQuotaPauses(); const config = loadOrCreateUnifiedConfig(); @@ -593,9 +592,8 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise', 'command')} Default target: claude or droid (create)` + ` ${color('--target ', 'command')} Default target: claude, droid, or codex (create)` ); writeLine(` ${color('--register', 'command')} Register discovered orphan settings`); writeLine(` ${color('--json', 'command')} JSON output for discover command`); diff --git a/src/management/checks/image-analysis-check.ts b/src/management/checks/image-analysis-check.ts index 50e9ba99..d6d2dc05 100644 --- a/src/management/checks/image-analysis-check.ts +++ b/src/management/checks/image-analysis-check.ts @@ -112,9 +112,8 @@ export async function runImageAnalysisCheck(results: HealthCheck): Promise * Fix image analysis configuration issues */ export async function fixImageAnalysisConfig(): Promise { - const { updateConfig, loadOrCreateUnifiedConfig } = await import( - '../../config/config-loader-facade' - ); + const { updateConfig, loadOrCreateUnifiedConfig } = + await import('../../config/config-loader-facade'); const config = loadOrCreateUnifiedConfig(); let fixed = false; diff --git a/src/targets/target-metadata.ts b/src/targets/target-metadata.ts index 035ba432..00b2f60c 100644 --- a/src/targets/target-metadata.ts +++ b/src/targets/target-metadata.ts @@ -23,7 +23,7 @@ export const TARGET_METADATA: Record = { displayName: 'Codex CLI', runtimeAliases: ['ccs-codex', 'ccsx', 'ccsxp'], legacyAliasEnvVar: 'CCS_CODEX_ALIASES', - persistedTarget: false, + persistedTarget: true, }, } satisfies Record; diff --git a/tests/unit/api/profile-reader.test.ts b/tests/unit/api/profile-reader.test.ts index f677b2a1..c7bcc24f 100644 --- a/tests/unit/api/profile-reader.test.ts +++ b/tests/unit/api/profile-reader.test.ts @@ -56,7 +56,7 @@ describe('profile reader target sanitization', () => { } }); - it('normalizes legacy stored codex targets back to claude for profiles and variants', async () => { + it('normalizes legacy invalid stored targets back to claude for profiles and variants', async () => { const ccsDir = getScopedCcsDir(); fs.mkdirSync(ccsDir, { recursive: true }); fs.writeFileSync( @@ -64,12 +64,12 @@ describe('profile reader target sanitization', () => { JSON.stringify( { profiles: { demo: '~/.ccs/demo.settings.json' }, - profile_targets: { demo: 'codex' }, + profile_targets: { demo: 'glm' }, cliproxy: { routed: { provider: 'codex', settings: '~/.ccs/routed.settings.json', - target: 'codex', + target: 'glm', }, }, }, @@ -94,7 +94,7 @@ describe('profile reader target sanitization', () => { expect(result.variants[0]?.target).toBe('claude'); }); - it('normalizes unified stored codex targets back to claude for profiles and variants', async () => { + it('normalizes unified invalid stored targets back to claude for profiles and variants', async () => { const ccsDir = getScopedCcsDir(); fs.mkdirSync(ccsDir, { recursive: true }); process.env.CCS_UNIFIED_CONFIG = '1'; @@ -106,7 +106,7 @@ describe('profile reader target sanitization', () => { ' demo:', ' type: api', ' settings: ~/.ccs/demo.settings.json', - ' target: codex', + ' target: glm', 'cliproxy:', ' oauth_accounts: {}', ' providers: []', @@ -114,7 +114,7 @@ describe('profile reader target sanitization', () => { ' routed:', ' provider: codex', ' settings: ~/.ccs/routed.settings.json', - ' target: codex', + ' target: glm', '', ].join('\n'), 'utf8' diff --git a/tests/unit/commands/api-command-args.test.ts b/tests/unit/commands/api-command-args.test.ts index 83387831..9eebee5d 100644 --- a/tests/unit/commands/api-command-args.test.ts +++ b/tests/unit/commands/api-command-args.test.ts @@ -71,15 +71,15 @@ describe('api-command arg parser', () => { expect(parsed.target).toBeUndefined(); expect(parsed.errors).toEqual([ - 'Invalid --target value "invalid-target". Use: claude or droid', + 'Invalid --target value "invalid-target". Use: claude, droid, or codex', ]); }); - test('rejects runtime-only codex as a persisted API target value', () => { + test('accepts codex as a persisted API target value', () => { const parsed = parseApiCommandArgs(['my-api', '--target', 'codex']); - expect(parsed.target).toBeUndefined(); - expect(parsed.errors).toEqual(['Invalid --target value "codex". Use: claude or droid']); + expect(parsed.target).toBe('codex'); + expect(parsed.errors).toEqual([]); }); test('collects missing-value error for --target with no value', () => { diff --git a/tests/unit/commands/cliproxy-variant-args.test.ts b/tests/unit/commands/cliproxy-variant-args.test.ts index a810bceb..5e3f514c 100644 --- a/tests/unit/commands/cliproxy-variant-args.test.ts +++ b/tests/unit/commands/cliproxy-variant-args.test.ts @@ -26,11 +26,11 @@ describe('cliproxy variant arg parser', () => { expect(parsed.errors).toEqual(['Missing value for --target']); }); - test('rejects runtime-only codex as a persisted variant target value', () => { + test('accepts codex as a persisted variant target value', () => { const parsed = parseProfileArgs(['variant-a', '--target', 'codex']); - expect(parsed.target).toBeUndefined(); - expect(parsed.errors).toEqual(['Invalid --target value "codex". Use: claude or droid']); + expect(parsed.target).toBe('codex'); + expect(parsed.errors).toEqual([]); }); test('uses last --target value when repeated', () => { diff --git a/tests/unit/targets/target-resolver.test.ts b/tests/unit/targets/target-resolver.test.ts index 72bfbdaa..172da6e0 100644 --- a/tests/unit/targets/target-resolver.test.ts +++ b/tests/unit/targets/target-resolver.test.ts @@ -68,9 +68,9 @@ describe('resolveTargetType', () => { expect(resolveTargetType([], { target: 'invalid-target' as never })).toBe('claude'); }); - it('should ignore runtime-only codex target when it appears in persisted profile config', () => { + it('should use codex from persisted profile config', () => { process.argv = ['node', 'ccs']; - expect(resolveTargetType([], { target: 'codex' })).toBe('claude'); + expect(resolveTargetType([], { target: 'codex' })).toBe('codex'); }); it('should prioritize --target flag over profile config', () => { diff --git a/tests/unit/web-server/target-parse-routes.test.ts b/tests/unit/web-server/target-parse-routes.test.ts index b0446dab..d1942308 100644 --- a/tests/unit/web-server/target-parse-routes.test.ts +++ b/tests/unit/web-server/target-parse-routes.test.ts @@ -7,16 +7,16 @@ describe('route target parsing', () => { it('accepts valid target values', () => { expect(parseProfileTarget('claude')).toBe('claude'); expect(parseProfileTarget('DROID')).toBe('droid'); + expect(parseProfileTarget('codex')).toBe('codex'); expect(parseVariantTarget(' claude ')).toBe('claude'); expect(parseVariantTarget('droid')).toBe('droid'); + expect(parseVariantTarget(' codex ')).toBe('codex'); }); it('returns null for invalid target values', () => { expect(parseProfileTarget('glm')).toBeNull(); - expect(parseProfileTarget('codex')).toBeNull(); expect(parseProfileTarget('')).toBeNull(); expect(parseVariantTarget('factory')).toBeNull(); - expect(parseVariantTarget('codex')).toBeNull(); expect(parseVariantTarget(' ')).toBeNull(); }); From 70601e6364fd1fd0e4cce2fec922ebf33f5ca61f Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:57:09 -0400 Subject: [PATCH 095/137] fix(dashboard): respect account last-used timeline base --- ui/src/components/account/flow-viz/utils.ts | 13 ++++++--- .../components/account/flow-viz/utils.test.ts | 28 +++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/ui/src/components/account/flow-viz/utils.ts b/ui/src/components/account/flow-viz/utils.ts index e6249006..fdbd0e35 100644 --- a/ui/src/components/account/flow-viz/utils.ts +++ b/ui/src/components/account/flow-viz/utils.ts @@ -47,10 +47,15 @@ export function generateConnectionEvents(accounts: AccountData[]): ConnectionEve // Use a shared base time so events from all accounts interleave in the timeline. // Without this, accounts with more recent lastUsedAt dominate the sorted output. const now = Date.now(); - const sharedBaseTime = accounts.reduce((latest, a) => { - const t = a.lastUsedAt ? new Date(a.lastUsedAt).getTime() : now; - return Math.max(latest, isNaN(t) ? now : t); - }, now); + const latestLastUsedAt = accounts.reduce((latest, account) => { + if (!account.lastUsedAt) return latest; + + const timestamp = new Date(account.lastUsedAt).getTime(); + if (Number.isNaN(timestamp)) return latest; + + return latest === undefined ? timestamp : Math.max(latest, timestamp); + }, undefined); + const sharedBaseTime = latestLastUsedAt ?? now; accounts.forEach((account) => { const lastUsed = new Date(sharedBaseTime); diff --git a/ui/tests/unit/ui/components/account/flow-viz/utils.test.ts b/ui/tests/unit/ui/components/account/flow-viz/utils.test.ts index 3d70a166..b5bdba4b 100644 --- a/ui/tests/unit/ui/components/account/flow-viz/utils.test.ts +++ b/ui/tests/unit/ui/components/account/flow-viz/utils.test.ts @@ -40,6 +40,7 @@ describe('generateConnectionEvents()', () => { }); afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -78,6 +79,33 @@ describe('generateConnectionEvents()', () => { expect(emails.has('older@example.com')).toBe(true); }); + it('bases shared timelines on the latest actual lastUsedAt instead of current time', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-29T00:00:00.000Z')); + + const latestActualLastUsedAt = new Date('2021-01-01T00:00:00.000Z').getTime(); + const accounts: AccountData[] = [ + makeAccount({ + id: 'old2020', + email: 'old2020@example.com', + successCount: 1, + lastUsedAt: '2020-01-01T00:00:00.000Z', + }), + makeAccount({ + id: 'old2021', + email: 'old2021@example.com', + successCount: 1, + lastUsedAt: '2021-01-01T00:00:00.000Z', + }), + ]; + + const events = generateConnectionEvents(accounts); + const mostRecentTimestamp = Math.max(...events.map((event) => event.timestamp.getTime())); + + expect(mostRecentTimestamp).toBe(latestActualLastUsedAt); + expect(mostRecentTimestamp).toBeLessThan(Date.now() - 365 * 24 * 60 * 60 * 1000); + }); + it('returns at most MAX_TIMELINE_EVENTS events (cap respected by caller slice)', () => { // generateConnectionEvents returns all events unsorted-by-cap; cap is applied by caller. // But we verify total output does not exceed successCount + failureCount across accounts. From c0cd44ecaa0546b9ca9f2ce55840c539c8bba676 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:57:12 -0400 Subject: [PATCH 096/137] fix(cliproxy): infer emails from alternate token prefixes --- .../account-registry-integrity.test.ts | 34 +++++++++++++++++++ src/cliproxy/accounts/registry.ts | 15 +++++--- src/cliproxy/quota/quota-manager.ts | 10 +++--- src/management/checks/image-analysis-check.ts | 5 ++- 4 files changed, 51 insertions(+), 13 deletions(-) diff --git a/src/cliproxy/accounts/__tests__/account-registry-integrity.test.ts b/src/cliproxy/accounts/__tests__/account-registry-integrity.test.ts index 935f9787..a12f514c 100644 --- a/src/cliproxy/accounts/__tests__/account-registry-integrity.test.ts +++ b/src/cliproxy/accounts/__tests__/account-registry-integrity.test.ts @@ -280,6 +280,40 @@ describe('account registry integrity', () => { }); }); + it('strips alternate token file prefixes when inferring missing emails', async () => { + const cases = [ + { provider: 'gemini', tokenFile: 'google-user@example.com.json', type: 'gemini' }, + { provider: 'codex', tokenFile: 'openai-user@example.com.json', type: 'codex' }, + { provider: 'agy', tokenFile: 'antigravity-user@example.com.json', type: 'antigravity' }, + { + provider: 'ghcp', + tokenFile: 'github-copilot-user@example.com.json', + type: 'github-copilot', + }, + ] as const; + + for (const testCase of cases) { + await withIsolatedHome(async (homeDir) => { + const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync( + path.join(authDir, testCase.tokenFile), + JSON.stringify({ type: testCase.type }), + 'utf8' + ); + + const { discoverExistingAccounts, loadAccountsRegistry } = await loadRegistryModule(); + discoverExistingAccounts(); + const registry = loadAccountsRegistry(); + const providerAccounts = registry.providers[testCase.provider]; + + expect(Object.keys(providerAccounts?.accounts ?? {})).toEqual(['user@example.com']); + expect(providerAccounts?.accounts['user@example.com']?.email).toBe('user@example.com'); + expect(providerAccounts?.accounts['user@example.com']?.tokenFile).toBe(testCase.tokenFile); + }); + } + }); + it('preserves the corrupted registry backup when recovery cannot rewrite accounts.json', async () => { await withIsolatedHome(async (homeDir) => { const authDir = path.join(homeDir, '.ccs', 'cliproxy', 'auth'); diff --git a/src/cliproxy/accounts/registry.ts b/src/cliproxy/accounts/registry.ts index fb688341..bf175186 100644 --- a/src/cliproxy/accounts/registry.ts +++ b/src/cliproxy/accounts/registry.ts @@ -7,6 +7,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as lockfile from 'proper-lockfile'; import { CLIProxyProvider } from '../types'; +import { PROVIDER_CAPABILITIES } from '../provider-capabilities'; import { PROVIDER_TYPE_VALUES } from '../auth/auth-types'; import { getAuthDir, getCliproxyDir } from '../config/config-generator'; import { AccountsRegistry, AccountInfo, PROVIDERS_WITHOUT_EMAIL } from './types'; @@ -61,15 +62,21 @@ function resolveProviderFromTokenType(typeValue: string): CLIProxyProvider | und const EMAIL_FILE_NAME_PATTERN = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i; +function stripTokenFileProviderPrefix(baseName: string, provider: CLIProxyProvider): string { + const knownPrefixes = [...PROVIDER_CAPABILITIES[provider].authFilePrefixes, `${provider}-`].sort( + (a, b) => b.length - a.length + ); + const prefix = knownPrefixes.find((knownPrefix) => baseName.startsWith(knownPrefix)); + + return prefix ? baseName.slice(prefix.length) : baseName; +} + function inferEmailFromTokenFileName( tokenFile: string, provider: CLIProxyProvider ): string | undefined { const baseName = tokenFile.replace(/\.json$/i, ''); - const providerPrefix = `${provider}-`; - const candidate = baseName.startsWith(providerPrefix) - ? baseName.slice(providerPrefix.length) - : baseName; + const candidate = stripTokenFileProviderPrefix(baseName, provider); if (PROVIDERS_WITHOUT_EMAIL.includes(provider)) { const scopedCandidate = candidate.slice(candidate.indexOf('-') + 1); diff --git a/src/cliproxy/quota/quota-manager.ts b/src/cliproxy/quota/quota-manager.ts index 8574b079..041e2748 100644 --- a/src/cliproxy/quota/quota-manager.ts +++ b/src/cliproxy/quota/quota-manager.ts @@ -483,9 +483,8 @@ export async function reconcileExhaustedRotationAccounts( return []; } - const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = await import( - '../accounts/account-safety' - ); + const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = + await import('../accounts/account-safety'); restoreExpiredQuotaPauses(); const config = loadOrCreateUnifiedConfig(); @@ -593,9 +592,8 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise * Fix image analysis configuration issues */ export async function fixImageAnalysisConfig(): Promise { - const { updateConfig, loadOrCreateUnifiedConfig } = await import( - '../../config/config-loader-facade' - ); + const { updateConfig, loadOrCreateUnifiedConfig } = + await import('../../config/config-loader-facade'); const config = loadOrCreateUnifiedConfig(); let fixed = false; From f44b651cd6752053e175301c8c8f911b1be882fc Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:57:17 -0400 Subject: [PATCH 097/137] fix(management): skip broken shared plugin entries --- src/management/shared-manager.ts | 14 +++++++++++++- tests/unit/shared-manager.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index 6ce5b2ce..728fa0e9 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -354,7 +354,19 @@ class SharedManager { } const entryPath = path.join(sharedPluginsPath, entry.name); - const stats = fs.statSync(entryPath); + let stats: fs.Stats; + try { + stats = fs.statSync(entryPath); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + console.log( + warn( + `Skipping plugins/${entry.name}: unable to inspect shared plugin entry${code ? ` (${code})` : ''}` + ) + ); + continue; + } + items.set(entry.name, { name: entry.name, type: stats.isDirectory() ? 'directory' : 'file', diff --git a/tests/unit/shared-manager.test.ts b/tests/unit/shared-manager.test.ts index 063ae9a4..555953eb 100644 --- a/tests/unit/shared-manager.test.ts +++ b/tests/unit/shared-manager.test.ts @@ -307,6 +307,31 @@ describe('SharedManager', () => { }); describe('marketplace registry ownership', () => { + it('skips unstatable shared plugin entries during instance linking', () => { + const manager = new SharedManager(); + const instancePath = instanceDir('work'); + const sharedPluginsPath = path.join(claudeDir(), 'plugins'); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + + fs.mkdirSync(instancePath, { recursive: true }); + fs.mkdirSync(sharedPluginsPath, { recursive: true }); + fs.symlinkSync( + path.join(sharedPluginsPath, 'missing-plugin'), + path.join(sharedPluginsPath, 'broken-plugin'), + 'dir' + ); + + expect(() => manager.linkSharedDirectories(instancePath)).not.toThrow(); + expect(fs.existsSync(path.join(instancePath, 'plugins', 'broken-plugin'))).toBe(false); + expect( + logSpy.mock.calls.some(([message]) => + String(message).includes( + 'Skipping plugins/broken-plugin: unable to inspect shared plugin entry' + ) + ) + ).toBe(true); + }); + it('writes global and instance registries with different authoritative install locations', () => { const globalRegistryPath = path.join(claudeDir(), 'plugins', 'known_marketplaces.json'); ensureMarketplacePayload(claudeDir()); From 62a4d44b583fea7222fe94dabce1f6c93d5558f3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 30 May 2026 18:57:59 +0000 Subject: [PATCH 098/137] chore(release): 8.1.4-dev.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e6fe3f8f..84b1035f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.1.4-dev.3", + "version": "8.1.4-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 8114ff016e5c29c869692a9812b3187f2842ccae Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 13:21:01 -0400 Subject: [PATCH 099/137] fix: validate browser flags before proxy side effects --- .../__tests__/executor-option-value.test.ts | 58 +++++++++++++++++++ src/cliproxy/executor/browser-launch-setup.ts | 5 +- src/cliproxy/executor/index.ts | 20 +++++-- src/cliproxy/executor/proxy-resolver.ts | 38 ++++++++---- src/cliproxy/quota/quota-manager.ts | 10 ++-- src/management/checks/image-analysis-check.ts | 5 +- 6 files changed, 109 insertions(+), 27 deletions(-) diff --git a/src/cliproxy/executor/__tests__/executor-option-value.test.ts b/src/cliproxy/executor/__tests__/executor-option-value.test.ts index 9315f3f7..52323e9c 100644 --- a/src/cliproxy/executor/__tests__/executor-option-value.test.ts +++ b/src/cliproxy/executor/__tests__/executor-option-value.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, jest } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import * as http from 'http'; import { execClaudeWithCLIProxy, hasGitLabTokenLoginFlag, readOptionValue } from '../index'; describe('readOptionValue', () => { @@ -57,6 +58,7 @@ describe('execClaudeWithCLIProxy browser flag validation', () => { fs.writeFileSync(fakeClaudePath, '#!/bin/sh\nexit 0\n', { mode: 0o755 }); fs.chmodSync(fakeClaudePath, 0o755); originalCcsHome = process.env.CCS_HOME; + process.exitCode = 0; process.env.CCS_HOME = tmpHome; }); @@ -66,9 +68,65 @@ describe('execClaudeWithCLIProxy browser flag validation', () => { } else { delete process.env.CCS_HOME; } + process.exitCode = 0; fs.rmSync(tmpHome, { recursive: true, force: true }); }); + it('validates conflicting browser launch flags before remote proxy checks', async () => { + let requestCount = 0; + const server = http.createServer((_req, res) => { + requestCount += 1; + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); + }); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + throw new Error('Test server did not bind to a TCP port'); + } + + const exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined as never) as typeof process.exit); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + try { + await execClaudeWithCLIProxy( + fakeClaudePath, + 'gemini', + [ + '--proxy-host', + '127.0.0.1', + '--proxy-port', + String(address.port), + '--proxy-auth-token', + 'SECRET_TOKEN_FOR_VALIDATION', + '--remote-only', + '--browser', + '--no-browser', + ], + {} + ); + + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errorSpy).toHaveBeenCalledWith( + '[X] Use either `--browser` or `--no-browser`, not both.' + ); + expect(requestCount).toBe(0); + } finally { + exitSpy.mockRestore(); + errorSpy.mockRestore(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); + it('exits cleanly when conflicting browser launch flags are provided', async () => { const exitSpy = jest .spyOn(process, 'exit') diff --git a/src/cliproxy/executor/browser-launch-setup.ts b/src/cliproxy/executor/browser-launch-setup.ts index 94405a33..89a8d0ef 100644 --- a/src/cliproxy/executor/browser-launch-setup.ts +++ b/src/cliproxy/executor/browser-launch-setup.ts @@ -9,7 +9,7 @@ * 4. Browser MCP ensure + sync-to-config-dir */ -import { warn } from '../../utils/ui'; +import { fail, warn } from '../../utils/ui'; import { type BrowserLaunchOverride, ensureBrowserMcpOrThrow, @@ -49,7 +49,8 @@ export function resolveBrowserLaunchFlags(argsWithoutProxy: string[]): { browserLaunchOverride = browserLaunchFlags.override; argsWithoutBrowserFlags = browserLaunchFlags.argsWithoutFlags; } catch (error) { - console.error(warn((error as Error).message)); + console.error(fail((error as Error).message)); + process.exitCode = 1; process.exit(1); return { browserLaunchOverride: undefined, argsWithoutBrowserFlags }; } diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index d88ff83e..f72408be 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -63,7 +63,7 @@ import { } from './thinking-override-resolver'; import { shouldStartHttpsTunnel } from './https-tunnel-policy'; import { filterCcsFlags, parseExecutorFlags, validateFlagCombinations } from './arg-parser'; -import { resolveExecutorProxy } from './proxy-resolver'; +import { resolveExecutorProxy, resolveExecutorProxyConfig } from './proxy-resolver'; import { buildProxyChain } from './proxy-chain-builder'; import { warnBrokenModels } from './model-warnings'; import { launchClaude } from './claude-launcher'; @@ -129,8 +129,21 @@ export async function execClaudeWithCLIProxy( // Collect all providers to validate (default + composite tiers) const allProviders = [provider, ...compositeProviders]; + const proxyResolution = resolveExecutorProxyConfig(args, { + unifiedConfig, + allProviders, + verbose, + cfg, + log, + }); + + const { browserLaunchOverride, argsWithoutBrowserFlags } = resolveBrowserLaunchFlags( + proxyResolution.argsWithoutProxy + ); + if (process.exitCode === 1) return; + const { proxyConfig, useRemoteProxy, localBackend, binaryPath, argsWithoutProxy } = - await resolveExecutorProxy(args, { + await resolveExecutorProxy(proxyResolution, { unifiedConfig, allProviders, verbose, @@ -138,9 +151,6 @@ export async function execClaudeWithCLIProxy( log, }); - const { browserLaunchOverride, argsWithoutBrowserFlags } = - resolveBrowserLaunchFlags(argsWithoutProxy); - // Setup first-class CCS WebSearch runtime ensureWebSearchMcpOrThrow(); const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow(); diff --git a/src/cliproxy/executor/proxy-resolver.ts b/src/cliproxy/executor/proxy-resolver.ts index eae39abb..bade6f32 100644 --- a/src/cliproxy/executor/proxy-resolver.ts +++ b/src/cliproxy/executor/proxy-resolver.ts @@ -24,20 +24,23 @@ import type { ResolvedProxyConfig } from '../types'; import type { UnifiedConfig } from '../../config/schemas/unified-config'; import { isNetworkError, handleNetworkError } from './retry-handler'; -/** Result returned from resolveExecutorProxy */ -export interface ResolvedProxy { +export interface ResolvedExecutorProxyConfig { /** Resolved proxy config after merging CLI > ENV > config.yaml > defaults */ proxyConfig: ResolvedProxyConfig; + /** Args after proxy-related flags are stripped out */ + argsWithoutProxy: string[]; + /** Mutated executor config (port resolved and validated) */ + cfg: ExecutorConfig; +} + +/** Result returned from resolveExecutorProxy */ +export interface ResolvedProxy extends ResolvedExecutorProxyConfig { /** Whether to use the remote proxy (vs spawning a local one) */ useRemoteProxy: boolean; /** Which local backend binary to use ('original' | 'plus') */ localBackend: CLIProxyBackend; /** Absolute path to CLIProxy binary; undefined when useRemoteProxy=true */ binaryPath: string | undefined; - /** Args after proxy-related flags are stripped out */ - argsWithoutProxy: string[]; - /** Mutated executor config (port resolved and validated) */ - cfg: ExecutorConfig; } /** Dependencies injected by the orchestrator */ @@ -50,16 +53,15 @@ export interface ResolveExecutorProxyContext { } /** - * Resolves proxy configuration, checks remote reachability, selects the local - * backend, and ensures the CLIProxy binary is present when running locally. + * Resolves side-effect-free proxy configuration and strips proxy flags. * * Mutates `context.cfg.port` in-place (same as original orchestrator behaviour). */ -export async function resolveExecutorProxy( +export function resolveExecutorProxyConfig( args: string[], context: ResolveExecutorProxyContext -): Promise { - const { unifiedConfig, allProviders, verbose: _verbose, cfg, log } = context; +): ResolvedExecutorProxyConfig { + const { unifiedConfig, cfg, log } = context; // Resolve proxy config from CLI flags > ENV > config.yaml > defaults const cliproxyServerConfig = unifiedConfig.cliproxy_server; @@ -98,6 +100,20 @@ export async function resolveExecutorProxy( log(`Remote host: ${proxyConfig.host}:${proxyConfig.port} (${proxyConfig.protocol})`); } + return { proxyConfig, argsWithoutProxy, cfg }; +} + +/** + * Resolves proxy configuration, checks remote reachability, selects the local + * backend, and ensures the CLIProxy binary is present when running locally. + */ +export async function resolveExecutorProxy( + resolvedConfig: ResolvedExecutorProxyConfig, + context: ResolveExecutorProxyContext +): Promise { + const { allProviders, verbose: _verbose } = context; + const { proxyConfig, argsWithoutProxy, cfg } = resolvedConfig; + // Check remote proxy reachability let useRemoteProxy = false; let localBackend: CLIProxyBackend = 'original'; diff --git a/src/cliproxy/quota/quota-manager.ts b/src/cliproxy/quota/quota-manager.ts index 8574b079..041e2748 100644 --- a/src/cliproxy/quota/quota-manager.ts +++ b/src/cliproxy/quota/quota-manager.ts @@ -483,9 +483,8 @@ export async function reconcileExhaustedRotationAccounts( return []; } - const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = await import( - '../accounts/account-safety' - ); + const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = + await import('../accounts/account-safety'); restoreExpiredQuotaPauses(); const config = loadOrCreateUnifiedConfig(); @@ -593,9 +592,8 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise * Fix image analysis configuration issues */ export async function fixImageAnalysisConfig(): Promise { - const { updateConfig, loadOrCreateUnifiedConfig } = await import( - '../../config/config-loader-facade' - ); + const { updateConfig, loadOrCreateUnifiedConfig } = + await import('../../config/config-loader-facade'); const config = loadOrCreateUnifiedConfig(); let fixed = false; From 1f1cc056ef038f491d588e56f2f81f7c0a9acfd1 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 30 May 2026 15:09:42 -0400 Subject: [PATCH 100/137] test: update proxy resolver coverage --- .../executor/__tests__/proxy-resolver.test.ts | 170 +++++++----------- src/cliproxy/quota/quota-manager.ts | 10 +- src/management/checks/image-analysis-check.ts | 5 +- 3 files changed, 77 insertions(+), 108 deletions(-) diff --git a/src/cliproxy/executor/__tests__/proxy-resolver.test.ts b/src/cliproxy/executor/__tests__/proxy-resolver.test.ts index 86af7137..d67f4797 100644 --- a/src/cliproxy/executor/__tests__/proxy-resolver.test.ts +++ b/src/cliproxy/executor/__tests__/proxy-resolver.test.ts @@ -5,7 +5,7 @@ * logic extracted from executor/index.ts. */ -import { beforeEach, describe, expect, it, jest } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it, jest } from 'bun:test'; import type { ResolveExecutorProxyContext } from '../proxy-resolver'; import type { ExecutorConfig } from '../../types'; import type { UnifiedConfig } from '../../../config/schemas/unified-config'; @@ -15,11 +15,29 @@ import type { UnifiedConfig } from '../../../config/schemas/unified-config'; const mockEnsureCLIProxyBinary = jest.fn().mockResolvedValue('/usr/local/bin/cliproxy'); const mockGetConfiguredBackend = jest.fn().mockReturnValue('original'); const mockGetPlusBackendUnavailableMessage = jest.fn().mockReturnValue('Plus backend unavailable'); +const mockInstallCliproxyVersion = jest.fn().mockResolvedValue(undefined); +const mockFetchLatestCliproxyVersion = jest.fn().mockResolvedValue('test-version'); +const mockCheckCliproxyUpdate = jest.fn().mockResolvedValue({ available: false }); jest.mock('../../binary-manager', () => ({ ensureCLIProxyBinary: mockEnsureCLIProxyBinary, getConfiguredBackend: mockGetConfiguredBackend, getPlusBackendUnavailableMessage: mockGetPlusBackendUnavailableMessage, + getStoredConfiguredBackend: mockGetConfiguredBackend, + getCLIProxyPath: jest.fn().mockReturnValue('/usr/local/bin/cliproxy'), + getInstalledCliproxyVersion: jest.fn().mockReturnValue('test-version'), + isCLIProxyInstalled: jest.fn().mockReturnValue(true), + resolveLocalBackend: mockGetConfiguredBackend, + syncPlusFallbackStateIfNeeded: jest.fn(), + installCliproxyVersion: mockInstallCliproxyVersion, + fetchLatestCliproxyVersion: mockFetchLatestCliproxyVersion, + checkCliproxyUpdate: mockCheckCliproxyUpdate, + getPinnedVersion: jest.fn().mockReturnValue(null), + savePinnedVersion: jest.fn(), + clearPinnedVersion: jest.fn(), + isVersionPinned: jest.fn().mockReturnValue(false), + getVersionPinPath: jest.fn().mockReturnValue('/tmp/cliproxy-version-pin'), + BinaryManager: class {}, })); const mockCheckRemoteProxy = jest.fn(); @@ -30,21 +48,28 @@ jest.mock('../../services/remote-proxy-client', () => ({ jest.mock('../retry-handler', () => ({ isNetworkError: jest.fn().mockReturnValue(false), handleNetworkError: jest.fn(), -})); - -const mockResolveProxyConfig = jest.fn(); -jest.mock('../../proxy/proxy-config-resolver', () => ({ - resolveProxyConfig: mockResolveProxyConfig, -})); - -jest.mock('../../config/config-generator', () => ({ - CLIPROXY_DEFAULT_PORT: 8317, - validatePort: jest.fn((port: number | undefined) => port ?? 8317), + handleTokenExpiration: jest.fn(), + handleQuotaCheck: jest.fn(), + PROVIDER_ERROR_PATTERNS: [], + detectFailedTier: jest.fn().mockReturnValue(null), + isProviderError: jest.fn().mockReturnValue(false), })); // ── Import after mocks ──────────────────────────────────────────────────────── -const { resolveExecutorProxy } = await import('../proxy-resolver'); +const { resolveExecutorProxy, resolveExecutorProxyConfig } = await import('../proxy-resolver'); + +const PROXY_ENV_KEYS = [ + 'CCS_PROXY_HOST', + 'CCS_PROXY_PORT', + 'CCS_PROXY_PROTOCOL', + 'CCS_PROXY_AUTH_TOKEN', + 'CCS_PROXY_TIMEOUT', + 'CCS_PROXY_FALLBACK_ENABLED', + 'CCS_ALLOW_SELF_SIGNED', +] as const; + +let proxyEnvSnapshot: Record = {}; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -76,51 +101,37 @@ function makeContext( }; } -/** Mock resolveProxyConfig to return a local-mode config */ -function mockLocalProxyConfig(remainingArgs: string[] = []): void { - mockResolveProxyConfig.mockReturnValue({ - config: { - mode: 'local', - port: 8317, - protocol: 'http', - fallbackEnabled: false, - autoStartLocal: false, - remoteOnly: false, - forceLocal: true, - }, - remainingArgs, - }); -} - -/** Mock resolveProxyConfig to return a remote-mode config */ -function mockRemoteProxyConfig(remainingArgs: string[] = []): void { - mockResolveProxyConfig.mockReturnValue({ - config: { - mode: 'remote', - host: '192.168.1.100', - port: 8317, - protocol: 'http', - fallbackEnabled: false, - autoStartLocal: false, - remoteOnly: false, - forceLocal: false, - }, - remainingArgs, - }); +async function resolveProxyForTest(args: string[], context = makeContext()) { + const resolvedConfig = resolveExecutorProxyConfig(args, context); + return resolveExecutorProxy(resolvedConfig, context); } // ── Tests ───────────────────────────────────────────────────────────────────── beforeEach(() => { jest.clearAllMocks(); + proxyEnvSnapshot = Object.fromEntries(PROXY_ENV_KEYS.map((key) => [key, process.env[key]])); + for (const key of PROXY_ENV_KEYS) { + delete process.env[key]; + } mockEnsureCLIProxyBinary.mockResolvedValue('/usr/local/bin/cliproxy'); mockGetConfiguredBackend.mockReturnValue('original'); }); +afterEach(() => { + for (const key of PROXY_ENV_KEYS) { + const value = proxyEnvSnapshot[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +}); + describe('resolveExecutorProxy — local mode', () => { it('returns useRemoteProxy=false and correct binary for local mode', async () => { - mockLocalProxyConfig(['--verbose']); - const result = await resolveExecutorProxy(['--verbose'], makeContext()); + const result = await resolveProxyForTest(['--local-proxy', '--verbose']); expect(result.useRemoteProxy).toBe(false); expect(result.localBackend).toBe('original'); @@ -129,16 +140,14 @@ describe('resolveExecutorProxy — local mode', () => { }); it('strips proxy flags and passes remainingArgs through', async () => { - mockLocalProxyConfig(['clean-arg']); - const result = await resolveExecutorProxy(['--local-proxy', 'clean-arg'], makeContext()); + const result = await resolveProxyForTest(['--local-proxy', 'clean-arg']); expect(result.argsWithoutProxy).toEqual(['clean-arg']); expect(result.useRemoteProxy).toBe(false); }); it('does not call checkRemoteProxy in local mode', async () => { - mockLocalProxyConfig(); - await resolveExecutorProxy([], makeContext()); + await resolveProxyForTest(['--local-proxy']); expect(mockCheckRemoteProxy).not.toHaveBeenCalled(); }); @@ -146,19 +155,17 @@ describe('resolveExecutorProxy — local mode', () => { describe('resolveExecutorProxy — remote mode reachable', () => { it('returns useRemoteProxy=true when remote proxy is reachable', async () => { - mockRemoteProxyConfig(); mockCheckRemoteProxy.mockResolvedValue({ reachable: true, latencyMs: 12, error: undefined }); - const result = await resolveExecutorProxy([], makeContext()); + const result = await resolveProxyForTest(['--proxy-host', '192.168.1.100']); expect(result.useRemoteProxy).toBe(true); }); it('skips binary acquisition when remote proxy is reachable', async () => { - mockRemoteProxyConfig(); mockCheckRemoteProxy.mockResolvedValue({ reachable: true, latencyMs: 5, error: undefined }); - const result = await resolveExecutorProxy([], makeContext()); + const result = await resolveProxyForTest(['--proxy-host', '192.168.1.100']); expect(result.binaryPath).toBeUndefined(); expect(mockEnsureCLIProxyBinary).not.toHaveBeenCalled(); @@ -167,65 +174,27 @@ describe('resolveExecutorProxy — remote mode reachable', () => { describe('resolveExecutorProxy — remote mode unreachable', () => { it('throws expected message when remoteOnly=true and remote is unreachable', async () => { - mockResolveProxyConfig.mockReturnValue({ - config: { - mode: 'remote', - host: '192.168.1.100', - port: 8317, - protocol: 'http', - fallbackEnabled: false, - autoStartLocal: false, - remoteOnly: true, - forceLocal: false, - }, - remainingArgs: [], - }); mockCheckRemoteProxy.mockResolvedValue({ reachable: false, error: 'Connection refused' }); - await expect(resolveExecutorProxy([], makeContext())).rejects.toThrow( - 'Remote proxy unreachable and --remote-only specified' - ); + await expect( + resolveProxyForTest(['--proxy-host', '192.168.1.100', '--remote-only']) + ).rejects.toThrow('Remote proxy unreachable and --remote-only specified'); }); it('throws when fallback disabled and remote is unreachable', async () => { - mockResolveProxyConfig.mockReturnValue({ - config: { - mode: 'remote', - host: '192.168.1.100', - port: 8317, - protocol: 'http', - fallbackEnabled: false, - autoStartLocal: false, - remoteOnly: false, - forceLocal: false, - }, - remainingArgs: [], - }); + process.env.CCS_PROXY_FALLBACK_ENABLED = '0'; mockCheckRemoteProxy.mockResolvedValue({ reachable: false, error: 'Timeout' }); - await expect(resolveExecutorProxy([], makeContext())).rejects.toThrow( + await expect(resolveProxyForTest(['--proxy-host', '192.168.1.100'])).rejects.toThrow( 'Remote proxy unreachable and fallback disabled' ); }); it('falls back to local and acquires binary when autoStartLocal=true', async () => { - mockResolveProxyConfig.mockReturnValue({ - config: { - mode: 'remote', - host: '192.168.1.100', - port: 8317, - protocol: 'http', - fallbackEnabled: true, - autoStartLocal: true, - remoteOnly: false, - forceLocal: false, - }, - remainingArgs: [], - }); mockCheckRemoteProxy.mockResolvedValue({ reachable: false, error: 'Timeout' }); mockEnsureCLIProxyBinary.mockResolvedValue('/usr/local/bin/cliproxy'); - const result = await resolveExecutorProxy([], makeContext()); + const result = await resolveProxyForTest(['--proxy-host', '192.168.1.100']); expect(result.useRemoteProxy).toBe(false); expect(result.binaryPath).toBe('/usr/local/bin/cliproxy'); @@ -235,9 +204,7 @@ describe('resolveExecutorProxy — remote mode unreachable', () => { describe('resolveExecutorProxy — proxyConfig propagated in result', () => { it('returns the resolved proxyConfig object', async () => { - mockLocalProxyConfig(); - - const result = await resolveExecutorProxy([], makeContext()); + const result = await resolveProxyForTest(['--local-proxy']); expect(result.proxyConfig).toBeDefined(); expect(result.proxyConfig.mode).toBe('local'); @@ -245,10 +212,9 @@ describe('resolveExecutorProxy — proxyConfig propagated in result', () => { }); it('returns mutated cfg with validated port', async () => { - mockLocalProxyConfig(); const ctx = makeContext(); - const result = await resolveExecutorProxy([], ctx); + const result = await resolveProxyForTest(['--local-proxy'], ctx); // cfg is mutated in place and also returned expect(result.cfg).toBe(ctx.cfg); diff --git a/src/cliproxy/quota/quota-manager.ts b/src/cliproxy/quota/quota-manager.ts index 041e2748..8574b079 100644 --- a/src/cliproxy/quota/quota-manager.ts +++ b/src/cliproxy/quota/quota-manager.ts @@ -483,8 +483,9 @@ export async function reconcileExhaustedRotationAccounts( return []; } - const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = - await import('../accounts/account-safety'); + const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = await import( + '../accounts/account-safety' + ); restoreExpiredQuotaPauses(); const config = loadOrCreateUnifiedConfig(); @@ -592,8 +593,9 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise * Fix image analysis configuration issues */ export async function fixImageAnalysisConfig(): Promise { - const { updateConfig, loadOrCreateUnifiedConfig } = - await import('../../config/config-loader-facade'); + const { updateConfig, loadOrCreateUnifiedConfig } = await import( + '../../config/config-loader-facade' + ); const config = loadOrCreateUnifiedConfig(); let fixed = false; From c630ce878ea4ad22d0bfa07d71dd352392db728a Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:53:07 -0400 Subject: [PATCH 101/137] fix: restrict Codex usage cache permissions --- .../usage/codex-native-usage-collector.ts | 20 +++++++++++++++-- .../codex-native-usage-collector.test.ts | 22 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/web-server/usage/codex-native-usage-collector.ts b/src/web-server/usage/codex-native-usage-collector.ts index 160fcfb7..a0ba3d8e 100644 --- a/src/web-server/usage/codex-native-usage-collector.ts +++ b/src/web-server/usage/codex-native-usage-collector.ts @@ -14,6 +14,8 @@ interface CodexNativeUsageCollectorOptions { } const CODEX_NATIVE_USAGE_CACHE_VERSION = 1; +const SECURE_CACHE_DIR_MODE = 0o700; +const SECURE_CACHE_FILE_MODE = 0o600; interface CachedRolloutFile { path: string; @@ -133,6 +135,14 @@ function isCodexNativeUsageCache(value: unknown): value is CodexNativeUsageCache return Object.values(value.files).every(isCachedRolloutFile); } +function chmodBestEffort(targetPath: string, mode: number): void { + try { + fs.chmodSync(targetPath, mode); + } catch { + // Cache reads and writes remain best-effort on filesystems that do not support chmod. + } +} + function readUsageCache( cacheDir: string, includeCliproxySessions: boolean @@ -153,11 +163,17 @@ function readUsageCache( function writeUsageCache(cacheDir: string, cache: CodexNativeUsageCache): void { try { - fs.mkdirSync(cacheDir, { recursive: true }); + fs.mkdirSync(cacheDir, { recursive: true, mode: SECURE_CACHE_DIR_MODE }); + chmodBestEffort(cacheDir, SECURE_CACHE_DIR_MODE); const cachePath = getCacheFilePath(cacheDir, cache.includeCliproxySessions); const tempPath = `${cachePath}.${process.pid}.tmp`; - fs.writeFileSync(tempPath, JSON.stringify(cache), 'utf8'); + fs.writeFileSync(tempPath, JSON.stringify(cache), { + encoding: 'utf8', + mode: SECURE_CACHE_FILE_MODE, + }); + chmodBestEffort(tempPath, SECURE_CACHE_FILE_MODE); fs.renameSync(tempPath, cachePath); + chmodBestEffort(cachePath, SECURE_CACHE_FILE_MODE); } catch { // Best-effort only. } diff --git a/tests/unit/web-server/codex-native-usage-collector.test.ts b/tests/unit/web-server/codex-native-usage-collector.test.ts index 50d68991..0111f560 100644 --- a/tests/unit/web-server/codex-native-usage-collector.test.ts +++ b/tests/unit/web-server/codex-native-usage-collector.test.ts @@ -311,6 +311,28 @@ describe('codex native usage collector', () => { expect(entries).toHaveLength(2); }); + it('writes native usage caches with owner-only permissions', async () => { + if (process.platform === 'win32') return; + + writeCodexRollout(tempRoot); + const cacheDir = getCacheDir(); + const previousUmask = process.umask(0o022); + + try { + await scanCodexNativeUsageEntries({ + env: { CODEX_HOME: tempRoot }, + homeDir: tempRoot, + cacheDir, + }); + } finally { + process.umask(previousUmask); + } + + const cachePath = path.join(cacheDir, 'codex-native-usage-v1.json'); + expect(fs.statSync(cacheDir).mode & 0o777).toBe(0o700); + expect(fs.statSync(cachePath).mode & 0o777).toBe(0o600); + }); + it('keeps default and include-cliproxy cache entries separate', async () => { writeCodexRollout(tempRoot, { modelProvider: 'cliproxy' }); const cacheDir = getCacheDir(); From 6bba193cdd742a56f3d495a3de5d478df636f444 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:53:55 -0400 Subject: [PATCH 102/137] fix: harden cliproxy usage cache --- src/web-server/usage/cliproxy-usage-syncer.ts | 29 +++++++++++++------ .../usage/cliproxy-usage-transformer.ts | 22 +++++++++----- .../web-server/cliproxy-usage-syncer.test.ts | 15 ++++++++++ .../cliproxy-usage-transformer.test.ts | 15 ++++++++++ 4 files changed, 65 insertions(+), 16 deletions(-) diff --git a/src/web-server/usage/cliproxy-usage-syncer.ts b/src/web-server/usage/cliproxy-usage-syncer.ts index 83d15a69..887f2a7a 100644 --- a/src/web-server/usage/cliproxy-usage-syncer.ts +++ b/src/web-server/usage/cliproxy-usage-syncer.ts @@ -50,6 +50,8 @@ const HISTORY_RETENTION_DAYS = Math.max( ); const HISTORY_RETENTION_MS = HISTORY_RETENTION_DAYS * 24 * 60 * 60 * 1000; const MAX_WRITE_ATTEMPTS = 3; +const PRIVATE_DIR_MODE = 0o700; +const PRIVATE_FILE_MODE = 0o600; /** Sync interval in ms, configurable via CCS_CLIPROXY_SYNC_INTERVAL env var (default: 5 min) */ const SYNC_INTERVAL_MS = Math.max( @@ -68,11 +70,19 @@ function getLatestSnapshotPath(): string { return path.join(getCliproxyCacheDir(), 'latest.json'); } +function ensurePrivateDirectory(dir: string): void { + fs.mkdirSync(dir, { recursive: true, mode: PRIVATE_DIR_MODE }); + fs.chmodSync(dir, PRIVATE_DIR_MODE); +} + function ensureCliproxyCacheDir(): void { - const dir = getCliproxyCacheDir(); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } + const ccsDir = getCcsDir(); + const cacheDir = path.join(ccsDir, 'cache'); + const cliproxyCacheDir = path.join(cacheDir, 'cliproxy-usage'); + + ensurePrivateDirectory(ccsDir); + ensurePrivateDirectory(cacheDir); + ensurePrivateDirectory(cliproxyCacheDir); } function getSnapshotTimestamp(): number { @@ -112,8 +122,6 @@ function buildLegacyHistoryDetails( details.push({ model: breakdown.modelName, timestamp: buildHourlyTimestamp(hour.hour), - source: hour.source, - authIndex: 'legacy-hourly', inputTokens: breakdown.inputTokens, outputTokens: breakdown.outputTokens, cacheReadTokens: breakdown.cacheReadTokens, @@ -135,8 +143,6 @@ function buildLegacyHistoryDetails( details.push({ model: breakdown.modelName, timestamp: buildDailyTimestamp(day.date), - source: day.source, - authIndex: 'legacy-daily', inputTokens: breakdown.inputTokens, outputTokens: breakdown.outputTokens, cacheReadTokens: breakdown.cacheReadTokens, @@ -255,7 +261,11 @@ async function writeSnapshotWithMerge( const snapshot = buildSnapshot(baseSnapshot?.details ?? [], incomingDetails); const tempFile = `${snapshotPath}.${process.pid}.${snapshot.timestamp}.tmp`; - fs.writeFileSync(tempFile, JSON.stringify(snapshot), 'utf-8'); + fs.writeFileSync(tempFile, JSON.stringify(snapshot), { + encoding: 'utf-8', + mode: PRIVATE_FILE_MODE, + }); + fs.chmodSync(tempFile, PRIVATE_FILE_MODE); const latestSnapshot = readSnapshot(false); const latestTimestamp = latestSnapshot?.timestamp ?? -Infinity; @@ -265,6 +275,7 @@ async function writeSnapshotWithMerge( } fs.renameSync(tempFile, snapshotPath); + fs.chmodSync(snapshotPath, PRIVATE_FILE_MODE); console.log(ok('CLIProxy usage snapshot updated')); return; } diff --git a/src/web-server/usage/cliproxy-usage-transformer.ts b/src/web-server/usage/cliproxy-usage-transformer.ts index 5f96d632..68675274 100644 --- a/src/web-server/usage/cliproxy-usage-transformer.ts +++ b/src/web-server/usage/cliproxy-usage-transformer.ts @@ -22,8 +22,6 @@ export interface CliproxyUsageHistoryDetail { model: string; provider?: string; timestamp: string; - source: string; - authIndex: string; inputTokens: number; outputTokens: number; cacheReadTokens: number; @@ -68,8 +66,6 @@ function createHistoryDetail( model, provider: pricingProvider, timestamp: detail.timestamp, - source: detail.source, - authIndex: String(detail.auth_index), inputTokens: detail.tokens?.input_tokens ?? 0, outputTokens: detail.tokens?.output_tokens ?? 0, cacheReadTokens: detail.tokens?.cached_tokens ?? 0, @@ -128,13 +124,25 @@ export function extractCliproxyUsageHistoryDetails( return results; } +function sanitizeHistoryDetail(detail: CliproxyUsageHistoryDetail): CliproxyUsageHistoryDetail { + return { + model: detail.model, + ...(detail.provider && { provider: detail.provider }), + timestamp: detail.timestamp, + inputTokens: detail.inputTokens, + outputTokens: detail.outputTokens, + cacheReadTokens: detail.cacheReadTokens, + requestCount: detail.requestCount, + cost: detail.cost, + failed: detail.failed, + }; +} + function createHistorySignature(detail: CliproxyUsageHistoryDetail): string { return [ detail.model, detail.provider ?? '', detail.timestamp, - detail.source, - detail.authIndex, detail.inputTokens, detail.outputTokens, detail.cacheReadTokens, @@ -182,7 +190,7 @@ export function mergeCliproxyUsageHistoryDetails( const merged: CliproxyUsageHistoryDetail[] = []; for (const { detail, count } of existingCounts.values()) { for (let index = 0; index < count; index++) { - merged.push({ ...detail }); + merged.push(sanitizeHistoryDetail(detail)); } } diff --git a/tests/unit/web-server/cliproxy-usage-syncer.test.ts b/tests/unit/web-server/cliproxy-usage-syncer.test.ts index db9dffa1..2daf3666 100644 --- a/tests/unit/web-server/cliproxy-usage-syncer.test.ts +++ b/tests/unit/web-server/cliproxy-usage-syncer.test.ts @@ -109,6 +109,21 @@ describe('cliproxy usage syncer', () => { const snapshotPath = path.join(ccsDir, 'cache', 'cliproxy-usage', 'latest.json'); expect(fs.existsSync(snapshotPath)).toBe(true); + const snapshot = JSON.parse(fs.readFileSync(snapshotPath, 'utf-8')) as { + details: Array>; + }; + expect(snapshot.details[0]).not.toHaveProperty('source'); + expect(snapshot.details[0]).not.toHaveProperty('authIndex'); + + if (process.platform !== 'win32') { + const cacheDir = path.join(ccsDir, 'cache'); + const cliproxyCacheDir = path.dirname(snapshotPath); + expect(fs.statSync(ccsDir).mode & 0o777).toBe(0o700); + expect(fs.statSync(cacheDir).mode & 0o777).toBe(0o700); + expect(fs.statSync(cliproxyCacheDir).mode & 0o777).toBe(0o700); + expect(fs.statSync(snapshotPath).mode & 0o777).toBe(0o600); + } + const cached = await runWithScopedConfigDir(ccsDir, async () => { return await loadCachedCliproxyData(); }); diff --git a/tests/unit/web-server/cliproxy-usage-transformer.test.ts b/tests/unit/web-server/cliproxy-usage-transformer.test.ts index 4e590aa6..83c78f98 100644 --- a/tests/unit/web-server/cliproxy-usage-transformer.test.ts +++ b/tests/unit/web-server/cliproxy-usage-transformer.test.ts @@ -109,6 +109,8 @@ describe('cliproxy usage transformer', () => { const flat = extractCliproxyUsageHistoryDetails(sampleResponse); expect(flat).toHaveLength(4); expect(flat[0].provider).toBe('google'); + expect(flat[0]).not.toHaveProperty('source'); + expect(flat[0]).not.toHaveProperty('authIndex'); expect( flat.some( (entry) => @@ -134,6 +136,19 @@ describe('cliproxy usage transformer', () => { expect(merged).toHaveLength(details.length); }); + it('strips legacy account identifiers when merging persisted history', () => { + const details = extractCliproxyUsageHistoryDetails(sampleResponse); + const legacyDetail = { + ...details[0], + source: 'user@example.com', + authIndex: 'auth-file-7', + }; + const merged = mergeCliproxyUsageHistoryDetails([legacyDetail], []); + + expect(merged[0]).not.toHaveProperty('source'); + expect(merged[0]).not.toHaveProperty('authIndex'); + }); + it('preserves legitimate duplicate requests when the incoming batch has more occurrences', () => { const details = extractCliproxyUsageHistoryDetails(sampleResponse); const repeated = [details[0], { ...details[0] }]; From 9a7b26eab745f5099d57685c9a14845a4adb985e Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:53:57 -0400 Subject: [PATCH 103/137] fix: restrict local runtime readiness probes --- src/web-server/routes/profile-routes.ts | 10 ++++- ...ile-routes-local-runtime-readiness.test.ts | 43 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/web-server/routes/profile-routes.ts b/src/web-server/routes/profile-routes.ts index 9b812760..a5ea70ac 100644 --- a/src/web-server/routes/profile-routes.ts +++ b/src/web-server/routes/profile-routes.ts @@ -6,6 +6,7 @@ */ import { Router, Request, Response } from 'express'; +import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names'; import { createApiProfile, @@ -30,6 +31,9 @@ import { isAnthropicDirectProfile, updateSettingsFile, parseTarget } from './rou const router = Router(); +const LOCAL_RUNTIME_READINESS_LOCAL_ACCESS_ERROR = + 'Local runtime readiness requires localhost access when dashboard auth is disabled.'; + function isDenylistError(message: string | undefined): boolean { return typeof message === 'string' && message.toLowerCase().includes('denylist'); } @@ -87,7 +91,11 @@ router.get('/cliproxy-bridge/providers', (_req: Request, res: Response): void => } }); -router.get('/local-runtime-readiness', async (_req: Request, res: Response): Promise => { +router.get('/local-runtime-readiness', async (req: Request, res: Response): Promise => { + if (!requireLocalAccessWhenAuthDisabled(req, res, LOCAL_RUNTIME_READINESS_LOCAL_ACCESS_ERROR)) { + return; + } + try { res.json({ runtimes: await getLocalRuntimeReadiness() }); } catch (error) { diff --git a/tests/unit/web-server/profile-routes-local-runtime-readiness.test.ts b/tests/unit/web-server/profile-routes-local-runtime-readiness.test.ts index 46c89b25..31b2488d 100644 --- a/tests/unit/web-server/profile-routes-local-runtime-readiness.test.ts +++ b/tests/unit/web-server/profile-routes-local-runtime-readiness.test.ts @@ -6,11 +6,20 @@ import profileRoutes from '../../../src/web-server/routes/profile-routes'; describe('profile-routes local runtime readiness', () => { let server: Server; let baseUrl = ''; + let forcedRemoteAddress = '127.0.0.1'; + let originalDashboardAuthEnabled: string | undefined; const originalFetch = globalThis.fetch; beforeAll(async () => { const app = express(); app.use(express.json()); + app.use((req, _res, next) => { + Object.defineProperty(req.socket, 'remoteAddress', { + value: forcedRemoteAddress, + configurable: true, + }); + next(); + }); app.use('/api/profiles', profileRoutes); await new Promise((resolve, reject) => { @@ -36,6 +45,10 @@ describe('profile-routes local runtime readiness', () => { }); beforeEach(() => { + originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED; + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false'; + forcedRemoteAddress = '127.0.0.1'; + globalThis.fetch = (async (input: RequestInfo | URL) => { const url = String(input); @@ -73,6 +86,12 @@ describe('profile-routes local runtime readiness', () => { afterEach(() => { globalThis.fetch = originalFetch; + + if (originalDashboardAuthEnabled !== undefined) { + process.env.CCS_DASHBOARD_AUTH_ENABLED = originalDashboardAuthEnabled; + } else { + delete process.env.CCS_DASHBOARD_AUTH_ENABLED; + } }); it('reports local runtimes as ready when their endpoints respond with models', async () => { @@ -103,6 +122,30 @@ describe('profile-routes local runtime readiness', () => { ); }); + it('blocks remote readiness probes when dashboard auth is disabled', async () => { + let probeCount = 0; + forcedRemoteAddress = '10.10.0.24'; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith(baseUrl)) { + return originalFetch(input); + } + probeCount += 1; + return new Response(JSON.stringify({ models: [{ name: 'gemma4:e4b' }] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + + const response = await fetch(`${baseUrl}/api/profiles/local-runtime-readiness`); + + expect(response.status).toBe(403); + expect(await response.json()).toEqual({ + error: 'Local runtime readiness requires localhost access when dashboard auth is disabled.', + }); + expect(probeCount).toBe(0); + }); + it('reports setup guidance when local endpoints are unavailable', async () => { globalThis.fetch = (async (input: RequestInfo | URL) => { const url = String(input); From 4f2dd70b8c77d878c01fb3da9e0fae305cff2cf3 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:54:30 -0400 Subject: [PATCH 104/137] fix: avoid relative completion backend execution --- scripts/completion/ccs.bash | 12 ----------- scripts/completion/ccs.fish | 11 ---------- scripts/completion/ccs.ps1 | 10 --------- scripts/completion/ccs.zsh | 13 ------------ .../commands/shell-completion-command.test.ts | 21 +++++++++++++++++++ 5 files changed, 21 insertions(+), 46 deletions(-) diff --git a/scripts/completion/ccs.bash b/scripts/completion/ccs.bash index f7d19e10..a1b27984 100644 --- a/scripts/completion/ccs.bash +++ b/scripts/completion/ccs.bash @@ -22,18 +22,6 @@ __ccs_completion_run() { local current="$1" shift || true - local script_dir repo_root repo_cli - script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - repo_root="$(cd "${script_dir}/../.." && pwd)" - repo_cli="${repo_root}/dist/ccs.js" - if [[ ! -f "${repo_cli}" ]]; then - repo_cli="${repo_root}/bin/ccs.js" - fi - if [[ -f "${repo_cli}" ]]; then - node "${repo_cli}" __complete --shell bash --current "${current}" -- "$@" 2>/dev/null - return 0 - fi - if command -v ccs >/dev/null 2>&1; then ccs __complete --shell bash --current "${current}" -- "$@" 2>/dev/null fi diff --git a/scripts/completion/ccs.fish b/scripts/completion/ccs.fish index 96d801bf..c06111e3 100644 --- a/scripts/completion/ccs.fish +++ b/scripts/completion/ccs.fish @@ -11,17 +11,6 @@ function __fish_ccs_complete set -e tokens_before_current[-1] end - set -l script_file (status filename) - set -l repo_root (realpath (dirname $script_file)/../.. 2>/dev/null) - set -l repo_cli "$repo_root/dist/ccs.js" - if not test -f "$repo_cli" - set repo_cli "$repo_root/bin/ccs.js" - end - if test -f "$repo_cli" - node "$repo_cli" __complete --shell fish --current "$current" -- $tokens_before_current 2>/dev/null - return - end - if command -sq ccs ccs __complete --shell fish --current "$current" -- $tokens_before_current 2>/dev/null end diff --git a/scripts/completion/ccs.ps1 b/scripts/completion/ccs.ps1 index 0ec00e94..04051ead 100644 --- a/scripts/completion/ccs.ps1 +++ b/scripts/completion/ccs.ps1 @@ -6,16 +6,6 @@ function Invoke-CcsCompletionBackend { [string[]]$TokensBeforeCurrent ) - $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path - $repoCli = Join-Path $repoRoot 'dist\ccs.js' - if (-not (Test-Path $repoCli)) { - $repoCli = Join-Path $repoRoot 'bin\ccs.js' - } - if (Test-Path $repoCli) { - & node $repoCli __complete --shell powershell --current $CurrentWord -- @TokensBeforeCurrent 2>$null - return - } - if (Get-Command ccs -ErrorAction SilentlyContinue) { & ccs __complete --shell powershell --current $CurrentWord -- @TokensBeforeCurrent 2>$null } diff --git a/scripts/completion/ccs.zsh b/scripts/completion/ccs.zsh index 97e59220..281da9e3 100644 --- a/scripts/completion/ccs.zsh +++ b/scripts/completion/ccs.zsh @@ -22,19 +22,6 @@ __ccs_completion_run() { local current="$1" shift || true - local script_path script_dir repo_root repo_cli - script_path="${(%):-%N}" - script_dir="${script_path:A:h}" - repo_root="${script_dir:h:h}" - repo_cli="${repo_root}/dist/ccs.js" - if [[ ! -f "${repo_cli}" ]]; then - repo_cli="${repo_root}/bin/ccs.js" - fi - if [[ -f "${repo_cli}" ]]; then - node "${repo_cli}" __complete --shell zsh --current "${current}" -- "$@" 2>/dev/null - return 0 - fi - if (( $+commands[ccs] )); then ccs __complete --shell zsh --current "${current}" -- "$@" 2>/dev/null fi diff --git a/tests/unit/commands/shell-completion-command.test.ts b/tests/unit/commands/shell-completion-command.test.ts index c8a285d9..86d2efd4 100644 --- a/tests/unit/commands/shell-completion-command.test.ts +++ b/tests/unit/commands/shell-completion-command.test.ts @@ -143,6 +143,27 @@ describe('shell-completion command', () => { ); }); + it('completion adapters only delegate to the ccs command on PATH', () => { + const completionScripts = [ + '../../../scripts/completion/ccs.bash', + '../../../scripts/completion/ccs.zsh', + '../../../scripts/completion/ccs.fish', + '../../../scripts/completion/ccs.ps1', + ]; + + for (const scriptPath of completionScripts) { + const script = readFileSync(join(import.meta.dir, scriptPath), 'utf8'); + + expect(script).not.toContain('../..'); + expect(script).not.toContain('repo_root'); + expect(script).not.toContain('repoRoot'); + expect(script).not.toContain('repo_cli'); + expect(script).not.toContain('repoCli'); + expect(script).not.toContain('node '); + expect(script).not.toContain('& node'); + } + }); + it('fish completion strips a duplicated partial token before delegating to __complete', () => { const fishScript = readFileSync( join(import.meta.dir, '../../../scripts/completion/ccs.fish'), From 5776e9c3b1ac584eebcd01c6332196acc6ff7aa4 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:54:35 -0400 Subject: [PATCH 105/137] fix: stop ccsxp model alias parsing at option terminator --- src/targets/codex-adapter.ts | 4 +++ .../targets/codex-runtime-integration.test.ts | 30 ++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/targets/codex-adapter.ts b/src/targets/codex-adapter.ts index ba22c63b..2798968c 100644 --- a/src/targets/codex-adapter.ts +++ b/src/targets/codex-adapter.ts @@ -143,6 +143,10 @@ function normalizeCcsxpCodexModelFlagAliases(args: string[]): { for (let index = 0; index < normalizedArgs.length; index += 1) { const arg = normalizedArgs[index]; + if (arg === '--') { + break; + } + if (arg === '-m' || arg === '--model') { const nextValue = normalizedArgs[index + 1]; if (typeof nextValue === 'string') { diff --git a/tests/unit/targets/codex-runtime-integration.test.ts b/tests/unit/targets/codex-runtime-integration.test.ts index 4018dd45..5f043903 100644 --- a/tests/unit/targets/codex-runtime-integration.test.ts +++ b/tests/unit/targets/codex-runtime-integration.test.ts @@ -848,6 +848,32 @@ process.exit(0); ]); }); + it('preserves ccsxp positional model-like arguments after the option terminator', () => { + if (process.platform === 'win32') return; + + const result = runCcsxpAlias(['--', '--', '-m', 'gpt-5.5-high-fast', 'prompt text'], { + ...process.env, + CI: '1', + NO_COLOR: '1', + HOME: tmpHome, + CCS_HOME: tmpHome, + CCS_CODEX_PATH: fakeCodexPath, + CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath, + CCS_TEST_CODEX_ENV_OUT: codexEnvLogPath, + }); + + expect(result.status).toBe(0); + const codexCalls = readLoggedCodexCalls(codexArgsLogPath); + expect(codexCalls.at(-1)).toEqual([ + '--config', + 'model_provider="cliproxy"', + '--', + '-m', + 'gpt-5.5-high-fast', + 'prompt text', + ]); + }); + it('normalizes ccsxp native low Codex tuning aliases in config.toml', () => { if (process.platform === 'win32') return; @@ -1137,7 +1163,9 @@ supports_websockets = false }); expect(result.status).toBe(0); - expect(result.stderr).not.toContain('Codex CLI does not support Claude account-based profiles.'); + expect(result.stderr).not.toContain( + 'Codex CLI does not support Claude account-based profiles.' + ); expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([['fix failing tests']]); expect(readLoggedCodexEnv(codexEnvLogPath)).toEqual([ { From 901bad2ec7faab8d5725a65c96a1de8cb9945b3d Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:54:43 -0400 Subject: [PATCH 106/137] fix: validate usage profile query type --- src/web-server/usage/profile-filter.ts | 9 +++++-- .../usage-handlers-semantics.test.ts | 24 ++++++++++++++----- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/web-server/usage/profile-filter.ts b/src/web-server/usage/profile-filter.ts index a319daa1..c3d0f2fd 100644 --- a/src/web-server/usage/profile-filter.ts +++ b/src/web-server/usage/profile-filter.ts @@ -9,8 +9,13 @@ export interface ProfileScopedUsageData { const PROFILE_NAME_REGEX = /^[A-Za-z0-9._-]+$/; -export function normalizeProfileQuery(profile?: string): string | undefined { - const value = profile?.trim(); +export function normalizeProfileQuery(profile?: unknown): string | undefined { + if (profile === undefined) return undefined; + if (typeof profile !== 'string') { + throw new Error('Invalid profile filter'); + } + + const value = profile.trim(); if (!value || value === 'all') return undefined; if (!PROFILE_NAME_REGEX.test(value)) { throw new Error('Invalid profile filter'); diff --git a/tests/unit/web-server/usage-handlers-semantics.test.ts b/tests/unit/web-server/usage-handlers-semantics.test.ts index e91aec59..dfb1de23 100644 --- a/tests/unit/web-server/usage-handlers-semantics.test.ts +++ b/tests/unit/web-server/usage-handlers-semantics.test.ts @@ -83,11 +83,7 @@ function writeAssistantEntriesToDir(baseClaudeDir: string, entries: AssistantFix }, }); - fs.writeFileSync( - path.join(projectDir, `${entry.sessionId}.jsonl`), - `${line}\n`, - 'utf-8' - ); + fs.writeFileSync(path.join(projectDir, `${entry.sessionId}.jsonl`), `${line}\n`, 'utf-8'); } } @@ -222,7 +218,10 @@ describe('usage handlers semantics', () => { res as never ); - const payload = res.payload as { success: boolean; data: Array<{ hour: string; requests: number }> }; + const payload = res.payload as { + success: boolean; + data: Array<{ hour: string; requests: number }>; + }; const targetHour = payload.data.find((row) => row.hour === '2026-03-02 10:00'); expect(targetHour?.requests).toBe(3); @@ -415,6 +414,19 @@ describe('usage handlers semantics', () => { }); }); + it('rejects non-string profile filters as validation errors', async () => { + for (const profile of [['work', 'default'], { name: 'work' }]) { + const res = createMockResponse(); + await handlers.handleSummary({ query: { profile } } as never, res as never); + + expect(res.statusCode).toBe(400); + expect(res.payload).toMatchObject({ + success: false, + error: 'Invalid profile filter', + }); + } + }); + it('rejects reversed date ranges before computing summary totals', async () => { const res = createMockResponse(); From 5da63347367564104b2cc514c94d1f1fc1519996 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:54:45 -0400 Subject: [PATCH 107/137] fix(persist): bound Codex translator receipt scan --- src/shared/stale-codex-translator-settings.ts | 72 +++++++++++++++---- .../commands/persist-command-handler.test.ts | 37 ++++++++++ .../stale-codex-translator-settings.test.ts | 10 +++ 3 files changed, 104 insertions(+), 15 deletions(-) diff --git a/src/shared/stale-codex-translator-settings.ts b/src/shared/stale-codex-translator-settings.ts index 81156690..e0446ffd 100644 --- a/src/shared/stale-codex-translator-settings.ts +++ b/src/shared/stale-codex-translator-settings.ts @@ -1,5 +1,8 @@ export const CODEX_TRANSLATOR_URL_MARKER = '/api/provider/codex'; +const MAX_CODEX_TRANSLATOR_SCAN_DEPTH = 100; +const MAX_CODEX_TRANSLATOR_SCAN_NODES = 10000; + function formatSettingsPathSegment(basePath: string, segment: string | number): string { if (typeof segment === 'number') { return `${basePath}[${segment}]`; @@ -13,23 +16,62 @@ function formatSettingsPathSegment(basePath: string, segment: string | number): } export function findCodexTranslatorUrlPaths(value: unknown, path = ''): string[] { - if (typeof value === 'string') { - return value.includes(CODEX_TRANSLATOR_URL_MARKER) ? [path || '(root)'] : []; + const matches: string[] = []; + const stack: Array<{ value: unknown; path: string; depth: number }> = [{ value, path, depth: 0 }]; + const seen = new WeakSet(); + let visitedNodes = 0; + + while (stack.length > 0 && visitedNodes < MAX_CODEX_TRANSLATOR_SCAN_NODES) { + const item = stack.pop(); + if (!item) { + break; + } + + visitedNodes += 1; + + if (typeof item.value === 'string') { + if (item.value.includes(CODEX_TRANSLATOR_URL_MARKER)) { + matches.push(item.path || '(root)'); + } + continue; + } + + if (typeof item.value !== 'object' || item.value === null) { + continue; + } + + if (seen.has(item.value)) { + continue; + } + seen.add(item.value); + + if (item.depth >= MAX_CODEX_TRANSLATOR_SCAN_DEPTH) { + continue; + } + + if (Array.isArray(item.value)) { + for (let index = item.value.length - 1; index >= 0; index -= 1) { + stack.push({ + value: item.value[index], + path: formatSettingsPathSegment(item.path, index), + depth: item.depth + 1, + }); + } + continue; + } + + const entries = Object.entries(item.value); + for (let index = entries.length - 1; index >= 0; index -= 1) { + const [key, child] = entries[index]; + stack.push({ + value: child, + path: formatSettingsPathSegment(item.path, key), + depth: item.depth + 1, + }); + } } - if (Array.isArray(value)) { - return value.flatMap((item, index) => - findCodexTranslatorUrlPaths(item, formatSettingsPathSegment(path, index)) - ); - } - - if (typeof value === 'object' && value !== null) { - return Object.entries(value).flatMap(([key, item]) => - findCodexTranslatorUrlPaths(item, formatSettingsPathSegment(path, key)) - ); - } - - return []; + return matches; } export function formatSettingsPathList(paths: string[]): string { diff --git a/tests/unit/commands/persist-command-handler.test.ts b/tests/unit/commands/persist-command-handler.test.ts index f80ad8c5..5179f505 100644 --- a/tests/unit/commands/persist-command-handler.test.ts +++ b/tests/unit/commands/persist-command-handler.test.ts @@ -415,6 +415,43 @@ describe('persist command Claude extension parity', () => { expect(renderedLogs).toContain('Native Codex target: ccsxp or ccs codex --target codex'); }); + it('does not fail after writing settings when a cleared env value is deeply nested', async () => { + await writeUnifiedConfig(); + + const settingsPath = path.join(tempRoot, '.claude', 'settings.json'); + await fs.promises.mkdir(path.dirname(settingsPath), { recursive: true }); + const deepValue = `${'{"nested":'.repeat(20000)}"leaf"${'}'.repeat(20000)}`; + await fs.promises.writeFile( + settingsPath, + `{"env":{"KEEP_ME":"still-here","ANTHROPIC_AUTH_TOKEN":${deepValue}}}\n`, + 'utf8' + ); + + const originalConsoleLog = console.log; + const capturedLogs: string[] = []; + console.log = (...args: unknown[]) => { + capturedLogs.push(args.map((arg) => String(arg)).join(' ')); + }; + + try { + await withScopedHome(() => handlePersistCommand(['default', '--yes'])); + } finally { + console.log = originalConsoleLog; + } + + const persisted = JSON.parse(await fs.promises.readFile(settingsPath, 'utf8')) as { + env: Record; + }; + + expect(persisted.env.KEEP_ME).toBe('still-here'); + expect(persisted.env.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + const renderedLogs = capturedLogs.join('\n'); + expect(renderedLogs).toContain("Profile 'default' written to"); + expect(renderedLogs).toContain('Config Receipt'); + expect(renderedLogs).toContain('Codex translator URL: not found'); + expect(renderedLogs).not.toContain('Failed to write settings'); + }); + it('warns in the persist receipt when a Codex translator URL remains in settings', async () => { await writeUnifiedConfig(); diff --git a/tests/unit/shared/stale-codex-translator-settings.test.ts b/tests/unit/shared/stale-codex-translator-settings.test.ts index be0cfd89..1873fb3d 100644 --- a/tests/unit/shared/stale-codex-translator-settings.test.ts +++ b/tests/unit/shared/stale-codex-translator-settings.test.ts @@ -31,4 +31,14 @@ describe('stale Codex translator settings scanner', () => { '["custom-key"][0]', ]); }); + + it('does not overflow the stack on deeply nested local settings values', () => { + let settings: Record = { value: 'leaf' }; + for (let depth = 0; depth < 20000; depth += 1) { + settings = { nested: settings }; + } + + expect(() => findCodexTranslatorUrlPaths(settings)).not.toThrow(); + expect(findCodexTranslatorUrlPaths(settings)).toEqual([]); + }); }); From 88421026c64b0dc86c183f6d71b38ec9e5d89920 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:55:00 -0400 Subject: [PATCH 108/137] fix: correct claude image analysis default model --- src/config/schemas/proxy-server.ts | 2 +- .../utils/hooks/image-analysis-backend-resolver.test.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/config/schemas/proxy-server.ts b/src/config/schemas/proxy-server.ts index 745ebab3..93fba4cf 100644 --- a/src/config/schemas/proxy-server.ts +++ b/src/config/schemas/proxy-server.ts @@ -183,7 +183,7 @@ export const DEFAULT_IMAGE_ANALYSIS_CONFIG: ImageAnalysisConfig = { codex: 'gpt-5.1-codex-mini', kiro: 'kiro-claude-haiku-4-5', ghcp: 'claude-haiku-4.5', - claude: 'claude-haiku-4.5-20251001', + claude: 'claude-haiku-4-5-20251001', qwen: 'vision-model', iflow: 'qwen3-vl-plus', kimi: 'vision-model', diff --git a/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts b/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts index 246a0d3f..5957e83c 100644 --- a/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts +++ b/tests/unit/utils/hooks/image-analysis-backend-resolver.test.ts @@ -3,12 +3,20 @@ import { DEFAULT_IMAGE_ANALYSIS_CONFIG, type ImageAnalysisConfig, } from '../../../../src/config/unified-config-types'; +import { findModel } from '../../../../src/cliproxy/model-catalog'; import { canonicalizeImageAnalysisConfig, resolveImageAnalysisStatus, } from '../../../../src/utils/hooks/image-analysis-backend-resolver'; describe('image-analysis-backend-resolver', () => { + it('uses a catalog-backed Claude image analysis default model', () => { + const defaultModel = DEFAULT_IMAGE_ANALYSIS_CONFIG.provider_models.claude; + + expect(defaultModel).toBe('claude-haiku-4-5-20251001'); + expect(findModel('claude', defaultModel)?.id).toBe(defaultModel); + }); + it('canonicalizes provider aliases in config', () => { const config = canonicalizeImageAnalysisConfig({ enabled: true, From 062e77e55f05b821ff8c6c2b0380a57b5d8594f2 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:56:32 -0400 Subject: [PATCH 109/137] fix(analytics): price Claude Opus 4.7 thinking --- src/web-server/model-pricing.ts | 6 ++++++ tests/unit/model-pricing.test.ts | 22 ++++++++++------------ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index 6d7a8bfa..a8f42fe0 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -243,6 +243,12 @@ const PRICING_REGISTRY: Record = { cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, }, + 'claude-opus-4-7-thinking': { + inputPerMillion: 5.0, + outputPerMillion: 25.0, + cacheCreationPerMillion: 6.25, + cacheReadPerMillion: 0.5, + }, // --------------------------------------------------------------------------- // OpenAI Models - Source: better-ccusage diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index 9e6eca84..a6951211 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -256,14 +256,14 @@ describe('model-pricing', () => { expect(cost).toBe(36.75); // 5 + 25 + 6.25 + 0.5 }); - it('should calculate Claude Opus 4.7 cache cost consistently across repeat lookups', () => { + it('should calculate Claude Opus 4.7 thinking cost including cache token rates', () => { const usage: TokenUsage = { inputTokens: 1_000_000, outputTokens: 1_000_000, cacheCreationTokens: 1_000_000, cacheReadTokens: 1_000_000, }; - const cost = calculateCost(usage, 'claude-opus-4-7'); + const cost = calculateCost(usage, 'claude-opus-4-7-thinking'); expect(cost).toBe(36.75); // 5 + 25 + 6.25 + 0.5 }); }); @@ -469,17 +469,15 @@ describe('model-pricing', () => { }); it('gracefully ignores malformed cached model entries', () => { - setCachedModelsDevRegistry( - { - openai: { - id: 'openai', - models: { - 'null-entry': null, - 'gpt-5.5': { id: 'gpt-5.5', cost: { input: 5, output: 30 } }, - }, + setCachedModelsDevRegistry({ + openai: { + id: 'openai', + models: { + 'null-entry': null, + 'gpt-5.5': { id: 'gpt-5.5', cost: { input: 5, output: 30 } }, }, - } as unknown as Parameters[0] - ); + }, + } as unknown as Parameters[0]); expect(() => getModelPricing('openai/gpt-5.5')).not.toThrow(); expect(getModelPricing('openai/gpt-5.5').inputPerMillion).toBe(5); From 6ecca9b16fbd77cd3fe130cdd131fa90af68ae32 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:56:32 -0400 Subject: [PATCH 110/137] fix(cliproxy): preserve manual quota pauses --- .../account-safety-quota-exhaustion.test.ts | 60 +++++++++++++++++++ src/cliproxy/accounts/registry.ts | 4 ++ src/commands/cliproxy/quota-subcommand.ts | 7 ++- 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/cliproxy/accounts/__tests__/account-safety-quota-exhaustion.test.ts b/src/cliproxy/accounts/__tests__/account-safety-quota-exhaustion.test.ts index 32b59c5c..ebac4851 100644 --- a/src/cliproxy/accounts/__tests__/account-safety-quota-exhaustion.test.ts +++ b/src/cliproxy/accounts/__tests__/account-safety-quota-exhaustion.test.ts @@ -21,6 +21,7 @@ import { restoreExpiredQuotaPauses, } from '../../accounts/account-safety'; import { sanitizeEmail } from '../../auth/auth-utils'; +import { pauseAccount } from '../registry'; // Setup test isolation let tmpDir: string; @@ -1228,6 +1229,65 @@ describe('Quota Exhaustion Handlers', () => { expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(false); }); + it('does not auto-resume when a user manually re-pauses a quota-paused account', async () => { + writeRegistry({ + agy: { + default: 'cooldown@gmail.com', + accounts: { + 'cooldown@gmail.com': { + email: 'cooldown@gmail.com', + tokenFile: 'agy-cooldown.json', + }, + }, + }, + }); + writeAuthToken('agy-cooldown.json', { + type: 'agy', + email: 'cooldown@gmail.com', + access_token: 'token', + }); + + const now = Date.now(); + expect(pauseAccountForQuotaCooldown('agy', 'cooldown@gmail.com', 5, now)).toBe(true); + + const registryPath = path.join(tmpDir, '.ccs', 'cliproxy', 'accounts.json'); + const quotaPausedPath = path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'); + const originalPausedAt = '2026-01-01T00:00:00.000Z'; + const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as { + providers: { + agy: { + accounts: Record; + }; + }; + }; + const quotaPaused = JSON.parse(fs.readFileSync(quotaPausedPath, 'utf8')) as { + entries: Array<{ pausedAt: string }>; + }; + registry.providers.agy.accounts['cooldown@gmail.com'].pausedAt = originalPausedAt; + quotaPaused.entries[0].pausedAt = originalPausedAt; + fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2)); + fs.writeFileSync(quotaPausedPath, JSON.stringify(quotaPaused, null, 2)); + + expect(pauseAccount('agy', 'cooldown@gmail.com')).toBe(true); + + const refreshedRegistry = JSON.parse( + fs.readFileSync(registryPath, 'utf8') + ) as typeof registry; + expect(refreshedRegistry.providers.agy.accounts['cooldown@gmail.com'].pausedAt).not.toBe( + originalPausedAt + ); + + const resumed = restoreExpiredQuotaPauses(now + 6 * 60 * 1000); + const { getAccount } = await import('../account-manager'); + + expect(resumed).toBe(0); + expect(getAccount('agy', 'cooldown@gmail.com')?.paused).toBe(true); + expect( + fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'auth-paused', 'agy-cooldown.json')) + ).toBe(true); + expect(fs.existsSync(quotaPausedPath)).toBe(false); + }); + it('does not auto-resume quota-paused accounts when pausedAt metadata is missing', async () => { writeRegistry({ agy: { diff --git a/src/cliproxy/accounts/registry.ts b/src/cliproxy/accounts/registry.ts index fb688341..b87d0dd6 100644 --- a/src/cliproxy/accounts/registry.ts +++ b/src/cliproxy/accounts/registry.ts @@ -660,6 +660,10 @@ export function pauseAccount(provider: CLIProxyProvider, accountId: string): boo const accountMeta = providerAccounts.accounts[accountId]; if (accountMeta.paused) { + // Treat an explicit pause request for an already paused account as a fresh + // manual decision. This changes the pause metadata so quota cooldown + // restore cannot later mistake the pause for its original auto-pause. + accountMeta.pausedAt = new Date().toISOString(); return true; } diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index b53dd770..5094e4e2 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -1056,8 +1056,13 @@ export async function handlePauseAccount(args: string[]): Promise { } if (account.paused) { + const refreshed = pauseAccount(provider, account.id); + const refreshedAccount = refreshed ? findAccountByQuery(provider, account.id) : account; console.log(warn(`Account already paused: ${formatCliAccountLabel(account)}`)); - console.log(info(`Paused at: ${account.pausedAt || 'unknown'}`)); + if (refreshed) { + console.log(info('Manual pause refreshed; account will stay out of quota rotation')); + } + console.log(info(`Paused at: ${refreshedAccount?.pausedAt || account.pausedAt || 'unknown'}`)); return; } From 14da76abae511f020bfa67d9b2b57342ebdc27fa Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 15:28:32 -0400 Subject: [PATCH 111/137] fix: redact OAuth trace error messages --- .../oauth-handler-paste-callback.test.ts | 6 ++++- .../__tests__/oauth-trace-recorder.test.ts | 26 ++++++++++++++++--- src/cliproxy/auth/oauth-handler.ts | 11 +++++--- .../auth/oauth-trace/trace-recorder.ts | 6 ++--- 4 files changed, 38 insertions(+), 11 deletions(-) diff --git a/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts b/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts index 9bf3436b..b8aa6646 100644 --- a/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts +++ b/src/cliproxy/auth/__tests__/oauth-handler-paste-callback.test.ts @@ -306,7 +306,11 @@ describe('handlePasteCallbackMode traceability', () => { }, { url: /\/v0\/management\/oauth-callback$/, - response: { status: 'error', error: 'invalid_grant: expired code' }, + response: { + status: 'error', + error: + 'invalid_grant: bad redirect http://localhost:1455/callback?code=oauth-code-secret&state=upstream-state-secret', + }, status: 400, }, ]); diff --git a/src/cliproxy/auth/__tests__/oauth-trace-recorder.test.ts b/src/cliproxy/auth/__tests__/oauth-trace-recorder.test.ts index 86dc7fc8..7b7839b8 100644 --- a/src/cliproxy/auth/__tests__/oauth-trace-recorder.test.ts +++ b/src/cliproxy/auth/__tests__/oauth-trace-recorder.test.ts @@ -120,9 +120,29 @@ describe('createOAuthTraceRecorder', () => { expect(snap[0].error).toEqual({ code: 'E1', message: 'boom' }); }); - test('Error instance accepted', () => { + test('redacts OAuth secrets from error messages before they reach sinks', () => { + const lines: string[] = []; + const { rec } = makeRecorder(true, lines); + rec.record(OAuthTracePhase.Error, undefined, { + code: 'CALLBACK_REJECTED', + message: + 'bad redirect http://localhost:1455/callback?code=AUTHCODE_SECRET&state=STATE_SECRET', + }); + + const blob = JSON.stringify(rec.snapshot()) + '\n' + lines.join('\n'); + expect(blob).not.toContain('AUTHCODE_SECRET'); + expect(blob).not.toContain('STATE_SECRET'); + expect(blob).toContain(REDACTED_PLACEHOLDER); + }); + + test('Error instance accepted and redacted', () => { const { rec } = makeRecorder(); - rec.record(OAuthTracePhase.Error, undefined, new Error('plain')); - expect(rec.snapshot()[0].error?.message).toBe('plain'); + rec.record( + OAuthTracePhase.Error, + undefined, + new Error('bad redirect http://localhost:1455/callback?code=AUTHCODE_SECRET') + ); + expect(rec.snapshot()[0].error?.message).toContain(REDACTED_PLACEHOLDER); + expect(rec.snapshot()[0].error?.message).not.toContain('AUTHCODE_SECRET'); }); }); diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index 3c0c06f3..dea6cddc 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -82,6 +82,7 @@ import { generateSessionId } from './project-selection-handler'; import { createFileSink } from './oauth-trace/sink-file'; import { createOAuthTraceRecorder, OAuthTracePhase, type OAuthTraceRecorder } from './oauth-trace'; import { diagnoseFailure, formatErrorMessage } from './oauth-trace/diagnose-failure'; +import { redactString } from './oauth-trace/redactor'; interface PasteCallbackStartData { url?: string; @@ -889,8 +890,9 @@ export async function handlePasteCallbackMode( if (!callbackResponse.ok || callbackData.status === 'error') { const callbackError = callbackData.error || `OAuth callback failed with status ${callbackResponse.status}`; - console.log(fail(callbackError)); - warnPossible403Ban(provider, callbackError); + const redactedCallbackError = redactString(callbackError); + console.log(fail(redactedCallbackError)); + warnPossible403Ban(provider, redactedCallbackError); trace.record( OAuthTracePhase.Error, { status: callbackResponse.status }, @@ -915,8 +917,9 @@ export async function handlePasteCallbackMode( ); if (tokenWaitError) { - console.log(fail(tokenWaitError)); - warnPossible403Ban(provider, tokenWaitError); + const redactedTokenWaitError = redactString(tokenWaitError); + console.log(fail(redactedTokenWaitError)); + warnPossible403Ban(provider, redactedTokenWaitError); trace.record( OAuthTracePhase.Error, {}, diff --git a/src/cliproxy/auth/oauth-trace/trace-recorder.ts b/src/cliproxy/auth/oauth-trace/trace-recorder.ts index 5cf14c17..7b8d8a72 100644 --- a/src/cliproxy/auth/oauth-trace/trace-recorder.ts +++ b/src/cliproxy/auth/oauth-trace/trace-recorder.ts @@ -1,7 +1,7 @@ import { OAuthTraceEvent, OAuthTracePhase, OAuthTraceSink } from './trace-events'; import { createMemorySink } from './sink-memory'; import { createVerboseStdoutSink } from './sink-verbose-stdout'; -import { redactJsonShallow } from './redactor'; +import { redactJsonShallow, redactString } from './redactor'; export interface OAuthTraceRecorder { record( @@ -53,9 +53,9 @@ export function createOAuthTraceRecorder(options: OAuthTraceRecorderOptions): OA ): { code?: string; message: string } | undefined { if (!err) return undefined; if (err instanceof Error) { - return { message: err.message }; + return { message: redactString(err.message) }; } - return { code: err.code, message: err.message }; + return { code: err.code, message: redactString(err.message) }; } return { From 9eb0201b794d2c231416865e28b991c93982db43 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 15:29:17 -0400 Subject: [PATCH 112/137] fix(codex-auth): narrow running Codex detection --- .../commands/import-default-command.ts | 46 ++--- .../commands/import-default-command.test.ts | 169 ++++++++++-------- 2 files changed, 106 insertions(+), 109 deletions(-) diff --git a/src/codex-auth/commands/import-default-command.ts b/src/codex-auth/commands/import-default-command.ts index d4352813..5fb9db08 100644 --- a/src/codex-auth/commands/import-default-command.ts +++ b/src/codex-auth/commands/import-default-command.ts @@ -3,7 +3,7 @@ * * Migrates legacy ~/.codex/auth.json into a named profile (non-destructive). * Implements C3 torn-write protection: read-with-retry + JWT-shape validation - * + pgrep Codex-running detection + atomic write. + * + current-user Codex-running detection + atomic write. * * Usage: ccsx auth import-default [--with-history] [--force] [--force-while-running] */ @@ -40,51 +40,37 @@ function sleep(ms: number): Promise { } /** - * Detect a running `codex` process via pgrep + ps validation (best-effort, never throws). - * Returns the PID string if found, null otherwise. + * Detect a running Codex CLI process from the current user's process table + * (best-effort, never throws). Returns the PID string if found, null otherwise. */ function detectCodexRunning(): string | null { try { - const result = childProcess.spawnSync('pgrep', ['-f', 'codex'], { - encoding: 'utf8', - timeout: 2000, - }); - if (result.status !== 0 || !result.stdout || result.stdout.trim().length === 0) { - return null; - } - - const pids = parsePgrepPids(result.stdout); - if (pids.length === 0) return null; - - const psResult = childProcess.spawnSync( + const result = childProcess.spawnSync( 'ps', - ['-p', pids.join(','), '-o', 'pid=', '-o', 'command='], + ['-A', '-o', 'pid=', '-o', 'uid=', '-o', 'command='], { encoding: 'utf8', timeout: 2000, } ); - if (psResult.status !== 0 || !psResult.stdout) return null; - return selectCodexPidFromPsOutput(psResult.stdout, pids); + if (result.status !== 0 || !result.stdout || result.stdout.trim().length === 0) { + return null; + } + + return selectCodexPidFromPsOutput(result.stdout); } catch { return null; } } -function parsePgrepPids(stdout: string): string[] { - return stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((pid) => /^\d+$/.test(pid) && pid !== String(process.pid)); -} - -function selectCodexPidFromPsOutput(stdout: string, candidatePids: string[]): string | null { - const candidates = new Set(candidatePids); +function selectCodexPidFromPsOutput(stdout: string): string | null { + const currentUid = typeof process.getuid === 'function' ? process.getuid() : null; for (const line of stdout.split(/\r?\n/)) { - const match = line.match(/^\s*(\d+)\s+(.+?)\s*$/); + const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/); if (!match) continue; - const [, pid, command] = match; - if (!pid || !command || !candidates.has(pid)) continue; + const [, pid, uid, command] = match; + if (!pid || !uid || !command || pid === String(process.pid)) continue; + if (currentUid !== null && uid !== String(currentUid)) continue; if (isLikelyCodexProcessCommand(command)) return pid; } return null; diff --git a/tests/unit/codex-auth/commands/import-default-command.test.ts b/tests/unit/codex-auth/commands/import-default-command.test.ts index 78107bbe..da1923b5 100644 --- a/tests/unit/codex-auth/commands/import-default-command.test.ts +++ b/tests/unit/codex-auth/commands/import-default-command.test.ts @@ -8,7 +8,7 @@ * - cliproxy-format source → rejects with clear message * - torn-write retry: truncated JSON twice then full → succeeds on 3rd read * - persistent torn state → clean error, not silent corruption - * - pgrep mock returning PID → warns + refuses without --force-while-running + * - process-table Codex PID → warns + refuses without --force-while-running * - --force-while-running bypasses pgrep check * - --with-history copies history.jsonl + sessions/ * - --with-history default false → not copied @@ -54,9 +54,9 @@ beforeEach(() => { process.env.CCS_HOME = ccsHome; process.env.LEGACY_CODEX_HOME = legacyCodexHome; - // Default: pgrep finds nothing (no Codex running). Tests that need a positive - // result override this per-test. Without this default, `pgrep -f codex` on a - // dev machine matches the Claude Code process itself and exits early with code 7. + // Default: process-table lookup finds nothing (no Codex running). Tests that + // need a positive result override this per-test. This keeps local developer + // processes from affecting import-default tests. spyOn(childProcess, 'spawnSync').mockReturnValue({ status: 1, stdout: '', @@ -78,9 +78,8 @@ afterEach(() => { }); async function makeCtx() { - const { CodexProfileRegistry } = await import( - '../../../../src/codex-auth/codex-profile-registry' - ); + const { CodexProfileRegistry } = + await import('../../../../src/codex-auth/codex-profile-registry'); return { registry: new CodexProfileRegistry(), version: '0.0.0-test', @@ -128,21 +127,10 @@ function captureOutput(): { stderr: string[]; restore: () => void } { }; } -function mockProcessTable(pgrepStdout: string, psStdout: string) { +function mockProcessTable(psStdout: string) { spyOn(childProcess, 'spawnSync').mockImplementation( // eslint-disable-next-line @typescript-eslint/no-explicit-any (cmd: string, _args: string[]): any => { - if (cmd === 'pgrep') { - return { - status: pgrepStdout.trim().length > 0 ? 0 : 1, - stdout: pgrepStdout, - stderr: '', - pid: 0, - output: [], - signal: null, - error: undefined, - }; - } if (cmd === 'ps') { return { status: psStdout.trim().length > 0 ? 0 : 1, @@ -171,9 +159,8 @@ function mockProcessTable(pgrepStdout: string, psStdout: string) { describe('import-default — missing legacy auth.json', () => { it('exits with clear error when ~/.codex/auth.json does not exist', async () => { - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); let exitCalled = false; @@ -201,9 +188,8 @@ describe('import-default — option validation', () => { it('rejects unsupported flags before importing legacy auth', async () => { fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); let exitCount = 0; @@ -252,9 +238,8 @@ describe('import-default — profile collision without --force', () => { // Write valid legacy auth fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); // Pre-create the profile ctx.registry.createProfile('myprofile'); @@ -284,9 +269,8 @@ describe('import-default — --force overwrites and creates backup', () => { // Write valid legacy auth fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); // First import (no --force needed since profile doesn't exist) @@ -338,9 +322,8 @@ describe('import-default — cliproxy-format rejection', () => { }); fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), cliproxyAuth); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); let exitCalled = false; @@ -370,9 +353,8 @@ describe('import-default — torn-write retry', () => { // Write truncated JSON initially — simulates torn write mid-file fs.writeFileSync(authPath, '{truncated'); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); // After 50ms (before 2nd retry at 100ms) replace with valid JSON @@ -397,9 +379,8 @@ describe('import-default — torn-write retry', () => { // Write persistently invalid JSON — all retries will fail fs.writeFileSync(authPath, '{always-truncated'); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); let exitCalled = false; @@ -426,9 +407,8 @@ describe('import-default — torn-write retry', () => { const authPath = path.join(legacyCodexHome, 'auth.json'); fs.writeFileSync(authPath, JSON.stringify({ tokens: { id_token: 'header.not-json.sig' } })); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); let exitCalled = false; @@ -455,9 +435,8 @@ describe('import-default — torn-write retry', () => { const authPath = path.join(legacyCodexHome, 'auth.json'); fs.writeFileSync(authPath, JSON.stringify({ tokens: { id_token: 'h.e30$.s' } })); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); let exitCalled = false; @@ -485,9 +464,8 @@ describe('import-default — torn-write retry', () => { const [header, payload] = VALID_JWT.split('.'); fs.writeFileSync(authPath, JSON.stringify({ tokens: { id_token: `${header}.${payload}.a` } })); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); let exitCalled = false; @@ -512,14 +490,13 @@ describe('import-default — torn-write retry', () => { }); describe('import-default — Codex running detection', () => { - it('warns and refuses when pgrep finds a codex PID', async () => { + it('warns and refuses when process table finds a same-user Codex PID', async () => { fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON); - mockProcessTable('12345\n', '12345 /usr/local/bin/codex login\n'); + mockProcessTable(`12345 ${process.getuid()} /usr/local/bin/codex login\n`); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); let exitCalled = false; @@ -546,11 +523,10 @@ describe('import-default — Codex running detection', () => { it('proceeds with --force-while-running even when Codex is running', async () => { fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON); - mockProcessTable('12345\n', '12345 /usr/local/bin/codex login\n'); + mockProcessTable(`12345 ${process.getuid()} /usr/local/bin/codex login\n`); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); const restore = silenceConsole(); @@ -567,11 +543,10 @@ describe('import-default — Codex running detection', () => { it('warns and refuses when Codex is running through a node shim', async () => { fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON); - mockProcessTable('12345\n', '12345 /usr/bin/node /usr/local/bin/codex login\n'); + mockProcessTable(`12345 ${process.getuid()} /usr/bin/node /usr/local/bin/codex login\n`); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); let exitCalled = false; @@ -595,14 +570,13 @@ describe('import-default — Codex running detection', () => { expect(ctx.registry.hasProfile('nodeshim')).toBe(false); }); - it('ignores pgrep false positives that are not Codex executables', async () => { + it('ignores process-table false positives that are not Codex executables', async () => { fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON); - mockProcessTable('11111\n', '11111 /usr/bin/node /tmp/codex-auth-helper.js\n'); + mockProcessTable(`11111 ${process.getuid()} /usr/bin/node /tmp/codex-auth-helper.js\n`); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); const restore = silenceConsole(); @@ -614,6 +588,47 @@ describe('import-default — Codex running detection', () => { expect(ctx.registry.hasProfile('falsepositive')).toBe(true); }); + + it('ignores same-process codex-runtime invocation paths', async () => { + fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON); + + mockProcessTable( + `${process.pid} ${process.getuid()} node /workspace/ccs/dist/bin/codex-runtime.js auth import-default self\n` + ); + + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); + const ctx = await makeCtx(); + + const restore = silenceConsole(); + try { + await handleImportDefaultCodex(ctx, ['selfmatch']); + } finally { + restore(); + } + + expect(ctx.registry.hasProfile('selfmatch')).toBe(true); + }); + + it('ignores Codex processes owned by another uid', async () => { + fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON); + + const otherUid = process.getuid() + 1; + mockProcessTable(`22222 ${otherUid} /usr/local/bin/codex login\n`); + + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); + const ctx = await makeCtx(); + + const restore = silenceConsole(); + try { + await handleImportDefaultCodex(ctx, ['otheruid']); + } finally { + restore(); + } + + expect(ctx.registry.hasProfile('otheruid')).toBe(true); + }); }); describe('import-default — --with-history', () => { @@ -624,9 +639,8 @@ describe('import-default — --with-history', () => { fs.mkdirSync(sessionsDir, { recursive: true }); fs.writeFileSync(path.join(sessionsDir, 'sess1.json'), '{}'); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); const restore = silenceConsole(); @@ -645,9 +659,8 @@ describe('import-default — --with-history', () => { fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON); fs.writeFileSync(path.join(legacyCodexHome, 'history.jsonl'), '{"prompt":"hello"}\n'); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); const restore = silenceConsole(); @@ -666,9 +679,8 @@ describe('import-default — atomic write', () => { it('leaves no tmp file after successful import', async () => { fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); const restore = silenceConsole(); @@ -689,9 +701,8 @@ describe('import-default — happy path end-to-end', () => { it('registers profile with decoded email in registry', async () => { fs.writeFileSync(path.join(legacyCodexHome, 'auth.json'), VALID_AUTH_JSON); - const { handleImportDefaultCodex } = await import( - '../../../../src/codex-auth/commands/import-default-command' - ); + const { handleImportDefaultCodex } = + await import('../../../../src/codex-auth/commands/import-default-command'); const ctx = await makeCtx(); const restore = silenceConsole(); From c0f5413523c8ae0f7cff25ea4de97c4427c5f989 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 15:29:47 -0400 Subject: [PATCH 113/137] fix(ui): preserve distinct log detail rows --- ui/src/components/logs/derive-trace-groups.ts | 32 +++++++--- .../logs-derive-trace-groups.test.ts | 59 +++++++++++++++++++ 2 files changed, 83 insertions(+), 8 deletions(-) diff --git a/ui/src/components/logs/derive-trace-groups.ts b/ui/src/components/logs/derive-trace-groups.ts index a533fc38..04e0d8ad 100644 --- a/ui/src/components/logs/derive-trace-groups.ts +++ b/ui/src/components/logs/derive-trace-groups.ts @@ -17,20 +17,30 @@ export interface LeafItem { export type DerivedItem = LeafItem | TraceGroup; +function stableStringify(value: unknown): string { + if (value === undefined) return ''; + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`; + + return `{${Object.entries(value as Record) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`) + .join(',')}}`; +} + /** - * Tuple key for coalescing standalone leaves. Includes `message` and - * `source` so two adjacent logs that share event/module/level but report - * different content (or come from a different service) stay distinct - * (e.g. `User logged in: alice` and `User logged in: bob`). Excludes - * `latencyMs` and `metadata` because those drift per request even on - * truly redundant polls — including them would prevent any coalescing. + * Tuple key for coalescing standalone leaves. The row/detail UI exposes more + * than event/module/level, so every inspectable payload field must participate + * in equality; otherwise two adjacent logs with different metadata, context, + * error, or latency could collapse into one selectable row. `timestamp` stays + * out of the key because collapsed rows surface their time span separately. * * NB: this only applies to *leaves* (entries without `requestId`). Trace * children render uncoalesced so retries and duplicated-stage emissions * stay individually inspectable. */ function coalesceKey(entry: LogsEntry): string { - return [ + return JSON.stringify([ entry.event ?? '', entry.message ?? '', entry.stage ?? '', @@ -38,7 +48,13 @@ function coalesceKey(entry: LogsEntry): string { entry.level, entry.requestId ?? '', entry.source ?? '', - ].join(' '); + entry.runId ?? '', + String(entry.processId ?? ''), + String(entry.latencyMs ?? ''), + stableStringify(entry.context), + stableStringify(entry.metadata), + stableStringify(entry.error), + ]); } /** diff --git a/ui/tests/unit/ui/components/logs-derive-trace-groups.test.ts b/ui/tests/unit/ui/components/logs-derive-trace-groups.test.ts index 1fef043f..21025f6b 100644 --- a/ui/tests/unit/ui/components/logs-derive-trace-groups.test.ts +++ b/ui/tests/unit/ui/components/logs-derive-trace-groups.test.ts @@ -141,6 +141,65 @@ describe('deriveTraceGroups', () => { expect(result.every((r) => r.kind === 'leaf' && r.repeatCount === undefined)).toBe(true); }); + it('keeps adjacent leaves with different detail payloads as separate rows', () => { + const result = deriveTraceGroups( + leafEntries( + { + id: '1', + timestamp: 't1', + event: 'poll', + message: 'same', + latencyMs: 25, + metadata: { attempt: 1, nested: { status: 'warm' } }, + context: { account: 'alpha' }, + error: { code: 'E_ONE', message: 'first' }, + }, + { + id: '2', + timestamp: 't2', + event: 'poll', + message: 'same', + latencyMs: 50, + metadata: { attempt: 2, nested: { status: 'cold' } }, + context: { account: 'beta' }, + error: { code: 'E_TWO', message: 'second' }, + } + ) + ); + + expect(result).toHaveLength(2); + expect(result.every((r) => r.kind === 'leaf' && r.repeatCount === undefined)).toBe(true); + expect(result.map((r) => (r.kind === 'leaf' ? r.entry.id : 'trace'))).toEqual(['2', '1']); + }); + + it('coalesces leaves with semantically identical structured payloads', () => { + const result = deriveTraceGroups( + leafEntries( + { + id: '1', + timestamp: 't1', + event: 'poll', + message: 'tick', + latencyMs: 25, + metadata: { b: 2, a: 1 }, + context: { nested: { b: false, a: true } }, + }, + { + id: '2', + timestamp: 't2', + event: 'poll', + message: 'tick', + latencyMs: 25, + metadata: { a: 1, b: 2 }, + context: { nested: { a: true, b: false } }, + } + ) + ); + + expect(result).toHaveLength(1); + expect((result[0] as LeafItem).repeatCount).toBe(2); + }); + it('display-sorts items reverse-chronologically', () => { // Use distinct events so leaves don't coalesce — testing display sort, // not coalesce. From 0ca3e4b35a356e0bbeabb3cb8b996f6eb71d70f7 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 30 May 2026 15:31:38 -0400 Subject: [PATCH 114/137] style: format provider alias normalization changes --- src/cliproxy/quota/quota-manager.ts | 10 ++++++---- src/management/checks/image-analysis-check.ts | 5 +++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/cliproxy/quota/quota-manager.ts b/src/cliproxy/quota/quota-manager.ts index 041e2748..8574b079 100644 --- a/src/cliproxy/quota/quota-manager.ts +++ b/src/cliproxy/quota/quota-manager.ts @@ -483,8 +483,9 @@ export async function reconcileExhaustedRotationAccounts( return []; } - const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = - await import('../accounts/account-safety'); + const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = await import( + '../accounts/account-safety' + ); restoreExpiredQuotaPauses(); const config = loadOrCreateUnifiedConfig(); @@ -592,8 +593,9 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise * Fix image analysis configuration issues */ export async function fixImageAnalysisConfig(): Promise { - const { updateConfig, loadOrCreateUnifiedConfig } = - await import('../../config/config-loader-facade'); + const { updateConfig, loadOrCreateUnifiedConfig } = await import( + '../../config/config-loader-facade' + ); const config = loadOrCreateUnifiedConfig(); let fixed = false; From b3a8ced8feee587db1e3a5587b08fc0340dd140e Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 30 May 2026 15:34:55 -0400 Subject: [PATCH 115/137] style: format v3 snapshot migration changes --- src/cliproxy/quota/quota-manager.ts | 10 ++++++---- src/management/checks/image-analysis-check.ts | 5 +++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/cliproxy/quota/quota-manager.ts b/src/cliproxy/quota/quota-manager.ts index 041e2748..8574b079 100644 --- a/src/cliproxy/quota/quota-manager.ts +++ b/src/cliproxy/quota/quota-manager.ts @@ -483,8 +483,9 @@ export async function reconcileExhaustedRotationAccounts( return []; } - const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = - await import('../accounts/account-safety'); + const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = await import( + '../accounts/account-safety' + ); restoreExpiredQuotaPauses(); const config = loadOrCreateUnifiedConfig(); @@ -592,8 +593,9 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise * Fix image analysis configuration issues */ export async function fixImageAnalysisConfig(): Promise { - const { updateConfig, loadOrCreateUnifiedConfig } = - await import('../../config/config-loader-facade'); + const { updateConfig, loadOrCreateUnifiedConfig } = await import( + '../../config/config-loader-facade' + ); const config = loadOrCreateUnifiedConfig(); let fixed = false; From 89475e39100ed67889943272bedf4e8494c4c06b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 30 May 2026 15:36:13 -0400 Subject: [PATCH 116/137] style: format ccsxp config override changes --- src/cliproxy/quota/quota-manager.ts | 10 ++++++---- src/management/checks/image-analysis-check.ts | 5 +++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/cliproxy/quota/quota-manager.ts b/src/cliproxy/quota/quota-manager.ts index 041e2748..8574b079 100644 --- a/src/cliproxy/quota/quota-manager.ts +++ b/src/cliproxy/quota/quota-manager.ts @@ -483,8 +483,9 @@ export async function reconcileExhaustedRotationAccounts( return []; } - const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = - await import('../accounts/account-safety'); + const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = await import( + '../accounts/account-safety' + ); restoreExpiredQuotaPauses(); const config = loadOrCreateUnifiedConfig(); @@ -592,8 +593,9 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise * Fix image analysis configuration issues */ export async function fixImageAnalysisConfig(): Promise { - const { updateConfig, loadOrCreateUnifiedConfig } = - await import('../../config/config-loader-facade'); + const { updateConfig, loadOrCreateUnifiedConfig } = await import( + '../../config/config-loader-facade' + ); const config = loadOrCreateUnifiedConfig(); let fixed = false; From 8261901b89a4baa351a802417ed3a1a83a75e62b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 30 May 2026 15:37:27 -0400 Subject: [PATCH 117/137] style: format live catalog default changes --- src/cliproxy/quota/quota-manager.ts | 10 ++++++---- src/management/checks/image-analysis-check.ts | 5 +++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/cliproxy/quota/quota-manager.ts b/src/cliproxy/quota/quota-manager.ts index 041e2748..8574b079 100644 --- a/src/cliproxy/quota/quota-manager.ts +++ b/src/cliproxy/quota/quota-manager.ts @@ -483,8 +483,9 @@ export async function reconcileExhaustedRotationAccounts( return []; } - const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = - await import('../accounts/account-safety'); + const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = await import( + '../accounts/account-safety' + ); restoreExpiredQuotaPauses(); const config = loadOrCreateUnifiedConfig(); @@ -592,8 +593,9 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise * Fix image analysis configuration issues */ export async function fixImageAnalysisConfig(): Promise { - const { updateConfig, loadOrCreateUnifiedConfig } = - await import('../../config/config-loader-facade'); + const { updateConfig, loadOrCreateUnifiedConfig } = await import( + '../../config/config-loader-facade' + ); const config = loadOrCreateUnifiedConfig(); let fixed = false; From f592260049a6e7345a3457d990fa952e8fb09ff3 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 30 May 2026 15:37:47 -0400 Subject: [PATCH 118/137] fix(ui): suppress privacy mode account title leaks --- .../account/shared/account-surface-card.tsx | 10 ++-- .../account/account-surface-card.test.tsx | 48 +++++++++++++++++++ 2 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 ui/tests/components/account/account-surface-card.test.tsx diff --git a/ui/src/components/account/shared/account-surface-card.tsx b/ui/src/components/account/shared/account-surface-card.tsx index 6641068d..8e680913 100644 --- a/ui/src/components/account/shared/account-surface-card.tsx +++ b/ui/src/components/account/shared/account-surface-card.tsx @@ -172,6 +172,8 @@ export function AccountSurfaceCard({ const { t } = useTranslation(); const identity = getAccountIdentityPresentation(accountId, email, tokenFile); const title = displayEmail || identity.email || accountId; + const sensitiveTitle = (value: string | null | undefined) => + privacyMode ? undefined : (value ?? undefined); const normalizedProvider = provider.toLowerCase(); const effectiveTier = resolveEffectiveTier(tier, quota); const effectiveCodexBadge = @@ -202,7 +204,7 @@ export function AccountSurfaceCard({ {normalizedProvider === 'codex' ? effectiveCodexBadge?.label && ( { + it('keeps sensitive account title attributes available outside privacy mode', () => { + const { container } = render( + + ); + + expect(screen.getByText('person@example.com')).toHaveAttribute('title', 'person@example.com'); + expect(container.querySelector('[title="Business"]')).toBeInTheDocument(); + expect(container.querySelector('[title="Workspace abcdef12"]')).toBeInTheDocument(); + }); + + it('removes sensitive account title attributes in privacy mode', () => { + const { container } = render( + <> + + + + ); + + expect(screen.getByText('person@example.com')).not.toHaveAttribute('title'); + expect(screen.getByText('codex@example.com')).not.toHaveAttribute('title'); + expect(container.querySelector('[title="Business"]')).not.toBeInTheDocument(); + expect(container.querySelector('[title="Workspace abcdef12"]')).not.toBeInTheDocument(); + expect(container.querySelector('[title*="Enterprise"]')).not.toBeInTheDocument(); + }); +}); From a0870aa40455ae0d1475041d533e909629e9d906 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 30 May 2026 15:38:50 -0400 Subject: [PATCH 119/137] fix(cliproxy): fall back from invalid Codex feature labels --- src/cliproxy/quota/quota-label-sanitizer.ts | 10 ++++-- src/commands/cliproxy/quota-subcommand.ts | 16 +++++++--- .../cliproxy-quota-subcommand.test.ts | 31 +++++++++++++------ 3 files changed, 40 insertions(+), 17 deletions(-) diff --git a/src/cliproxy/quota/quota-label-sanitizer.ts b/src/cliproxy/quota/quota-label-sanitizer.ts index 438527ea..b7279eb1 100644 --- a/src/cliproxy/quota/quota-label-sanitizer.ts +++ b/src/cliproxy/quota/quota-label-sanitizer.ts @@ -11,8 +11,8 @@ const TERMINAL_CONTROL_CHARS_REGEX = /[\u0000-\u001f\u007f-\u009f]/g; * labels must be constrained to safe, printable strings before they reach the * terminal. */ -export function sanitizeCodexFeatureLabel(value: unknown): string { - if (typeof value !== 'string') return CODEX_FEATURE_LABEL_FALLBACK; +export function sanitizeCodexFeatureLabelOrNull(value: unknown): string | null { + if (typeof value !== 'string') return null; const sanitized = value .replace(TERMINAL_ESCAPE_SEQUENCE_REGEX, '') @@ -21,5 +21,9 @@ export function sanitizeCodexFeatureLabel(value: unknown): string { .slice(0, CODEX_FEATURE_LABEL_MAX_LENGTH) .trimEnd(); - return sanitized.length > 0 ? sanitized : CODEX_FEATURE_LABEL_FALLBACK; + return sanitized.length > 0 ? sanitized : null; +} + +export function sanitizeCodexFeatureLabel(value: unknown): string { + return sanitizeCodexFeatureLabelOrNull(value) ?? CODEX_FEATURE_LABEL_FALLBACK; } diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index 2efdbd05..428d0f65 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -18,7 +18,10 @@ import { } from '../../cliproxy/accounts/account-manager'; import { fetchAllProviderQuotas } from '../../cliproxy/quota/quota-fetcher'; import { fetchAllCodexQuotas } from '../../cliproxy/quota/quota-fetcher-codex'; -import { sanitizeCodexFeatureLabel } from '../../cliproxy/quota/quota-label-sanitizer'; +import { + sanitizeCodexFeatureLabel, + sanitizeCodexFeatureLabelOrNull, +} from '../../cliproxy/quota/quota-label-sanitizer'; import { fetchAllClaudeQuotas } from '../../cliproxy/quota/quota-fetcher-claude'; import { pickMostRestrictiveClaudeWeeklyWindow } from '../../cliproxy/quota/quota-fetcher-claude-normalizer'; import { fetchAllGeminiCliQuotas } from '../../cliproxy/quota/quota-fetcher-gemini-cli'; @@ -280,9 +283,12 @@ function inferCodeReviewCadence( * Strip a leading "GPT-X.Y-Codex-" prefix from a feature label and turn the * remainder into a Codex-prefixed display name. Other labels pass through unchanged. */ -function prettifyCodexFeatureLabel(featureLabel: unknown): string { - const trimmed = sanitizeCodexFeatureLabel(featureLabel); - if (!trimmed) return 'Additional'; +function prettifyCodexFeatureLabel(featureLabel: unknown, fallbackLabel?: unknown): string { + const trimmed = + sanitizeCodexFeatureLabelOrNull(featureLabel) ?? + (fallbackLabel === undefined + ? sanitizeCodexFeatureLabel(featureLabel) + : sanitizeCodexFeatureLabel(fallbackLabel)); const stripped = trimmed.replace(/^GPT-[\d.]+-Codex-/i, ''); if (stripped !== trimmed && stripped.length > 0) { return `Codex ${stripped}`; @@ -303,7 +309,7 @@ function getCodexWindowDisplayLabel( } if (window.category === 'additional') { - const pretty = prettifyCodexFeatureLabel(window.featureLabel ?? window.label); + const pretty = prettifyCodexFeatureLabel(window.featureLabel, window.label); if (window.cadence === '5h') return `${pretty} (5h)`; if (window.cadence === 'weekly') return `${pretty} (weekly)`; return pretty; diff --git a/tests/unit/commands/cliproxy-quota-subcommand.test.ts b/tests/unit/commands/cliproxy-quota-subcommand.test.ts index 5c30fc95..955ea422 100644 --- a/tests/unit/commands/cliproxy-quota-subcommand.test.ts +++ b/tests/unit/commands/cliproxy-quota-subcommand.test.ts @@ -86,18 +86,31 @@ describe('cliproxy quota subcommand failure formatting', () => { }); describe('cliproxy quota subcommand Codex label formatting', () => { - it('falls back for non-string cached Codex feature labels', async () => { + it('falls back to the cached window label for invalid Codex feature labels', async () => { const { getCodexWindowDisplayLabel } = await loadQuotaCommandTestExports(); - const label = getCodexWindowDisplayLabel({ - label: 'ignored', - resetAfterSeconds: 3600, - category: 'additional', - cadence: '5h', - featureLabel: { unexpected: true }, - } as never); + const cases = [ + { featureLabel: '', cadence: '5h', expected: 'Codex Spark (5h)' }, + { featureLabel: ' ', cadence: 'weekly', expected: 'Codex Spark (weekly)' }, + { + featureLabel: '\u001b[2J\u001b]52;c;payload\u0007', + cadence: '5h', + expected: 'Codex Spark (5h)', + }, + { featureLabel: { unexpected: true }, cadence: '5h', expected: 'Codex Spark (5h)' }, + ] as const; - expect(label).toBe('Additional (5h)'); + for (const { featureLabel, cadence, expected } of cases) { + const label = getCodexWindowDisplayLabel({ + label: 'GPT-5.3-Codex-Spark', + resetAfterSeconds: 3600, + category: 'additional', + cadence, + featureLabel, + } as never); + + expect(label).toBe(expected); + } }); it('removes terminal control characters from cached Codex feature labels', async () => { From ad47d0f478bb5bf150f524b9ae271e2766c2bb12 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 30 May 2026 15:39:16 -0400 Subject: [PATCH 120/137] style: format codex target persistence changes --- src/cliproxy/quota/quota-manager.ts | 10 ++++++---- src/management/checks/image-analysis-check.ts | 5 +++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/cliproxy/quota/quota-manager.ts b/src/cliproxy/quota/quota-manager.ts index 041e2748..8574b079 100644 --- a/src/cliproxy/quota/quota-manager.ts +++ b/src/cliproxy/quota/quota-manager.ts @@ -483,8 +483,9 @@ export async function reconcileExhaustedRotationAccounts( return []; } - const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = - await import('../accounts/account-safety'); + const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = await import( + '../accounts/account-safety' + ); restoreExpiredQuotaPauses(); const config = loadOrCreateUnifiedConfig(); @@ -592,8 +593,9 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise * Fix image analysis configuration issues */ export async function fixImageAnalysisConfig(): Promise { - const { updateConfig, loadOrCreateUnifiedConfig } = - await import('../../config/config-loader-facade'); + const { updateConfig, loadOrCreateUnifiedConfig } = await import( + '../../config/config-loader-facade' + ); const config = loadOrCreateUnifiedConfig(); let fixed = false; From 2a3bc6807fc354ed2a55a386d5012a7536aa765e Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 30 May 2026 14:57:14 -0400 Subject: [PATCH 121/137] fix(ci): narrow issue reference parsing --- .github/workflows/dev-release.yml | 4 ++-- .github/workflows/label-pending-release.yml | 2 +- scripts/github/stable-release-issue-cleanup-lib.mjs | 8 +++++--- .../github/stable-release-issue-cleanup.test.mjs | 12 ++++++++++++ 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/.github/workflows/dev-release.yml b/.github/workflows/dev-release.yml index 875a9cb6..afb7b699 100644 --- a/.github/workflows/dev-release.yml +++ b/.github/workflows/dev-release.yml @@ -139,7 +139,7 @@ jobs: COMMIT_TEXT=$(git log $RANGE --pretty=format:"%s%n%b" 2>/dev/null || true) ISSUES_FROM_COMMITS=$(printf '%s\n' "$COMMIT_TEXT" | \ - perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \ + perl -ne 'while (/\b(?:fixes|closes|resolves|refs?)\s+((?:#[0-9]+\b(?:\s*(?:,|and)?\s*#[0-9]+\b)*))/ig) { print "$1\n"; }' | \ grep -oE '#[0-9]+' || true) PR_CANDIDATES=$(printf '%s\n' "$COMMIT_TEXT" | \ @@ -155,7 +155,7 @@ jobs: done ISSUES_FROM_PRS=$(printf '%s\n' "$PR_TEXT" | \ - perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \ + perl -ne 'while (/\b(?:fixes|closes|resolves|refs?)\s+((?:#[0-9]+\b(?:\s*(?:,|and)?\s*#[0-9]+\b)*))/ig) { print "$1\n"; }' | \ grep -oE '#[0-9]+' || true) ISSUES=$(printf '%s\n%s\n' "$ISSUES_FROM_COMMITS" "$ISSUES_FROM_PRS" | \ diff --git a/.github/workflows/label-pending-release.yml b/.github/workflows/label-pending-release.yml index 7452f85d..3b10a329 100644 --- a/.github/workflows/label-pending-release.yml +++ b/.github/workflows/label-pending-release.yml @@ -38,7 +38,7 @@ jobs: $PR_BODY $COMMIT_TEXT" PR_ISSUES=$(printf '%s\n' "$PR_TEXT" | \ - perl -ne 'if (/(fixes|closes|resolves|refs?)(.*)/i) { print "$2\n"; }' | \ + perl -ne 'while (/\b(?:fixes|closes|resolves|refs?)\s+((?:#[0-9]+\b(?:\s*(?:,|and)?\s*#[0-9]+\b)*))/ig) { print "$1\n"; }' | \ grep -oE '#[0-9]+' || true) if [[ -n "$PR_ISSUES" ]]; then ALL_REFERENCED_ISSUES=$(printf '%s\n%s\n' "$ALL_REFERENCED_ISSUES" "$PR_ISSUES") diff --git a/scripts/github/stable-release-issue-cleanup-lib.mjs b/scripts/github/stable-release-issue-cleanup-lib.mjs index 64d7057d..959b2e35 100644 --- a/scripts/github/stable-release-issue-cleanup-lib.mjs +++ b/scripts/github/stable-release-issue-cleanup-lib.mjs @@ -2,8 +2,10 @@ import { readFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; const ISSUE_REF_PATTERN = /#([0-9]+)/g; -const ACTION_VERB_PATTERN = /\b(fixes|closes|resolves|refs?)\b(.*)/gi; -const RESOLVE_VERB_PATTERN = /\b(fixes|closes|resolves)\b(.*)/gi; +const ACTION_VERB_PATTERN = + /\b(?:fixes|closes|resolves|refs?)\b\s+(#\d+\b(?:\s*(?:,|and)?\s*#\d+\b)*)/gi; +const RESOLVE_VERB_PATTERN = + /\b(?:fixes|closes|resolves)\b\s+(#\d+\b(?:\s*(?:,|and)?\s*#\d+\b)*)/gi; const PR_REF_PATTERN = /(?:Merge pull request #|\(#)([0-9]+)/g; const STABLE_TAG_PATTERN = /^v[0-9]+\.[0-9]+\.[0-9]+$/; @@ -14,7 +16,7 @@ export function extractIssueNumbers(text, { includeRefs = true } = {}) { pattern.lastIndex = 0; while ((actionMatch = pattern.exec(text || '')) !== null) { - const tail = actionMatch[2] || ''; + const tail = actionMatch[1] || ''; let issueMatch; ISSUE_REF_PATTERN.lastIndex = 0; while ((issueMatch = ISSUE_REF_PATTERN.exec(tail)) !== null) { diff --git a/tests/unit/github/stable-release-issue-cleanup.test.mjs b/tests/unit/github/stable-release-issue-cleanup.test.mjs index 81cc4f35..de97747d 100644 --- a/tests/unit/github/stable-release-issue-cleanup.test.mjs +++ b/tests/unit/github/stable-release-issue-cleanup.test.mjs @@ -18,6 +18,18 @@ describe('stable release issue cleanup', () => { expect(extractIssueNumbers(text, { includeRefs: false })).toEqual([1340, 1341]); }); + it('ignores unrelated issue references after the action reference sequence', () => { + const text = [ + 'fix: guard release parser', + '', + 'Fixes #12; see #99 for the follow-up', + 'Resolves #13, #14 and #15; related to #100', + ].join('\n'); + + expect(extractIssueNumbers(text, { includeRefs: true })).toEqual([12, 13, 14, 15]); + expect(extractIssueNumbers(text, { includeRefs: false })).toEqual([12, 13, 14, 15]); + }); + it('extracts PR numbers from merge and squash commit subjects', () => { const text = [ 'Merge pull request #1392 from kaitranntt/kai/fix/foo', From f93cb36da68495015428123d4407a9337740aeee Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 30 May 2026 15:40:31 -0400 Subject: [PATCH 122/137] fix: surface cleanup directory read errors --- src/commands/cleanup-command.ts | 144 +++++++++++++------- tests/unit/commands/cleanup-command.test.ts | 90 ++++++++++++ 2 files changed, 186 insertions(+), 48 deletions(-) diff --git a/src/commands/cleanup-command.ts b/src/commands/cleanup-command.ts index 1db86c7b..017ef4c4 100644 --- a/src/commands/cleanup-command.ts +++ b/src/commands/cleanup-command.ts @@ -36,20 +36,52 @@ function formatBytes(bytes: number): string { return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`; } -/** Return entries for a real directory, rejecting symlinked directory targets. */ -function readRealDirectory(dirPath: string): string[] { +interface DirectorySummary { + fileCount: number; + size: number; +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isMissingPathError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOENT' + ); +} + +function pathExistsForCleanup(dirPath: string): boolean { try { - const stats = fs.lstatSync(dirPath); - if (!stats.isDirectory() || stats.isSymbolicLink()) return []; - return fs.readdirSync(dirPath); - } catch { - return []; + fs.lstatSync(dirPath); + return true; + } catch (error) { + if (isMissingPathError(error)) return false; + throw error; } } -/** Calculate total size of regular top-level files in a directory */ -function getDirSize(dirPath: string): number { - let totalSize = 0; +/** Return entries for a real directory, rejecting symlinked directory targets. */ +function readRealDirectory(dirPath: string): string[] { + let stats: fs.Stats; + + try { + stats = fs.lstatSync(dirPath); + } catch (error) { + if (isMissingPathError(error)) return []; + throw error; + } + + if (!stats.isDirectory() || stats.isSymbolicLink()) return []; + return fs.readdirSync(dirPath); +} + +/** Summarize regular top-level files in a real directory */ +function summarizeDirectory(dirPath: string): DirectorySummary { + const summary = { fileCount: 0, size: 0 }; const entries = readRealDirectory(dirPath); for (const entry of entries) { @@ -57,33 +89,15 @@ function getDirSize(dirPath: string): number { try { const stats = fs.lstatSync(filePath); if (stats.isFile() && !stats.isSymbolicLink()) { - totalSize += stats.size; + summary.fileCount++; + summary.size += stats.size; } } catch { // File may have been deleted between readdir and stat - skip } } - return totalSize; -} - -/** Count files in a directory */ -function countFiles(dirPath: string): number { - let count = 0; - const entries = readRealDirectory(dirPath); - - for (const entry of entries) { - const filePath = path.join(dirPath, entry); - try { - const stats = fs.lstatSync(filePath); - if (stats.isFile() && !stats.isSymbolicLink()) { - count++; - } - } catch { - // File may have been deleted - skip - } - } - return count; + return summary; } /** Delete all regular files in a real directory (skips symlinks for safety) */ @@ -154,10 +168,9 @@ function getErrorLogFiles(logsDir: string): ErrorLogInfo[] { /** Delete error logs older than specified days */ function cleanErrorLogs( - logsDir: string, + files: ErrorLogInfo[], maxAgeDays: number ): { deleted: number; freedBytes: number; kept: number } { - const files = getErrorLogFiles(logsDir); let deleted = 0; let freedBytes = 0; let kept = 0; @@ -255,14 +268,23 @@ async function handleErrorLogCleanup( dryRun: boolean, force: boolean ): Promise { - // Check if logs directory exists - if (!fs.existsSync(logsDir)) { - console.log(info('No CLIProxy logs directory found.')); + try { + if (!pathExistsForCleanup(logsDir)) { + console.log(info('No CLIProxy logs directory found.')); + return; + } + } catch (error) { + console.log(warn(`Could not inspect CLIProxy logs: ${getErrorMessage(error)}`)); return; } - // Get error log files - const errorLogs = getErrorLogFiles(logsDir); + let errorLogs: ErrorLogInfo[]; + try { + errorLogs = getErrorLogFiles(logsDir); + } catch (error) { + console.log(warn(`Could not read CLIProxy logs: ${getErrorMessage(error)}`)); + return; + } if (errorLogs.length === 0) { console.log(info('No error logs found.')); return; @@ -327,7 +349,14 @@ async function handleErrorLogCleanup( } // Perform cleanup - const { deleted, freedBytes, kept } = cleanErrorLogs(logsDir, maxAgeDays); + let result: { deleted: number; freedBytes: number; kept: number }; + try { + result = cleanErrorLogs(errorLogs, maxAgeDays); + } catch (error) { + console.log(warn(`Could not clean CLIProxy logs: ${getErrorMessage(error)}`)); + return; + } + const { deleted, freedBytes, kept } = result; console.log(ok(`Deleted ${deleted} error logs, freed ${formatBytes(freedBytes)}`)); if (kept > 0) { console.log(info(`Kept ${kept} recent error logs (less than ${maxAgeDays} days old)`)); @@ -344,15 +373,30 @@ async function handleMainLogCleanup(options: { dryRun: boolean; force: boolean; }): Promise { - const targets = [ + const targets: Array<{ label: string; dir: string } & DirectorySummary> = []; + const unreadableTargets: Array<{ label: string; dir: string; error: unknown }> = []; + for (const target of [ { label: 'CCS Logs', dir: options.ccsLogsDir }, { label: 'CCS Log Archives', dir: options.ccsArchiveDir }, { label: 'CLIProxy Logs', dir: options.cliproxyLogsDir }, - ].map((target) => ({ - ...target, - fileCount: countFiles(target.dir), - size: getDirSize(target.dir), - })); + ]) { + try { + targets.push({ + ...target, + ...summarizeDirectory(target.dir), + }); + } catch (error) { + unreadableTargets.push({ ...target, error }); + } + } + + if (unreadableTargets.length > 0) { + for (const target of unreadableTargets) { + console.log(warn(`Could not read ${target.label}: ${getErrorMessage(target.error)}`)); + console.log(` ${target.dir}`); + } + return; + } const activeTargets = targets.filter((target) => target.fileCount > 0); if (activeTargets.length === 0) { @@ -400,9 +444,13 @@ async function handleMainLogCleanup(options: { let deleted = 0; let freedBytes = 0; for (const target of activeTargets) { - const result = cleanDirectory(target.dir); - deleted += result.deleted; - freedBytes += result.freedBytes; + try { + const result = cleanDirectory(target.dir); + deleted += result.deleted; + freedBytes += result.freedBytes; + } catch (error) { + console.log(warn(`Could not clean ${target.label}: ${getErrorMessage(error)}`)); + } } console.log(ok(`Deleted ${deleted} files, freed ${formatBytes(freedBytes)}`)); diff --git a/tests/unit/commands/cleanup-command.test.ts b/tests/unit/commands/cleanup-command.test.ts index 1e09e680..9c1ae433 100644 --- a/tests/unit/commands/cleanup-command.test.ts +++ b/tests/unit/commands/cleanup-command.test.ts @@ -81,4 +81,94 @@ describe('cleanup command', () => { fs.rmSync(victimDir, { recursive: true, force: true }); } }); + + it('warns instead of reporting no logs when a cleanup directory cannot be read', async () => { + const archiveDir = getLogArchiveDir(); + fs.mkdirSync(archiveDir, { recursive: true }); + + const originalReaddirSync = fs.readdirSync; + const readdirSpy = spyOn(fs, 'readdirSync').mockImplementation((dirPath, options) => { + if (String(dirPath) === archiveDir) { + throw Object.assign(new Error('permission denied'), { code: 'EACCES' }); + } + + return originalReaddirSync(dirPath, options as never); + }); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + + try { + await handleCleanupCommand(['--dry-run']); + + const output = logSpy.mock.calls + .flatMap((call) => call.map((value) => String(value))) + .join('\n'); + + expect(output).toContain('Could not read CCS Log Archives'); + expect(output).toContain('permission denied'); + expect(output).not.toContain('No CCS or CLIProxy logs found.'); + } finally { + readdirSpy.mockRestore(); + logSpy.mockRestore(); + } + }); + + it('warns instead of reporting no error logs when CLIProxy logs cannot be read', async () => { + const cliproxyLogsDir = path.join(getCliproxyDir(), 'logs'); + fs.mkdirSync(cliproxyLogsDir, { recursive: true }); + + const originalReaddirSync = fs.readdirSync; + const readdirSpy = spyOn(fs, 'readdirSync').mockImplementation((dirPath, options) => { + if (String(dirPath) === cliproxyLogsDir) { + throw Object.assign(new Error('disk I/O failed'), { code: 'EIO' }); + } + + return originalReaddirSync(dirPath, options as never); + }); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + + try { + await handleCleanupCommand(['--errors']); + + const output = logSpy.mock.calls + .flatMap((call) => call.map((value) => String(value))) + .join('\n'); + + expect(output).toContain('Could not read CLIProxy logs'); + expect(output).toContain('disk I/O failed'); + expect(output).not.toContain('No error logs found.'); + } finally { + readdirSpy.mockRestore(); + logSpy.mockRestore(); + } + }); + + it('warns instead of treating CLIProxy log inspection errors as missing directories', async () => { + const cliproxyLogsDir = path.join(getCliproxyDir(), 'logs'); + fs.mkdirSync(cliproxyLogsDir, { recursive: true }); + + const originalLstatSync = fs.lstatSync; + const lstatSpy = spyOn(fs, 'lstatSync').mockImplementation((targetPath, options) => { + if (String(targetPath) === cliproxyLogsDir) { + throw Object.assign(new Error('stat failed'), { code: 'EIO' }); + } + + return originalLstatSync(targetPath, options as never); + }); + const logSpy = spyOn(console, 'log').mockImplementation(() => {}); + + try { + await handleCleanupCommand(['--errors']); + + const output = logSpy.mock.calls + .flatMap((call) => call.map((value) => String(value))) + .join('\n'); + + expect(output).toContain('Could not inspect CLIProxy logs'); + expect(output).toContain('stat failed'); + expect(output).not.toContain('No CLIProxy logs directory found.'); + } finally { + lstatSpy.mockRestore(); + logSpy.mockRestore(); + } + }); }); From 79379a25fe258025705088691f01f690040b053c Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 30 May 2026 15:44:45 -0400 Subject: [PATCH 123/137] style: format email fallback parsing changes --- src/cliproxy/quota/quota-manager.ts | 10 ++++++---- src/management/checks/image-analysis-check.ts | 5 +++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/cliproxy/quota/quota-manager.ts b/src/cliproxy/quota/quota-manager.ts index 041e2748..8574b079 100644 --- a/src/cliproxy/quota/quota-manager.ts +++ b/src/cliproxy/quota/quota-manager.ts @@ -483,8 +483,9 @@ export async function reconcileExhaustedRotationAccounts( return []; } - const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = - await import('../accounts/account-safety'); + const { pauseAccountForQuotaCooldown, restoreExpiredQuotaPauses } = await import( + '../accounts/account-safety' + ); restoreExpiredQuotaPauses(); const config = loadOrCreateUnifiedConfig(); @@ -592,8 +593,9 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise * Fix image analysis configuration issues */ export async function fixImageAnalysisConfig(): Promise { - const { updateConfig, loadOrCreateUnifiedConfig } = - await import('../../config/config-loader-facade'); + const { updateConfig, loadOrCreateUnifiedConfig } = await import( + '../../config/config-loader-facade' + ); const config = loadOrCreateUnifiedConfig(); let fixed = false; From bcc68992fad7786a54ffadff5283e343a5e73084 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 30 May 2026 15:48:18 -0400 Subject: [PATCH 124/137] fix(cliproxy): ignore stale executor exit code --- .../__tests__/executor-option-value.test.ts | 74 +++++++++++++++++++ src/cliproxy/executor/arg-parser.ts | 35 ++++++--- src/cliproxy/executor/browser-launch-setup.ts | 5 +- src/cliproxy/executor/index.ts | 22 ++++-- 4 files changed, 115 insertions(+), 21 deletions(-) diff --git a/src/cliproxy/executor/__tests__/executor-option-value.test.ts b/src/cliproxy/executor/__tests__/executor-option-value.test.ts index 52323e9c..45e1eba0 100644 --- a/src/cliproxy/executor/__tests__/executor-option-value.test.ts +++ b/src/cliproxy/executor/__tests__/executor-option-value.test.ts @@ -62,6 +62,15 @@ describe('execClaudeWithCLIProxy browser flag validation', () => { process.env.CCS_HOME = tmpHome; }); + async function waitForFile(filePath: string): Promise { + const deadline = Date.now() + 2000; + while (Date.now() < deadline) { + if (fs.existsSync(filePath)) return true; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return fs.existsSync(filePath); + } + afterEach(() => { if (originalCcsHome !== undefined) { process.env.CCS_HOME = originalCcsHome; @@ -145,4 +154,69 @@ describe('execClaudeWithCLIProxy browser flag validation', () => { errorSpy.mockRestore(); } }); + + it('does not treat a stale global exitCode as a current parse failure', async () => { + const markerPath = path.join(tmpHome, 'fake-claude-launched'); + fs.writeFileSync( + fakeClaudePath, + `#!/bin/sh\nprintf launched > ${JSON.stringify(markerPath)}\nexit 0\n`, + { mode: 0o755 } + ); + fs.chmodSync(fakeClaudePath, 0o755); + + let requestCount = 0; + const server = http.createServer((_req, res) => { + requestCount += 1; + res.writeHead(200, { 'content-type': 'application/json' }); + res.end('{"ok":true}'); + }); + + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + throw new Error('Test server did not bind to a TCP port'); + } + + const exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((() => undefined as never) as typeof process.exit); + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + try { + process.exitCode = 1; + + await execClaudeWithCLIProxy( + fakeClaudePath, + 'gemini', + [ + '--proxy-host', + '127.0.0.1', + '--proxy-port', + String(address.port), + '--proxy-auth-token', + 'SECRET_TOKEN_FOR_VALIDATION', + '--remote-only', + '--print', + 'hello', + ], + {} + ); + + expect(await waitForFile(markerPath)).toBe(true); + expect(requestCount).toBeGreaterThan(0); + expect(exitSpy).toHaveBeenCalledWith(0); + } finally { + exitSpy.mockRestore(); + logSpy.mockRestore(); + errorSpy.mockRestore(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }); }); diff --git a/src/cliproxy/executor/arg-parser.ts b/src/cliproxy/executor/arg-parser.ts index ea581f98..d1a74bc2 100644 --- a/src/cliproxy/executor/arg-parser.ts +++ b/src/cliproxy/executor/arg-parser.ts @@ -8,7 +8,9 @@ * - parseExecutorFlags() — flag extraction block (lines ~411-639 in original) * - validateFlagCombinations() — cross-flag guard block (lines ~531-585) * - * IMPORTANT: process.exit semantics are kept identical to original index.ts. + * IMPORTANT: process.exit semantics are kept identical to original index.ts, + * with explicit parseFailed/validation return state for callers that must not + * depend on ambient process.exitCode. * All console.error messages are byte-identical. */ @@ -153,6 +155,7 @@ export function filterCcsFlags(args: string[]): string[] { /** Result of parsing CCS executor flags from args. */ export interface ParsedExecutorFlags { + parseFailed?: boolean; forceAuth: boolean; pasteCallback: boolean; portForward: boolean; @@ -181,7 +184,7 @@ export interface ParsedExecutorFlags { /** * Parse all CCS executor flags from args. * - * Exits with code 1 (process.exitCode = 1 + return) on invalid flag values. + * Exits with code 1 (process.exitCode = 1 + parseFailed return) on invalid flag values. * Exits with process.exit(1) on conflicting flag combinations — identical to * the original index.ts behavior. * @@ -247,7 +250,7 @@ export function parseExecutorFlags( console.error(fail('--kiro-auth-method requires a value')); console.error(' Supported values: aws, aws-authcode, google, github, idc'); process.exitCode = 1; - // Caller must check process.exitCode = 1 and bail — matching original return behavior + // Caller must check parseFailed and bail — matching original return behavior return buildPartialFlags({ forceAuth, pasteCallback, @@ -272,6 +275,7 @@ export function parseExecutorFlags( gitlabBaseUrl: undefined, extendedContextOverride: undefined, thinkingParse: parseThinkingOverride(args), + parseFailed: true, }); } const normalized = rawMethod.trim().toLowerCase(); @@ -303,6 +307,7 @@ export function parseExecutorFlags( gitlabBaseUrl: undefined, extendedContextOverride: undefined, thinkingParse: parseThinkingOverride(args), + parseFailed: true, }); } kiroAuthMethod = normalizeKiroAuthMethod(normalized); @@ -339,6 +344,7 @@ export function parseExecutorFlags( gitlabBaseUrl: undefined, extendedContextOverride: undefined, thinkingParse: parseThinkingOverride(args), + parseFailed: true, }); } @@ -373,6 +379,7 @@ export function parseExecutorFlags( gitlabBaseUrl: undefined, extendedContextOverride: undefined, thinkingParse: parseThinkingOverride(args), + parseFailed: true, }); } @@ -408,6 +415,7 @@ export function parseExecutorFlags( gitlabBaseUrl: undefined, extendedContextOverride: undefined, thinkingParse: parseThinkingOverride(args), + parseFailed: true, }); } const normalized = rawFlow.trim().toLowerCase(); @@ -439,6 +447,7 @@ export function parseExecutorFlags( gitlabBaseUrl: undefined, extendedContextOverride: undefined, thinkingParse: parseThinkingOverride(args), + parseFailed: true, }); } kiroIDCFlow = normalizeKiroIDCFlow(normalized); @@ -475,6 +484,7 @@ export function parseExecutorFlags( gitlabBaseUrl: undefined, extendedContextOverride: undefined, thinkingParse: parseThinkingOverride(args), + parseFailed: true, }); } @@ -538,6 +548,7 @@ export function parseExecutorFlags( gitlabBaseUrl, extendedContextOverride, thinkingParse, + parseFailed: false, }; } @@ -550,8 +561,8 @@ function buildPartialFlags(fields: ParsedExecutorFlags): ParsedExecutorFlags { /** * Validate flag combinations that are mutually exclusive or provider-scoped. - * Calls process.exit(1) on any violation — identical to original index.ts. - * Call AFTER parseExecutorFlags() and only if process.exitCode is still 0. + * Sets process.exitCode=1 and returns false on any violation. + * Call AFTER parseExecutorFlags() and only if parseFailed is false. * * @param parsed Result of parseExecutorFlags() * @param context Provider context (provider string + compositeProviders list) @@ -561,7 +572,7 @@ export function validateFlagCombinations( parsed: ParsedExecutorFlags, context: { provider: string; compositeProviders: string[] }, args: string[] -): void { +): boolean { const { provider, compositeProviders } = context; const { kiroAuthMethod, @@ -575,7 +586,7 @@ export function validateFlagCombinations( if (kiroAuthMethod && provider !== 'kiro' && !compositeProviders.includes('kiro')) { console.error(fail('--kiro-auth-method is only valid for ccs kiro')); process.exitCode = 1; - return; + return false; } if ( @@ -589,7 +600,7 @@ export function validateFlagCombinations( ) ); process.exitCode = 1; - return; + return false; } if (kiroAuthMethod === 'idc' && !kiroIDCStartUrl) { @@ -598,7 +609,7 @@ export function validateFlagCombinations( ' Example: ccs kiro --auth --kiro-auth-method idc --kiro-idc-start-url https://d-xxx.awsapps.com/start' ); process.exitCode = 1; - return; + return false; } if ( @@ -612,13 +623,15 @@ export function validateFlagCombinations( ) ); process.exitCode = 1; - return; + return false; } if ((gitlabTokenLogin || gitlabBaseUrl) && provider !== 'gitlab') { const flagName = gitlabTokenLogin ? getGitLabTokenLoginFlagName(args) : '--gitlab-url'; console.error(fail(`${flagName} is only valid for ccs gitlab`)); process.exitCode = 1; - return; + return false; } + + return true; } diff --git a/src/cliproxy/executor/browser-launch-setup.ts b/src/cliproxy/executor/browser-launch-setup.ts index 89a8d0ef..c33f3bdc 100644 --- a/src/cliproxy/executor/browser-launch-setup.ts +++ b/src/cliproxy/executor/browser-launch-setup.ts @@ -41,6 +41,7 @@ export interface BrowserLaunchSetupResult { export function resolveBrowserLaunchFlags(argsWithoutProxy: string[]): { browserLaunchOverride: BrowserLaunchOverride | undefined; argsWithoutBrowserFlags: string[]; + parseFailed: boolean; } { let browserLaunchOverride: BrowserLaunchOverride | undefined; let argsWithoutBrowserFlags = argsWithoutProxy; @@ -52,7 +53,7 @@ export function resolveBrowserLaunchFlags(argsWithoutProxy: string[]): { console.error(fail((error as Error).message)); process.exitCode = 1; process.exit(1); - return { browserLaunchOverride: undefined, argsWithoutBrowserFlags }; + return { browserLaunchOverride: undefined, argsWithoutBrowserFlags, parseFailed: true }; } const browserConfig = getBrowserConfig(); @@ -72,7 +73,7 @@ export function resolveBrowserLaunchFlags(argsWithoutProxy: string[]): { console.error(warn(blockedBrowserOverrideWarning)); } - return { browserLaunchOverride, argsWithoutBrowserFlags }; + return { browserLaunchOverride, argsWithoutBrowserFlags, parseFailed: false }; } /** diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index f72408be..0a5ee7fa 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -137,10 +137,12 @@ export async function execClaudeWithCLIProxy( log, }); - const { browserLaunchOverride, argsWithoutBrowserFlags } = resolveBrowserLaunchFlags( - proxyResolution.argsWithoutProxy - ); - if (process.exitCode === 1) return; + const { + browserLaunchOverride, + argsWithoutBrowserFlags, + parseFailed: browserLaunchParseFailed, + } = resolveBrowserLaunchFlags(proxyResolution.argsWithoutProxy); + if (browserLaunchParseFailed) return; const { proxyConfig, useRemoteProxy, localBackend, binaryPath, argsWithoutProxy } = await resolveExecutorProxy(proxyResolution, { @@ -168,11 +170,15 @@ export async function execClaudeWithCLIProxy( compositeProviders, unifiedConfig, }); - if (process.exitCode === 1) return; + if (parsedFlags.parseFailed) return; - // Validate cross-flag combinations (exits with code 1 on violation) - validateFlagCombinations(parsedFlags, { provider, compositeProviders }, argsWithoutProxy); - if (process.exitCode === 1) return; + // Validate cross-flag combinations (reports failure without relying on ambient exitCode) + const flagCombinationsValid = validateFlagCombinations( + parsedFlags, + { provider, compositeProviders }, + argsWithoutProxy + ); + if (!flagCombinationsValid) return; const { forceConfig, From 878775f5b63e0f5debc563abd2d5d888a5488b80 Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sat, 30 May 2026 22:17:21 +0200 Subject: [PATCH 125/137] feat(pricing): support per-service-tier rates; wire Opus fast mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend `ModelPricing` with an optional `serviceTiers` map keyed by Anthropic's `service_tier` request parameter. `getModelPricing(model, { serviceTier })` returns the matching tier rates when present and transparently falls through to base rates otherwise — existing call sites keep current behavior. Registry entries added (per Anthropic Fast mode pricing docs): - claude-opus-4-8 fast: $10/$50 input/output (2x premium) - claude-opus-4-7 fast: $30/$150 (6x premium) - claude-opus-4-6 fast: $30/$150 (6x premium) Cache rates derived from the documented Prompt caching multipliers (1.25x for 5-min cache write, 0.1x for cache read). Note: tier tracking through CliproxyRequestDetail is not wired yet — that's a follow-up so usage transformers can pass `serviceTier` when Anthropic returns it on the response. Schema is in place; integration can land independently. Also adds a defensive `claude-opus-4-8-20260530` pricing test to exercise `stripDateSuffix` even though Anthropic no longer issues date-stamped IDs for the 4.6+ generation (addresses presto-review suggestion #1). Built [OnSteroids](https://onsteroids.ai) --- src/web-server/model-pricing.ts | 67 +++++++++++++++++++++++++++++--- tests/unit/model-pricing.test.ts | 51 ++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 5 deletions(-) diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index 5adc0508..80d7bfb3 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -17,13 +17,23 @@ import { // TYPE DEFINITIONS // ============================================================================ -export interface ModelPricing { +export interface PricingRates { inputPerMillion: number; outputPerMillion: number; cacheCreationPerMillion: number; cacheReadPerMillion: number; } +export interface ModelPricing extends PricingRates { + /** + * Optional per-service-tier rate overrides keyed by Anthropic's + * `service_tier` request parameter (e.g. `'fast'`). When the lookup is + * called with a known tier, these rates replace the base ones; otherwise + * the base rates apply. + */ + serviceTiers?: Record; +} + export interface TokenUsage { inputTokens: number; outputTokens: number; @@ -31,7 +41,14 @@ export interface TokenUsage { cacheReadTokens: number; } -export type PricingLookupOptions = ModelsDevPricingLookupOptions; +export interface PricingLookupOptions extends ModelsDevPricingLookupOptions { + /** + * Anthropic `service_tier` (e.g. `'fast'`). When set and the resolved model + * has matching `serviceTiers` rates, those are returned instead of the + * base rates. Unknown tiers transparently fall through to base. + */ + serviceTier?: string; +} // ============================================================================ // USER-EDITABLE PRICING TABLE @@ -223,12 +240,20 @@ const PRICING_REGISTRY: Record = { cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, }, - // Claude 4.6 Opus ($5/$25) + // Claude 4.6 Opus ($5/$25) — fast mode ($30/$150, 6x premium per Anthropic docs) 'claude-opus-4-6': { inputPerMillion: 5.0, outputPerMillion: 25.0, cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, + serviceTiers: { + fast: { + inputPerMillion: 30.0, + outputPerMillion: 150.0, + cacheCreationPerMillion: 37.5, + cacheReadPerMillion: 3.0, + }, + }, }, 'claude-opus-4-6-thinking': { inputPerMillion: 5.0, @@ -236,19 +261,35 @@ const PRICING_REGISTRY: Record = { cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, }, - // Claude 4.7 Opus ($5/$25) + // Claude 4.7 Opus ($5/$25) — fast mode ($30/$150, 6x premium per Anthropic docs) 'claude-opus-4-7': { inputPerMillion: 5.0, outputPerMillion: 25.0, cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, + serviceTiers: { + fast: { + inputPerMillion: 30.0, + outputPerMillion: 150.0, + cacheCreationPerMillion: 37.5, // 30 * 1.25 (5m cache write multiplier) + cacheReadPerMillion: 3.0, // 30 * 0.1 (cache read multiplier) + }, + }, }, - // Claude 4.8 Opus ($5/$25) + // Claude 4.8 Opus ($5/$25) — fast mode ($10/$50, 2x premium per Anthropic docs) 'claude-opus-4-8': { inputPerMillion: 5.0, outputPerMillion: 25.0, cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, + serviceTiers: { + fast: { + inputPerMillion: 10.0, + outputPerMillion: 50.0, + cacheCreationPerMillion: 12.5, // 10 * 1.25 (5m cache write multiplier) + cacheReadPerMillion: 1.0, // 10 * 0.1 (cache read multiplier) + }, + }, }, // --------------------------------------------------------------------------- @@ -893,12 +934,28 @@ function hasProviderContext(model: string, options: PricingLookupOptions): boole return Boolean(options.provider || /^[^/]+\//.test(model.trim())); } +/** + * Apply per-service-tier rates if the resolved model declares them and the + * caller requested a matching tier. Unknown tiers transparently fall through + * to the base rates so existing callers stay unaffected. + */ +function applyServiceTier(pricing: ModelPricing, tier: string | undefined): ModelPricing { + if (!tier) return pricing; + const tierRates = pricing.serviceTiers?.[tier]; + if (!tierRates) return pricing; + return { ...tierRates, serviceTiers: pricing.serviceTiers }; +} + /** * Get pricing for a model with narrow fuzzy matching fallback. * Unknown future model families should fall back instead of inheriting the * first known family tier that happens to share a prefix. */ export function getModelPricing(model: string, options: PricingLookupOptions = {}): ModelPricing { + return applyServiceTier(resolveBasePricing(model, options), options.serviceTier); +} + +function resolveBasePricing(model: string, options: PricingLookupOptions): ModelPricing { if (hasProviderContext(model, options)) { const ccsOverridePricing = getCcsPolicyOverridePricing(model); if (ccsOverridePricing !== undefined) { diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index 540afd3f..e5578831 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -176,6 +176,46 @@ describe('model-pricing', () => { expect(opus48.cacheReadPerMillion).toBe(0.5); }); + it('should match date-stamped Claude Opus 4.8 to correct pricing', () => { + // Anthropic stopped issuing date-stamped Opus IDs starting with the 4.6 + // generation, so this is a defensive guard for stripDateSuffix in case + // a third-party emits a synthesized dated alias. + const opus48dated = getModelPricing('claude-opus-4-8-20260530'); + expect(opus48dated.inputPerMillion).toBe(5.0); + expect(opus48dated.outputPerMillion).toBe(25.0); + }); + + it('should return fast-tier pricing for Claude Opus 4.8 when serviceTier=fast', () => { + // Anthropic's "fast mode" charges 2x for Opus 4.8 ($10/$50 vs $5/$25). + // Cache rates scale by the same standard multipliers (1.25x write, 0.1x read). + const opus48fast = getModelPricing('claude-opus-4-8', { serviceTier: 'fast' }); + expect(opus48fast.inputPerMillion).toBe(10.0); + expect(opus48fast.outputPerMillion).toBe(50.0); + expect(opus48fast.cacheCreationPerMillion).toBe(12.5); + expect(opus48fast.cacheReadPerMillion).toBe(1.0); + }); + + it('should return fast-tier pricing for Claude Opus 4.7 when serviceTier=fast', () => { + // Fast mode on 4.7 carries a 6x premium ($30/$150 per Anthropic docs). + const opus47fast = getModelPricing('claude-opus-4-7', { serviceTier: 'fast' }); + expect(opus47fast.inputPerMillion).toBe(30.0); + expect(opus47fast.outputPerMillion).toBe(150.0); + }); + + it('should fall back to standard rates when serviceTier is unknown', () => { + // Unknown tier names must not throw; revert to base pricing transparently. + const opus48 = getModelPricing('claude-opus-4-8', { serviceTier: 'enterprise-mythos' }); + expect(opus48.inputPerMillion).toBe(5.0); + expect(opus48.outputPerMillion).toBe(25.0); + }); + + it('should preserve standard rates for Opus 4.8 when serviceTier omitted', () => { + // Regression guard: existing callers must keep current behavior. + const opus48 = getModelPricing('claude-opus-4-8'); + expect(opus48.inputPerMillion).toBe(5.0); + expect(opus48.outputPerMillion).toBe(25.0); + }); + it('should not map unknown future model families onto known family pricing', () => { const fallback = getModelPricing('unknown-model-xyz'); @@ -285,6 +325,17 @@ describe('model-pricing', () => { const cost = calculateCost(usage, 'claude-opus-4-8'); expect(cost).toBe(36.75); // 5 + 25 + 6.25 + 0.5 }); + + it('should calculate fast-tier Claude Opus 4.8 cost (2x premium)', () => { + const usage: TokenUsage = { + inputTokens: 1_000_000, + outputTokens: 1_000_000, + cacheCreationTokens: 1_000_000, + cacheReadTokens: 1_000_000, + }; + const cost = calculateCost(usage, 'claude-opus-4-8', { serviceTier: 'fast' }); + expect(cost).toBe(73.5); // 10 + 50 + 12.5 + 1.0 + }); }); describe('getKnownModels', () => { From c8e0de6b02d1639495cd7fc153f6f257856bd587 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 30 May 2026 20:24:52 +0000 Subject: [PATCH 126/137] chore(release): 8.1.4-dev.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 84b1035f..d7e51c05 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.1.4-dev.4", + "version": "8.1.4-dev.5", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 5157a922a5ed0461381741d8a7b624c2dcd6f71d Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sat, 30 May 2026 23:01:58 +0200 Subject: [PATCH 127/137] test(accounts): align stale free-tier expectations with current behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 72ea1fc9 ("fix(accounts): simplify codex free tier badges") split free codex accounts out of the `personal` audience into a distinct `free` audience and dropped the `'Personal · '` prefix on inlineLabel, but `account-visual-groups.test.ts` was not updated and has been failing in CI ever since. Update expectations to match the shipped behavior: - `audience` for `-free` tokens is `'free'`, not `'personal'` - `inlineLabel` for a bare free account is `'Free'`, not `'Personal · Free'` - `compactDetailLabel` is `null` when no extra parts are present - Sort order: business -> personal -> free -> unknown Drive-by fix unrelated to the Opus 4.8 work in this PR but caught because these tests started failing on every push to the branch. Built [OnSteroids](https://onsteroids.ai) --- ui/tests/unit/ui/lib/account-visual-groups.test.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/ui/tests/unit/ui/lib/account-visual-groups.test.ts b/ui/tests/unit/ui/lib/account-visual-groups.test.ts index cba2b9c2..eae7c8da 100644 --- a/ui/tests/unit/ui/lib/account-visual-groups.test.ts +++ b/ui/tests/unit/ui/lib/account-visual-groups.test.ts @@ -29,17 +29,14 @@ describe('buildAccountVisualGroups', () => { ]); expect(groups).toHaveLength(1); - expect(groups[0]?.variants?.map((variant) => variant.audience)).toEqual([ - 'business', - 'personal', - ]); + expect(groups[0]?.variants?.map((variant) => variant.audience)).toEqual(['business', 'free']); expect(groups[0]?.variants?.map((variant) => variant.inlineLabel)).toEqual([ 'Business · Workspace 04a0f049', - 'Personal · Free', + 'Free', ]); expect(groups[0]?.variants?.map((variant) => variant.compactDetailLabel)).toEqual([ '04a0f049', - 'Free', + null, ]); expect(groups[0]?.memberIds).toEqual([ 'kaidu.kd@gmail.com#04a0f049-team', @@ -47,7 +44,7 @@ describe('buildAccountVisualGroups', () => { ]); }); - it('keeps multiple personal codex plans distinct inside the same grouped card', () => { + it('keeps personal and free codex plans distinct inside the same grouped card', () => { const groups = buildAccountVisualGroups([ makeAccount({ id: 'kaidu.kd@gmail.com#plus', @@ -61,8 +58,8 @@ describe('buildAccountVisualGroups', () => { expect(groups).toHaveLength(1); expect(groups[0]?.variants?.map((variant) => variant.inlineLabel)).toEqual([ - 'Personal · Free', 'Personal · Plus', + 'Free', ]); }); }); From 254fc88f717dd5f649aa6ffa30b1939ac3f9078f Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sun, 31 May 2026 10:34:50 +0200 Subject: [PATCH 128/137] refactor(pricing): derive fast-tier cache rates via buildRates helper Address review feedback on the per-service-tier pricing: - Extract `buildRates(input, output)` plus named CACHE_5M_WRITE_MULTIPLIER / CACHE_READ_MULTIPLIER constants so fast-tier cache rates are derived from Anthropic's documented multipliers instead of hand-computed numbers. This removes the inconsistency where Opus 4.6 fast-tier lacked the explanatory inline comments that 4.7/4.8 carried (presto suggestion #3). - Document in `applyServiceTier` that no production caller passes `serviceTier` yet (Anthropic's service_tier is not captured on CliproxyRequestDetail), so fast-mode usage is currently billed at the standard rate until the usage pipeline records the tier. - Add a combined date-suffix + fast-tier lookup test to prove the two resolution stages compose. Built [OnSteroids](https://onsteroids.ai) --- src/web-server/model-pricing.ts | 48 ++++++++++++++++++++------------ tests/unit/model-pricing.test.ts | 10 +++++++ 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index a37e084f..ab423c07 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -50,6 +50,26 @@ export interface PricingLookupOptions extends ModelsDevPricingLookupOptions { serviceTier?: string; } +// Anthropic prompt-caching multipliers, expressed relative to the base input +// rate (see https://platform.claude.com/docs/en/about-claude/pricing#prompt-caching). +// Keeping them as named constants avoids drift when deriving per-tier cache rates. +const CACHE_5M_WRITE_MULTIPLIER = 1.25; +const CACHE_READ_MULTIPLIER = 0.1; + +/** + * Build a full rate set from input/output rates, deriving cache rates from the + * documented Anthropic multipliers so fast-tier entries stay in sync with the + * base-rate math instead of carrying hand-computed cache numbers. + */ +function buildRates(inputPerMillion: number, outputPerMillion: number): PricingRates { + return { + inputPerMillion, + outputPerMillion, + cacheCreationPerMillion: inputPerMillion * CACHE_5M_WRITE_MULTIPLIER, + cacheReadPerMillion: inputPerMillion * CACHE_READ_MULTIPLIER, + }; +} + // ============================================================================ // USER-EDITABLE PRICING TABLE // Update rates below (per million tokens in USD) @@ -247,12 +267,7 @@ const PRICING_REGISTRY: Record = { cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, serviceTiers: { - fast: { - inputPerMillion: 30.0, - outputPerMillion: 150.0, - cacheCreationPerMillion: 37.5, - cacheReadPerMillion: 3.0, - }, + fast: buildRates(30.0, 150.0), }, }, 'claude-opus-4-6-thinking': { @@ -268,12 +283,7 @@ const PRICING_REGISTRY: Record = { cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, serviceTiers: { - fast: { - inputPerMillion: 30.0, - outputPerMillion: 150.0, - cacheCreationPerMillion: 37.5, // 30 * 1.25 (5m cache write multiplier) - cacheReadPerMillion: 3.0, // 30 * 0.1 (cache read multiplier) - }, + fast: buildRates(30.0, 150.0), }, }, // Claude 4.8 Opus ($5/$25) — fast mode ($10/$50, 2x premium per Anthropic docs) @@ -283,12 +293,7 @@ const PRICING_REGISTRY: Record = { cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, serviceTiers: { - fast: { - inputPerMillion: 10.0, - outputPerMillion: 50.0, - cacheCreationPerMillion: 12.5, // 10 * 1.25 (5m cache write multiplier) - cacheReadPerMillion: 1.0, // 10 * 0.1 (cache read multiplier) - }, + fast: buildRates(10.0, 50.0), }, }, 'claude-opus-4-7-thinking': { @@ -944,6 +949,13 @@ function hasProviderContext(model: string, options: PricingLookupOptions): boole * Apply per-service-tier rates if the resolved model declares them and the * caller requested a matching tier. Unknown tiers transparently fall through * to the base rates so existing callers stay unaffected. + * + * TODO(opus-fast-mode): No production caller currently passes `serviceTier`. + * Anthropic's `service_tier` is not yet captured on CliproxyRequestDetail, so + * usage transformers (cliproxy-usage-transformer.ts, data-aggregator.ts) bill + * fast-mode requests at the standard rate — i.e. fast Opus is under-reported by + * the tier premium ($10/$50 vs $5/$25). Wire `serviceTier` through once the + * usage pipeline records it. The schema below is ready for that integration. */ function applyServiceTier(pricing: ModelPricing, tier: string | undefined): ModelPricing { if (!tier) return pricing; diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index 59eb0523..653b5664 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -202,6 +202,16 @@ describe('model-pricing', () => { expect(opus47fast.outputPerMillion).toBe(150.0); }); + it('should apply fast-tier pricing together with date-suffix stripping', () => { + // stripDateSuffix (base lookup) and applyServiceTier run independently; + // confirm they compose for a date-stamped id requesting the fast tier. + const opus48fastDated = getModelPricing('claude-opus-4-8-20260530', { serviceTier: 'fast' }); + expect(opus48fastDated.inputPerMillion).toBe(10.0); + expect(opus48fastDated.outputPerMillion).toBe(50.0); + expect(opus48fastDated.cacheCreationPerMillion).toBe(12.5); + expect(opus48fastDated.cacheReadPerMillion).toBe(1.0); + }); + it('should fall back to standard rates when serviceTier is unknown', () => { // Unknown tier names must not throw; revert to base pricing transparently. const opus48 = getModelPricing('claude-opus-4-8', { serviceTier: 'enterprise-mythos' }); From e8057a70734e1d7556dbc53aec1739c73d0cf8cf Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sun, 31 May 2026 18:16:23 +0200 Subject: [PATCH 129/137] refactor(pricing): share Opus fast-tier rate constants Hoist the fast-mode rate sets into OPUS_46_47_FAST_RATES and OPUS_48_FAST_RATES module constants (built via buildRates) so the registry entries reference shared values instead of repeating literal buildRates(...) calls. Also wires the fast tier onto the -thinking aliases (claude-opus-4-6-thinking / -4-7-thinking) so they bill at the same premium as their base ids. Built [OnSteroids](https://onsteroids.ai) --- src/web-server/model-pricing.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index ab423c07..c8afc8e2 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -70,6 +70,13 @@ function buildRates(inputPerMillion: number, outputPerMillion: number): PricingR }; } +// Anthropic fast-mode premiums (per +// https://platform.claude.com/docs/en/about-claude/pricing#fast-mode-pricing). +// Opus 4.6 and 4.7 share the same 6x premium; 4.8 is 2x. Shared constants keep +// the registry entries in sync rather than repeating the literal rates. +const OPUS_46_47_FAST_RATES = buildRates(30.0, 150.0); +const OPUS_48_FAST_RATES = buildRates(10.0, 50.0); + // ============================================================================ // USER-EDITABLE PRICING TABLE // Update rates below (per million tokens in USD) @@ -267,7 +274,7 @@ const PRICING_REGISTRY: Record = { cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, serviceTiers: { - fast: buildRates(30.0, 150.0), + fast: OPUS_46_47_FAST_RATES, }, }, 'claude-opus-4-6-thinking': { @@ -275,6 +282,9 @@ const PRICING_REGISTRY: Record = { outputPerMillion: 25.0, cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, + serviceTiers: { + fast: OPUS_46_47_FAST_RATES, + }, }, // Claude 4.7 Opus ($5/$25) — fast mode ($30/$150, 6x premium per Anthropic docs) 'claude-opus-4-7': { @@ -283,7 +293,7 @@ const PRICING_REGISTRY: Record = { cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, serviceTiers: { - fast: buildRates(30.0, 150.0), + fast: OPUS_46_47_FAST_RATES, }, }, // Claude 4.8 Opus ($5/$25) — fast mode ($10/$50, 2x premium per Anthropic docs) @@ -293,7 +303,7 @@ const PRICING_REGISTRY: Record = { cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, serviceTiers: { - fast: buildRates(10.0, 50.0), + fast: OPUS_48_FAST_RATES, }, }, 'claude-opus-4-7-thinking': { @@ -301,6 +311,9 @@ const PRICING_REGISTRY: Record = { outputPerMillion: 25.0, cacheCreationPerMillion: 6.25, cacheReadPerMillion: 0.5, + serviceTiers: { + fast: OPUS_46_47_FAST_RATES, + }, }, // --------------------------------------------------------------------------- From 5a79b475063a3451b14e8805f9b0dc05397930cb Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sun, 31 May 2026 18:52:54 +0200 Subject: [PATCH 130/137] fix(pricing): restore opus version order; cover 4.6/4.7 fast-tier rates Address presto review on the fast-tier work: - Move the claude-opus-4-8 entry after claude-opus-4-7-thinking so the registry keeps chronological 4.6 -> 4.7 -> 4.8 ordering (the 4.8 insert had split the 4-7 / 4-7-thinking pair). - Drop the fast serviceTier from claude-opus-4-7-thinking: that id is a legacy pricing-only entry with no catalog model, so a fast premium on it is meaningless. The active 4.6-thinking variant (agy provider) keeps its fast tier. - Extend the 4.7 fast-tier test with cache-rate assertions and add an equivalent 4.6 fast-tier test so the derived buildRates math is covered for every premium-tier model. Built [OnSteroids](https://onsteroids.ai) --- src/web-server/model-pricing.ts | 15 ++++++--------- tests/unit/model-pricing.test.ts | 11 +++++++++++ 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index c8afc8e2..25127406 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -296,6 +296,12 @@ const PRICING_REGISTRY: Record = { fast: OPUS_46_47_FAST_RATES, }, }, + 'claude-opus-4-7-thinking': { + inputPerMillion: 5.0, + outputPerMillion: 25.0, + cacheCreationPerMillion: 6.25, + cacheReadPerMillion: 0.5, + }, // Claude 4.8 Opus ($5/$25) — fast mode ($10/$50, 2x premium per Anthropic docs) 'claude-opus-4-8': { inputPerMillion: 5.0, @@ -306,15 +312,6 @@ const PRICING_REGISTRY: Record = { fast: OPUS_48_FAST_RATES, }, }, - 'claude-opus-4-7-thinking': { - inputPerMillion: 5.0, - outputPerMillion: 25.0, - cacheCreationPerMillion: 6.25, - cacheReadPerMillion: 0.5, - serviceTiers: { - fast: OPUS_46_47_FAST_RATES, - }, - }, // --------------------------------------------------------------------------- // OpenAI Models - Source: better-ccusage diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index 653b5664..95b6c7f1 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -200,6 +200,17 @@ describe('model-pricing', () => { const opus47fast = getModelPricing('claude-opus-4-7', { serviceTier: 'fast' }); expect(opus47fast.inputPerMillion).toBe(30.0); expect(opus47fast.outputPerMillion).toBe(150.0); + expect(opus47fast.cacheCreationPerMillion).toBe(37.5); // 30 * 1.25 + expect(opus47fast.cacheReadPerMillion).toBe(3.0); // 30 * 0.1 + }); + + it('should return fast-tier pricing for Claude Opus 4.6 when serviceTier=fast', () => { + // Fast mode on 4.6 carries the same 6x premium as 4.7 ($30/$150). + const opus46fast = getModelPricing('claude-opus-4-6', { serviceTier: 'fast' }); + expect(opus46fast.inputPerMillion).toBe(30.0); + expect(opus46fast.outputPerMillion).toBe(150.0); + expect(opus46fast.cacheCreationPerMillion).toBe(37.5); // 30 * 1.25 + expect(opus46fast.cacheReadPerMillion).toBe(3.0); // 30 * 0.1 }); it('should apply fast-tier pricing together with date-suffix stripping', () => { From 1fc595f10cda9c13fa0afd38c98c9fec1b869ae5 Mon Sep 17 00:00:00 2001 From: Sergey Galuza Date: Sun, 31 May 2026 20:23:14 +0200 Subject: [PATCH 131/137] docs(pricing): document legacy 4-7-thinking entry; cover tier metadata Final review polish: - Add an inline comment on the claude-opus-4-7-thinking registry entry explaining it is a pricing-only id kept for historical analytics data (no catalog model after Opus 4.7 moved to adaptive thinking levels), and why it carries no fast tier. - Add a test asserting applyServiceTier preserves the serviceTiers map on its result, guarding against a future simplification dropping it. Built [OnSteroids](https://onsteroids.ai) --- src/web-server/model-pricing.ts | 4 ++++ tests/unit/model-pricing.test.ts | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index 25127406..7c01c32e 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -296,6 +296,10 @@ const PRICING_REGISTRY: Record = { fast: OPUS_46_47_FAST_RATES, }, }, + // Legacy pricing-only entry for historical analytics data; this id has no + // catalog model (Opus 4.7 moved to adaptive thinking levels in 84dc4e24, so + // there is no separate -thinking variant). Kept so older usage records still + // resolve to the correct rate. No fast tier: the id is never requested live. 'claude-opus-4-7-thinking': { inputPerMillion: 5.0, outputPerMillion: 25.0, diff --git a/tests/unit/model-pricing.test.ts b/tests/unit/model-pricing.test.ts index 95b6c7f1..16652330 100644 --- a/tests/unit/model-pricing.test.ts +++ b/tests/unit/model-pricing.test.ts @@ -223,6 +223,14 @@ describe('model-pricing', () => { expect(opus48fastDated.cacheReadPerMillion).toBe(1.0); }); + it('should preserve serviceTiers metadata when applying tier rates', () => { + // applyServiceTier must keep the serviceTiers map on the returned object + // so callers can still inspect available tiers after rate substitution. + const opus48fast = getModelPricing('claude-opus-4-8', { serviceTier: 'fast' }); + expect(opus48fast.serviceTiers).toBeDefined(); + expect(opus48fast.serviceTiers?.fast).toBeDefined(); + }); + it('should fall back to standard rates when serviceTier is unknown', () => { // Unknown tier names must not throw; revert to base pricing transparently. const opus48 = getModelPricing('claude-opus-4-8', { serviceTier: 'enterprise-mythos' }); From 7dba36fb03d56da03e0abff152d7a582eb4f10fa Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 2 Jun 2026 06:38:54 -0400 Subject: [PATCH 132/137] fix(ci): add always-reporting CI Gate so fork PRs stop deadlocking The validate/build/test/compose-parity jobs are gated to trusted author associations so untrusted fork code never runs on the self-hosted runners. But a job skipped via a job-level if reports no status, and branch protection requires the job names typecheck/lint/format/build/test directly, so fork PRs hang on 'Expected - waiting for status to be reported' and can never merge. Add a single CI Gate job (if: always(), needs the gated jobs, no checkout so it runs no third-party code) that fails only when a gated job actually failed/cancelled and passes when they succeed or skip-for-fork. Branch protection should require CI Gate instead of the individual job names. Closes #1461 --- .github/workflows/ci.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ba79cce..e4315973 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,3 +160,37 @@ jobs: - name: Assert compose parity (compose.yaml vs docker-compose.integrated.yml) run: bash tests/docker/compose-parity.sh + + # Single always-reporting status that branch protection requires. + # + # The validate/build/test jobs are gated to trusted author associations so + # untrusted fork code never executes on the self-hosted runners. A job skipped + # by a job-level `if` reports no status, so requiring those job names directly + # leaves fork PRs stuck on "Expected - waiting for status to be reported" + # forever. Requiring this gate instead keeps the contract satisfiable: + # - trusted PR: gate is green only if every gated job actually succeeded + # - fork PR: gated jobs skip, gate reports green so the PR is mergeable + # (fork code is still reviewed by a maintainer before merge) + # + # No checkout here: the gate only inspects upstream job results, so it runs no + # third-party code and is safe on the self-hosted runner. + ci-gate: + if: always() + needs: [validate, build, test] + runs-on: [self-hosted, linux, x64] + name: CI Gate + steps: + - name: Verify gated jobs did not fail + env: + VALIDATE: ${{ needs.validate.result }} + BUILD: ${{ needs.build.result }} + TEST: ${{ needs.test.result }} + run: | + echo "validate=$VALIDATE build=$BUILD test=$TEST" + for result in "$VALIDATE" "$BUILD" "$TEST"; do + if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then + echo "[X] A required CI job did not pass (result: $result)" + exit 1 + fi + done + echo "[OK] CI gate satisfied (success or skipped-for-fork)" From 2368d779c7e6cce8fdb7e855f1776a785b19543c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 2 Jun 2026 10:55:58 +0000 Subject: [PATCH 133/137] chore(release): 8.1.4-dev.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d7e51c05..86c7818b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.1.4-dev.5", + "version": "8.1.4-dev.6", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 28cfc175bdbc28c914c171af8b1435a12b26a487 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 2 Jun 2026 06:56:09 -0400 Subject: [PATCH 134/137] test(accounts): align stale free-tier expectations with current grouping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 72ea1fc9 added a 'free' audience to AUDIENCE_ORDER in account-visual-groups.ts but left the unit test asserting the old 'personal'/'Personal · Free' labels, so the suite fails on dev. Realign the two assertions to the current source output: free-tier codex variants resolve to audience 'free', inline label 'Free', and null compact detail label. --- ui/tests/unit/ui/lib/account-visual-groups.test.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/ui/tests/unit/ui/lib/account-visual-groups.test.ts b/ui/tests/unit/ui/lib/account-visual-groups.test.ts index cba2b9c2..eae7c8da 100644 --- a/ui/tests/unit/ui/lib/account-visual-groups.test.ts +++ b/ui/tests/unit/ui/lib/account-visual-groups.test.ts @@ -29,17 +29,14 @@ describe('buildAccountVisualGroups', () => { ]); expect(groups).toHaveLength(1); - expect(groups[0]?.variants?.map((variant) => variant.audience)).toEqual([ - 'business', - 'personal', - ]); + expect(groups[0]?.variants?.map((variant) => variant.audience)).toEqual(['business', 'free']); expect(groups[0]?.variants?.map((variant) => variant.inlineLabel)).toEqual([ 'Business · Workspace 04a0f049', - 'Personal · Free', + 'Free', ]); expect(groups[0]?.variants?.map((variant) => variant.compactDetailLabel)).toEqual([ '04a0f049', - 'Free', + null, ]); expect(groups[0]?.memberIds).toEqual([ 'kaidu.kd@gmail.com#04a0f049-team', @@ -47,7 +44,7 @@ describe('buildAccountVisualGroups', () => { ]); }); - it('keeps multiple personal codex plans distinct inside the same grouped card', () => { + it('keeps personal and free codex plans distinct inside the same grouped card', () => { const groups = buildAccountVisualGroups([ makeAccount({ id: 'kaidu.kd@gmail.com#plus', @@ -61,8 +58,8 @@ describe('buildAccountVisualGroups', () => { expect(groups).toHaveLength(1); expect(groups[0]?.variants?.map((variant) => variant.inlineLabel)).toEqual([ - 'Personal · Free', 'Personal · Plus', + 'Free', ]); }); }); From 288ee71c826539bbc55a444b90f5cd468a52b4b0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 2 Jun 2026 14:50:45 +0000 Subject: [PATCH 135/137] chore(release): 8.1.4-dev.7 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 86c7818b..b92d9b34 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.1.4-dev.6", + "version": "8.1.4-dev.7", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 8282ff68a1667efba68e23400eb7e7ec3f1af9bb Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 3 Jun 2026 11:08:41 -0400 Subject: [PATCH 136/137] fix(proxy): support opt-in OpenAI reasoning shaping --- src/proxy/profile-router.ts | 12 +- src/proxy/server/messages-route.ts | 20 ++- .../integration/proxy/request-routing.test.ts | 122 ++++++++++++++++++ tests/unit/proxy/profile-router.test.ts | 14 ++ 4 files changed, 158 insertions(+), 10 deletions(-) diff --git a/src/proxy/profile-router.ts b/src/proxy/profile-router.ts index 9fe6b092..ead4efc2 100644 --- a/src/proxy/profile-router.ts +++ b/src/proxy/profile-router.ts @@ -11,6 +11,7 @@ export interface OpenAICompatProfileConfig { apiKey: string; provider: DroidProvider; insecure?: boolean; + forceOpenAIReasoningModel?: boolean; model?: string; opusModel?: string; sonnetModel?: string; @@ -28,12 +29,18 @@ export interface OpenAICompatProfileEnv { ANTHROPIC_SMALL_FAST_MODEL?: string; CCS_DROID_PROVIDER?: string; CCS_OPENAI_PROXY_INSECURE?: string; + CCS_OPENAI_REASONING_MODEL?: string; } export function isOpenAICompatProvider(provider: DroidProvider | null): provider is DroidProvider { return provider === 'openai' || provider === 'generic-chat-completion-api'; } +function isTruthyEnv(value: string | undefined): boolean { + const normalized = value?.trim().toLowerCase(); + return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'; +} + export function resolveOpenAICompatProfileConfig( profileName: string, settingsPath: string, @@ -63,9 +70,8 @@ export function resolveOpenAICompatProfileConfig( baseUrl, apiKey, provider, - insecure: - env.CCS_OPENAI_PROXY_INSECURE === '1' || - env.CCS_OPENAI_PROXY_INSECURE?.toLowerCase() === 'true', + insecure: isTruthyEnv(env.CCS_OPENAI_PROXY_INSECURE), + forceOpenAIReasoningModel: isTruthyEnv(env.CCS_OPENAI_REASONING_MODEL), model: env.ANTHROPIC_MODEL?.trim() || undefined, opusModel: env.ANTHROPIC_DEFAULT_OPUS_MODEL?.trim() || undefined, sonnetModel: env.ANTHROPIC_DEFAULT_SONNET_MODEL?.trim() || undefined, diff --git a/src/proxy/server/messages-route.ts b/src/proxy/server/messages-route.ts index f33ff24f..cf79b027 100644 --- a/src/proxy/server/messages-route.ts +++ b/src/proxy/server/messages-route.ts @@ -31,15 +31,21 @@ function buildUpstreamHeaders(profile: OpenAICompatProfileConfig): Record { expect(body.reasoning_effort).toBeUndefined(); expect(body.tools?.length).toBe(1); }); + + it('keeps generic opaque model payloads unchanged unless reasoning shaping is opted in', async () => { + const hits: string[] = []; + const bodies: Array<{ label: string; body: unknown }> = []; + const upstreamPort = await startMockUpstream('gateway', hits, bodies); + + const settingsPath = writeSettings('gateway', { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${upstreamPort}`, + ANTHROPIC_AUTH_TOKEN: 'gateway_token', + ANTHROPIC_MODEL: 'b3f9a2c7e8d14f60', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + + fs.writeFileSync( + path.join(tempDir, '.ccs', 'config.json'), + JSON.stringify({ profiles: { gateway: settingsPath } }, null, 2), + 'utf8' + ); + + const profile: OpenAICompatProfileConfig = { + profileName: 'gateway', + settingsPath, + baseUrl: `http://127.0.0.1:${upstreamPort}`, + apiKey: 'gateway_token', + provider: 'generic-chat-completion-api', + model: 'b3f9a2c7e8d14f60', + }; + proxyServer = startOpenAICompatProxyServer({ + profile, + port: 0, + authToken: 'test-proxy-token', + }); + proxyPort = await waitForServerListening(proxyServer); + + const response = await requestProxy({ + model: 'b3f9a2c7e8d14f60', + max_tokens: 1024, + metadata: { trace: 'abc' }, + messages: [{ role: 'user', content: 'stay compatible' }], + }); + + expect(response.status).toBe(200); + expect(hits).toEqual(['gateway']); + + const body = bodies[0]?.body as { + max_tokens?: number; + max_completion_tokens?: number; + metadata?: unknown; + }; + expect(body.max_tokens).toBe(1024); + expect(body.max_completion_tokens).toBeUndefined(); + expect(body.metadata).toEqual({ trace: 'abc' }); + }); + + it('shapes generic opaque model payloads when reasoning shaping is opted in', async () => { + const hits: string[] = []; + const bodies: Array<{ label: string; body: unknown }> = []; + const upstreamPort = await startMockUpstream('gateway', hits, bodies); + + const settingsPath = writeSettings('gateway', { + ANTHROPIC_BASE_URL: `http://127.0.0.1:${upstreamPort}`, + ANTHROPIC_AUTH_TOKEN: 'gateway_token', + ANTHROPIC_MODEL: 'b3f9a2c7e8d14f60', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + CCS_OPENAI_REASONING_MODEL: '1', + }); + + fs.writeFileSync( + path.join(tempDir, '.ccs', 'config.json'), + JSON.stringify({ profiles: { gateway: settingsPath } }, null, 2), + 'utf8' + ); + + const profile: OpenAICompatProfileConfig = { + profileName: 'gateway', + settingsPath, + baseUrl: `http://127.0.0.1:${upstreamPort}`, + apiKey: 'gateway_token', + provider: 'generic-chat-completion-api', + forceOpenAIReasoningModel: true, + model: 'b3f9a2c7e8d14f60', + }; + proxyServer = startOpenAICompatProxyServer({ + profile, + port: 0, + authToken: 'test-proxy-token', + }); + proxyPort = await waitForServerListening(proxyServer); + + const response = await requestProxy({ + model: 'b3f9a2c7e8d14f60', + thinking: { type: 'adaptive' }, + output_config: { effort: 'max' }, + max_tokens: 1024, + metadata: { trace: 'abc' }, + tools: [{ name: 'search', description: 'Search docs', input_schema: { type: 'object' } }], + messages: [{ role: 'user', content: 'think with tools' }], + }); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + content: [{ type: 'text', text: 'Reply from gateway' }], + }); + expect(hits).toEqual(['gateway']); + + const body = bodies[0]?.body as { + max_tokens?: number; + max_completion_tokens?: number; + metadata?: unknown; + reasoning_effort?: string; + tools?: unknown[]; + }; + expect(body).toMatchObject({ + model: 'b3f9a2c7e8d14f60', + max_completion_tokens: 1024, + tool_choice: 'auto', + }); + expect(body.max_tokens).toBeUndefined(); + expect(body.metadata).toBeUndefined(); + expect(body.reasoning_effort).toBeUndefined(); + expect(body.tools?.length).toBe(1); + }); }); diff --git a/tests/unit/proxy/profile-router.test.ts b/tests/unit/proxy/profile-router.test.ts index 18f0d398..3608c448 100644 --- a/tests/unit/proxy/profile-router.test.ts +++ b/tests/unit/proxy/profile-router.test.ts @@ -28,6 +28,20 @@ describe('resolveOpenAICompatProfileConfig', () => { expect(result?.insecure).toBe(true); }); + it('supports opt-in reasoning payload shaping for opaque OpenAI-compatible model IDs', () => { + const result = resolveOpenAICompatProfileConfig('gateway', '/tmp/gateway.settings.json', { + ANTHROPIC_BASE_URL: 'https://gateway.example.com/v1', + ANTHROPIC_AUTH_TOKEN: 'gateway-token', + ANTHROPIC_MODEL: 'b3f9a2c7e8d14f60', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + CCS_OPENAI_REASONING_MODEL: 'yes', + }); + + expect(result).not.toBeNull(); + expect(result?.provider).toBe('generic-chat-completion-api'); + expect(result?.forceOpenAIReasoningModel).toBe(true); + }); + it('ignores Anthropic-compatible profiles', () => { const result = resolveOpenAICompatProfileConfig('glm', '/tmp/glm.settings.json', { ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic', From f5d84dcde167d1b6d9bf3c9d90a215f565a6c482 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 3 Jun 2026 15:20:04 +0000 Subject: [PATCH 137/137] chore(release): 8.1.4-dev.8 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b92d9b34..39289e0c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.1.4-dev.7", + "version": "8.1.4-dev.8", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli",