From 14cbe8fb671c8247adeeeedeba02044e85c2865a Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 16 May 2026 13:56:10 -0400 Subject: [PATCH 1/7] fix(security): reject 127-prefixed websocket origins (#1263) * fix(security): reject 127-prefixed websocket origins * style: apply prettier formatting --- src/web-server/middleware/auth-middleware.ts | 3 ++- tests/unit/web-server/auth-middleware.test.ts | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/web-server/middleware/auth-middleware.ts b/src/web-server/middleware/auth-middleware.ts index 39228916..204b4c97 100644 --- a/src/web-server/middleware/auth-middleware.ts +++ b/src/web-server/middleware/auth-middleware.ts @@ -9,6 +9,7 @@ import session from 'express-session'; import rateLimit from 'express-rate-limit'; import crypto from 'crypto'; +import * as net from 'net'; import fs from 'fs'; import path from 'path'; import { @@ -161,7 +162,7 @@ function isLoopbackHostname(value: string | undefined): boolean { return ( normalized === 'localhost' || normalized.endsWith('.localhost') || - isLoopbackRemoteAddress(normalized) + (net.isIP(normalized) !== 0 && isLoopbackRemoteAddress(normalized)) ); } diff --git a/tests/unit/web-server/auth-middleware.test.ts b/tests/unit/web-server/auth-middleware.test.ts index a252cda2..fabe6583 100644 --- a/tests/unit/web-server/auth-middleware.test.ts +++ b/tests/unit/web-server/auth-middleware.test.ts @@ -239,6 +239,18 @@ describe('Dashboard Auth', () => { expect(isDashboardWebSocketUpgradeAllowed(request)).toBe(true); }); + it('blocks 127-prefixed DNS names from loopback websocket origin aliases', () => { + process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false'; + const request = makeUpgradeRequest('127.0.0.1', false, { + host: 'localhost:3001', + origin: 'http://127.evil.example.test:3001', + }); + + expect(isDashboardWebSocketOriginAllowed(request)).toBe(false); + expect(isDashboardWebSocketUpgradeAllowed(request)).toBe(false); + expect(getDashboardWebSocketRejectionStatus(request)).toBe(403); + }); + it('blocks cross-site websocket origins even with an authenticated session', () => { process.env.CCS_DASHBOARD_AUTH_ENABLED = 'true'; const request = makeUpgradeRequest('127.0.0.1', true, { From 2115742cafa9497e5a2eaa1564b5b3d0dad7a3ae Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 16 May 2026 13:58:52 -0400 Subject: [PATCH 2/7] fix(websearch): avoid shell for legacy CLI fallbacks (#1267) * fix(websearch): avoid shell for legacy CLI fallbacks * style: apply prettier formatting --- lib/hooks/websearch-transformer.cjs | 15 ++++++++++++--- tests/unit/hooks/websearch-transformer.test.ts | 9 +++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/lib/hooks/websearch-transformer.cjs b/lib/hooks/websearch-transformer.cjs index 6da24c3e..eb198cba 100644 --- a/lib/hooks/websearch-transformer.cjs +++ b/lib/hooks/websearch-transformer.cjs @@ -874,7 +874,10 @@ function runGeminiCommand(args, timeoutMs) { timeout: timeoutMs, maxBuffer: 1024 * 1024 * 2, stdio: ['pipe', 'pipe', 'pipe'], - shell: isWindows, + // Never route query-derived prompts through a shell. Node concatenates + // arguments for shell-backed Windows spawns, which lets shell metacharacters + // in WebSearch queries escape the intended CLI invocation. + shell: false, }); if (result.error) { @@ -929,7 +932,10 @@ function tryOpenCodeSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { timeout: timeoutSec * 1000, maxBuffer: 1024 * 1024 * 2, stdio: ['pipe', 'pipe', 'pipe'], - shell: isWindows, + // Never route query-derived prompts through a shell. Node concatenates + // arguments for shell-backed Windows spawns, which lets shell metacharacters + // in WebSearch queries escape the intended CLI invocation. + shell: false, } ); @@ -965,7 +971,10 @@ function tryGrokSearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) { timeout: timeoutSec * 1000, maxBuffer: 1024 * 1024 * 2, stdio: ['pipe', 'pipe', 'pipe'], - shell: isWindows, + // Never route query-derived prompts through a shell. Node concatenates + // arguments for shell-backed Windows spawns, which lets shell metacharacters + // in WebSearch queries escape the intended CLI invocation. + shell: false, }); if (result.error) { diff --git a/tests/unit/hooks/websearch-transformer.test.ts b/tests/unit/hooks/websearch-transformer.test.ts index 646d1530..f05f9765 100644 --- a/tests/unit/hooks/websearch-transformer.test.ts +++ b/tests/unit/hooks/websearch-transformer.test.ts @@ -131,6 +131,15 @@ function runHookWithMockedFetch(mode: 'success' | 'empty' | 'non-result' | 'fail } } +describe('websearch-transformer legacy CLI safety', () => { + it('does not enable shell execution for query-derived legacy CLI prompts', () => { + const source = readFileSync(hookPath, 'utf8'); + + expect(source).not.toContain('shell: isWindows'); + expect(source.match(/shell: false/g) || []).toHaveLength(3); + }); +}); + describe('websearch-transformer hook helpers', () => { it('parses Retry-After seconds and HTTP dates', () => { expect(hook.parseRetryAfterSeconds('2')).toBe(2); From ece43fc30e735c385098cd20350cb7b4bf9568f9 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 16 May 2026 13:58:55 -0400 Subject: [PATCH 3/7] fix(cliproxy): redact AI provider header secrets (#1268) * fix(cliproxy): redact AI provider header secrets * style: apply prettier formatting --- .../ai-provider-service-stable-id.test.ts | 86 +++++++++++++++++++ src/cliproxy/ai-providers/service.ts | 59 ++++++++++--- 2 files changed, 133 insertions(+), 12 deletions(-) diff --git a/src/cliproxy/__tests__/ai-provider-service-stable-id.test.ts b/src/cliproxy/__tests__/ai-provider-service-stable-id.test.ts index e689b6f6..375ace19 100644 --- a/src/cliproxy/__tests__/ai-provider-service-stable-id.test.ts +++ b/src/cliproxy/__tests__/ai-provider-service-stable-id.test.ts @@ -152,6 +152,92 @@ describe('ai-provider service stable ids', () => { ).toBe('sk-openrouter'); }); + it('redacts custom headers and URL userinfo in provider list views', async () => { + const { listAiProviders } = await loadAiProviderService(); + + writeCliproxyConfig(tempHome, { + 'claude-api-key': [ + { + id: 'claude-secret-route', + 'api-key': 'sk-ant-provider-secret-1234', + 'base-url': 'https://baseuser:basepass@anthropic.example/v1', + 'proxy-url': 'http://proxyuser:proxypass@example.internal:8080', + headers: { + Authorization: 'Bearer HEADER-SECRET-123456', + 'X-API-Key': 'x-api-key-secret-abcdef', + }, + }, + ], + 'openai-compatibility': [ + { + id: 'openrouter-secret-route', + name: 'openrouter', + 'base-url': 'https://routeruser:routerpass@openrouter.example/api/v1', + headers: { + Authorization: 'Bearer OPENAI-COMPAT-HEADER-SECRET', + }, + 'api-key-entries': [{ 'api-key': 'sk-openrouter-secret-9999' }], + }, + ], + }); + + const listed = await listAiProviders(); + const claudeEntry = listed.families.find((entry) => entry.id === 'claude-api-key')?.entries[0]; + const openAiEntry = listed.families.find((entry) => entry.id === 'openai-compatibility') + ?.entries[0]; + + expect(claudeEntry?.baseUrl).toBe('https://***:***@anthropic.example/v1'); + expect(claudeEntry?.proxyUrl).toBe('http://***:***@example.internal:8080/'); + expect(claudeEntry?.headers).toEqual([ + { key: 'Authorization', value: '...3456' }, + { key: 'X-API-Key', value: '...cdef' }, + ]); + expect(openAiEntry?.baseUrl).toBe('https://***:***@openrouter.example/api/v1'); + expect(openAiEntry?.headers).toEqual([{ key: 'Authorization', value: '...CRET' }]); + }); + + it('preserves stored header and URL secrets when saving unchanged redacted values', async () => { + const { listAiProviders, updateAiProviderEntry } = await loadAiProviderService(); + + writeCliproxyConfig(tempHome, { + 'claude-api-key': [ + { + id: 'claude-secret-route', + 'api-key': 'sk-ant-provider-secret-1234', + 'base-url': 'https://baseuser:basepass@anthropic.example/v1', + 'proxy-url': 'http://proxyuser:proxypass@example.internal:8080', + headers: { + Authorization: 'Bearer HEADER-SECRET-123456', + 'X-Project': 'public-routing-context', + }, + }, + ], + }); + + const listed = await listAiProviders(); + const entry = listed.families.find((family) => family.id === 'claude-api-key')?.entries[0]; + + expect(entry).toBeDefined(); + + await updateAiProviderEntry('claude-api-key', 'claude-secret-route', { + apiKey: 'sk-ant-provider-secret-1234', + baseUrl: entry?.baseUrl, + proxyUrl: entry?.proxyUrl, + headers: entry?.headers, + preserveSecrets: true, + }); + + const persisted = readCliproxyConfig(tempHome)['claude-api-key'] as Array< + Record + >; + expect(persisted[0]?.['base-url']).toBe('https://baseuser:basepass@anthropic.example/v1'); + expect(persisted[0]?.['proxy-url']).toBe('http://proxyuser:proxypass@example.internal:8080'); + expect(persisted[0]?.headers).toEqual({ + Authorization: 'Bearer HEADER-SECRET-123456', + 'X-Project': 'public-routing-context', + }); + }); + it('normalizes plain openai-compatible model rules without aliases', async () => { const { listAiProviders } = await loadAiProviderService(); diff --git a/src/cliproxy/ai-providers/service.ts b/src/cliproxy/ai-providers/service.ts index d35982f4..0bcd01ee 100644 --- a/src/cliproxy/ai-providers/service.ts +++ b/src/cliproxy/ai-providers/service.ts @@ -17,14 +17,39 @@ function maskSecret(value: string | undefined): string | undefined { return value.length > 8 ? `...${value.slice(-4)}` : '***'; } +function sanitizeUrlForView(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + if (!trimmed) return undefined; + + try { + const parsed = new URL(trimmed); + if (parsed.username) parsed.username = '***'; + if (parsed.password) parsed.password = '***'; + return parsed.toString(); + } catch { + return trimmed; + } +} + +function restoreMaskedViewValue( + value: string | undefined, + existing: string | undefined, + sanitizeForView: (value: string | undefined) => string | undefined = maskSecret +): string | undefined { + const next = value?.trim() || undefined; + if (!next || !existing) return next; + return next === sanitizeForView(existing) ? existing : next; +} + function normalizeHeaders( - headers: Array<{ key: string; value: string }> | undefined + headers: Array<{ key: string; value: string }> | undefined, + existing?: Record ): Record | undefined { if (!headers) return undefined; const normalized = headers.reduce>((acc, header) => { const key = header.key.trim(); if (!key) return acc; - acc[key] = header.value; + acc[key] = restoreMaskedViewValue(header.value, existing?.[key]) || ''; return acc; }, {}); return Object.keys(normalized).length > 0 ? normalized : undefined; @@ -33,7 +58,10 @@ function normalizeHeaders( function toHeaderPairs( headers: Record | undefined ): Array<{ key: string; value: string }> { - return Object.entries(headers || {}).map(([key, value]) => ({ key, value })); + return Object.entries(headers || {}).map(([key, value]) => ({ + key, + value: maskSecret(value) || '***', + })); } function readModelRulePart(model: unknown, key: keyof AiProviderModelAlias) { @@ -62,9 +90,9 @@ function buildApiKeyEntryView( return { id: entry.id || `${family}:${index}`, index, - label: entry.prefix?.trim() || entry['base-url']?.trim() || `Entry ${index + 1}`, - baseUrl: entry['base-url']?.trim() || undefined, - proxyUrl: entry['proxy-url']?.trim() || undefined, + label: entry.prefix?.trim() || sanitizeUrlForView(entry['base-url']) || `Entry ${index + 1}`, + baseUrl: sanitizeUrlForView(entry['base-url']), + proxyUrl: sanitizeUrlForView(entry['proxy-url']), prefix: entry.prefix?.trim() || undefined, headers: toHeaderPairs(entry.headers), excludedModels: [...(entry['excluded-models'] || [])], @@ -80,7 +108,7 @@ function buildOpenAiCompatEntryView(entry: OpenAICompatEntry, index: number): Ai index, name: entry.name, label: entry.name, - baseUrl: entry['base-url']?.trim() || undefined, + baseUrl: sanitizeUrlForView(entry['base-url']), headers: toHeaderPairs(entry.headers), excludedModels: [], models: normalizeModelAliases(entry.models), @@ -142,10 +170,14 @@ function toApiKeyEntry( return { id: existing?.id, 'api-key': nextSecret, - 'base-url': input.baseUrl?.trim() || undefined, - 'proxy-url': input.proxyUrl?.trim() || undefined, + 'base-url': restoreMaskedViewValue(input.baseUrl, existing?.['base-url'], sanitizeUrlForView), + 'proxy-url': restoreMaskedViewValue( + input.proxyUrl, + existing?.['proxy-url'], + sanitizeUrlForView + ), prefix: input.prefix?.trim() || undefined, - headers: normalizeHeaders(input.headers), + headers: normalizeHeaders(input.headers, existing?.headers), 'excluded-models': (input.excludedModels || []) .map((value) => value.trim()) .filter((value) => value.length > 0), @@ -167,8 +199,11 @@ function toOpenAiCompatEntry( return { id: existing?.id, name: input.name?.trim() || existing?.name || 'connector', - 'base-url': input.baseUrl?.trim() || existing?.['base-url'] || '', - headers: normalizeHeaders(input.headers), + 'base-url': + restoreMaskedViewValue(input.baseUrl, existing?.['base-url'], sanitizeUrlForView) || + existing?.['base-url'] || + '', + headers: normalizeHeaders(input.headers, existing?.headers), 'api-key-entries': nextApiKeys.map((apiKey) => ({ 'api-key': apiKey })), models: normalizeModelAliases(input.models), }; From db175b6807c72f902b358591e609a53764424d5d Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sat, 16 May 2026 13:58:57 -0400 Subject: [PATCH 4/7] fix(security): require localhost for Claude extension `/setup` when dashboard auth is disabled (#1270) * fix(security): restrict Claude extension setup endpoint * style: apply prettier formatting --- .../routes/claude-extension-routes.ts | 7 +++ .../claude-extension-routes.test.ts | 49 +++++++++++++++++-- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/web-server/routes/claude-extension-routes.ts b/src/web-server/routes/claude-extension-routes.ts index 76c66225..e89b22ad 100644 --- a/src/web-server/routes/claude-extension-routes.ts +++ b/src/web-server/routes/claude-extension-routes.ts @@ -26,9 +26,12 @@ import { type ClaudeExtensionActionTarget, verifyClaudeExtensionBinding, } from '../services/claude-extension-settings-service'; +import { requireLocalAccessWhenAuthDisabled } from '../middleware/auth-middleware'; const router = Router(); const VALID_HOSTS = new Set(CLAUDE_EXTENSION_HOSTS.map((host) => host.id)); +const SETUP_LOCAL_ACCESS_ERROR = + 'Claude extension setup requires localhost access when dashboard auth is disabled.'; const VALID_TARGETS = new Set(['shared', 'ide', 'all']); function getHostFromRequest(req: Request): ClaudeExtensionHost { @@ -79,6 +82,10 @@ router.get('/profiles', (_req: Request, res: Response): void => { }); router.get('/setup', async (req: Request, res: Response): Promise => { + if (!requireLocalAccessWhenAuthDisabled(req, res, SETUP_LOCAL_ACCESS_ERROR)) { + return; + } + const rawProfile = typeof req.query.profile === 'string' ? req.query.profile.trim() : ''; if (!rawProfile) { res.status(400).json({ error: 'Missing required query parameter: profile' }); diff --git a/tests/unit/web-server/claude-extension-routes.test.ts b/tests/unit/web-server/claude-extension-routes.test.ts index 753e40fd..a37372e5 100644 --- a/tests/unit/web-server/claude-extension-routes.test.ts +++ b/tests/unit/web-server/claude-extension-routes.test.ts @@ -34,9 +34,8 @@ describe('web-server claude-extension-routes', () => { ({ default: SharedManager } = await import('../../../src/management/shared-manager')); ({ createEmptyUnifiedConfig } = await import('../../../src/config/unified-config-types')); ({ saveUnifiedConfig } = await import('../../../src/config/unified-config-loader')); - ({ default: claudeExtensionRoutes } = await import( - '../../../src/web-server/routes/claude-extension-routes' - )); + ({ default: claudeExtensionRoutes } = + await import('../../../src/web-server/routes/claude-extension-routes')); const app = express(); app.use(express.json()); @@ -68,7 +67,8 @@ describe('web-server claude-extension-routes', () => { if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; else delete process.env.CCS_HOME; - if (originalClaudeConfigDir !== undefined) process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + if (originalClaudeConfigDir !== undefined) + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; else delete process.env.CLAUDE_CONFIG_DIR; }); @@ -178,6 +178,47 @@ describe('web-server claude-extension-routes', () => { expect(payload.sharedSettings.json).toContain('"env"'); }); + it('blocks non-local setup requests when dashboard auth is disabled', async () => { + const app = express(); + app.use((_req, _res, next) => { + Object.defineProperty(_req.socket, 'remoteAddress', { + configurable: true, + value: '10.0.0.25', + }); + next(); + }); + app.use('/api/claude-extension', claudeExtensionRoutes); + + const remoteServer = await new Promise((resolve, reject) => { + const instance = app.listen(0, '127.0.0.1'); + const handleError = (error: Error) => reject(error); + instance.once('error', handleError); + instance.once('listening', () => { + instance.off('error', handleError); + resolve(instance); + }); + }); + + try { + const address = remoteServer.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve remote test server port'); + } + + const response = await fetch( + `http://127.0.0.1:${address.port}/api/claude-extension/setup?profile=glm&host=vscode` + ); + expect(response.status).toBe(403); + + const payload = (await response.json()) as { error: string }; + expect(payload.error).toBe( + 'Claude extension setup requires localhost access when dashboard auth is disabled.' + ); + } finally { + await new Promise((resolve) => remoteServer.close(() => resolve())); + } + }); + it('normalizes the effective profile CLAUDE_CONFIG_DIR for extension setup', async () => { const explicitConfigDir = path.join(tempHome, '.claude-profiles', 'glm'); const glmSettingsPath = path.join(tempHome, '.ccs', 'glm.settings.json'); From cf47615e9d039a2f74878263b7e0b94644250842 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 16 May 2026 18:04:39 +0000 Subject: [PATCH 5/7] chore(release): 7.79.1-dev.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7b3ccb8c..ac69c15d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.79.1-dev.3", + "version": "7.79.1-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From d68ee37590f8de7d77c5d7e26cfbe5dd3647ae83 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sun, 17 May 2026 06:59:09 -0400 Subject: [PATCH 6/7] fix(codex): preserve custom ccsxp cliproxy base URLs Preserve valid custom model_providers.cliproxy.base_url values during ccsxp Codex provider repair while keeping local fallback repair for missing or invalid URLs.\n\nCloses #1281 --- docs/codebase-summary.md | 2 +- docs/system-architecture/target-adapters.md | 1 + src/targets/codex-cliproxy-provider-config.ts | 39 +++++++------- .../codex-cliproxy-provider-config.test.ts | 51 ++++++++++++++++-- .../targets/codex-runtime-integration.test.ts | 54 +++++++++++++++++++ 5 files changed, 123 insertions(+), 24 deletions(-) diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index afe1d761..e9fe2bb3 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -273,7 +273,7 @@ src/ ### Native Codex Runtime Target - Dedicated runtime entrypoints: `ccs-codex` and `ccsx` resolve through `src/bin/codex-runtime.ts`, while `ccsxp` resolves through `src/bin/ccsxp-runtime.ts`; all three set `CCS_INTERNAL_ENTRY_TARGET=codex` before delegating to `src/targets/target-resolver.ts`. -- Provider shortcut behavior: `ccsxp` strips user-supplied `--target` overrides and prepends `--config model_provider="cliproxy"` so it behaves like native Codex plus the CLIProxy provider recipe. The stricter CCS-managed bridge remains available explicitly through `ccs codex --target codex`. It pins `CODEX_HOME` to native `~/.codex` by default so inherited launcher state does not send history/config writes to a nonstandard Codex root; `CCSXP_CODEX_HOME` is the explicit override. On launch, CCS repairs the native `[model_providers.cliproxy]` stanza in `config.toml`, reads that provider's configured `env_key` (default `CLIPROXY_API_KEY`), and injects the effective CLIProxy auth token into that key for the child Codex process. +- Provider shortcut behavior: `ccsxp` strips user-supplied `--target` overrides and prepends `--config model_provider="cliproxy"` so it behaves like native Codex plus the CLIProxy provider recipe. The stricter CCS-managed bridge remains available explicitly through `ccs codex --target codex`. It pins `CODEX_HOME` to native `~/.codex` by default so inherited launcher state does not send history/config writes to a nonstandard Codex root; `CCSXP_CODEX_HOME` is the explicit override. On launch, CCS repairs the native `[model_providers.cliproxy]` stanza in `config.toml`, preserves a valid custom `base_url`, reads that provider's configured `env_key` (default `CLIPROXY_API_KEY`), and injects the effective CLIProxy auth token into that key for the child Codex process. - Implicit Codex launches such as `ccs --target codex` and `ccsxp` use native Codex default mode even when the CCS default profile is a Claude account. Explicit unsupported profiles such as `ccs work --target codex` still fail fast with native-vs-pool guidance. - `argv[0]` alias mapping still exists in `src/targets/target-resolver.ts` for same-binary/custom alias scenarios, but the built-in npm bins above do not depend on that map at runtime. - Metadata boundary: `src/targets/target-metadata.ts` keeps Codex runtime-only in v1, so persisted default targets remain `claude | droid`. diff --git a/docs/system-architecture/target-adapters.md b/docs/system-architecture/target-adapters.md index f3d06468..228ed888 100644 --- a/docs/system-architecture/target-adapters.md +++ b/docs/system-architecture/target-adapters.md @@ -522,6 +522,7 @@ ccsxp → injects native `model_provider="cliproxy"` override → pins CODEX_HOME to native `~/.codex` unless `CCSXP_CODEX_HOME` is set → repairs `[model_providers.cliproxy]` in the active Codex `config.toml` +→ preserves valid custom `base_url` values for remote or non-default CLIProxy endpoints → injects the effective CCS CLIProxy auth token into the provider's configured `env_key` → ignores the configured CCS default account/profile and stays in native Codex default mode ``` diff --git a/src/targets/codex-cliproxy-provider-config.ts b/src/targets/codex-cliproxy-provider-config.ts index 2944e9ba..c2d084fe 100644 --- a/src/targets/codex-cliproxy-provider-config.ts +++ b/src/targets/codex-cliproxy-provider-config.ts @@ -50,22 +50,16 @@ function asObject(value: unknown): Record | null { : null; } -function normalizeLocalProviderUrl(value: unknown): string | null { - if (typeof value !== 'string') return null; +function isValidCodexCliproxyBaseUrl(value: unknown): value is string { + if (typeof value !== 'string') return false; const trimmed = value.trim(); + if (!trimmed) return false; try { const url = new URL(trimmed); - if ( - (url.hostname === 'localhost' || url.hostname === '127.0.0.1') && - url.pathname === '/api/provider/codex' - ) { - url.hostname = '127.0.0.1'; - return url.toString().replace(/\/$/, ''); - } + return url.protocol === 'http:' || url.protocol === 'https:'; } catch { - return null; + return false; } - return null; } function resolveProviderEnvKey(provider: Record | null): string { @@ -76,14 +70,10 @@ function resolveProviderEnvKey(provider: Record | null): string return CODEX_CLIPROXY_PROVIDER_ENV_KEY; } -function isProviderReady( - provider: Record, - expectedBaseUrl: string, - envKey: string -): boolean { +function isProviderReady(provider: Record, envKey: string): boolean { return ( provider.name === CODEX_CLIPROXY_PROVIDER_NAME && - normalizeLocalProviderUrl(provider.base_url) === expectedBaseUrl && + isValidCodexCliproxyBaseUrl(provider.base_url) && provider.env_key === envKey && provider.wire_api === 'responses' && provider.requires_openai_auth === false && @@ -102,6 +92,17 @@ function buildProviderConfig(baseUrl: string, envKey: string): Record, + fallbackBaseUrl: string +): string { + const baseUrl = provider.base_url; + if (isValidCodexCliproxyBaseUrl(baseUrl)) { + return baseUrl.trim(); + } + return fallbackBaseUrl; +} + function appendProviderBlock(rawText: string, baseUrl: string): string { const prefix = rawText.trimEnd(); const providerBlock = stringifyTomlObject({ @@ -179,12 +180,12 @@ export async function ensureCodexCliproxyProviderConfig( } const envKey = resolveProviderEnvKey(currentProvider); - const providerReady = isProviderReady(currentProvider, expectedBaseUrl, envKey); + const providerReady = isProviderReady(currentProvider, envKey); if (!providerReady) { providers[CODEX_CLIPROXY_PROVIDER_ID] = { ...currentProvider, - ...buildProviderConfig(expectedBaseUrl, envKey), + ...buildProviderConfig(resolveProviderBaseUrl(currentProvider, expectedBaseUrl), envKey), }; } diff --git a/tests/unit/targets/codex-cliproxy-provider-config.test.ts b/tests/unit/targets/codex-cliproxy-provider-config.test.ts index f182ce2c..08dabe2e 100644 --- a/tests/unit/targets/codex-cliproxy-provider-config.test.ts +++ b/tests/unit/targets/codex-cliproxy-provider-config.test.ts @@ -78,19 +78,19 @@ wire_api = "responses" expect(result.changed).toBe(true); expect(result.envKey).toBe('CLIPROXY_API_KEY'); const rawText = fs.readFileSync(configPath, 'utf8'); - expect(rawText).toContain(`base_url = "${buildCodexCliproxyProviderBaseUrl(9321)}"`); + expect(rawText).toContain('base_url = "http://localhost:8317/api/provider/codex"'); expect(rawText).toContain('env_key = "CLIPROXY_API_KEY"'); expect(rawText).toContain('requires_openai_auth = false'); expect(rawText).toContain('supports_websockets = false'); }); - it('preserves a custom cliproxy provider env key while repairing other fields', async () => { + it('preserves custom cliproxy provider values while repairing other fields', async () => { fs.mkdirSync(codexHome, { recursive: true }); fs.writeFileSync( configPath, `[model_providers.cliproxy] name = "Old Name" -base_url = "http://localhost:8317/api/provider/codex" +base_url = "https://cliproxy.example.com/api/provider/codex/responses" env_key = "CCS_CUSTOM_CLIPROXY_TOKEN" wire_api = "chat" `, @@ -102,11 +102,35 @@ wire_api = "chat" expect(result.changed).toBe(true); expect(result.envKey).toBe('CCS_CUSTOM_CLIPROXY_TOKEN'); const rawText = fs.readFileSync(configPath, 'utf8'); - expect(rawText).toContain(`base_url = "${buildCodexCliproxyProviderBaseUrl(9321)}"`); + expect(rawText).toContain( + 'base_url = "https://cliproxy.example.com/api/provider/codex/responses"' + ); expect(rawText).toContain('env_key = "CCS_CUSTOM_CLIPROXY_TOKEN"'); expect(rawText).toContain('wire_api = "responses"'); }); + it('repairs invalid cliproxy provider base URLs back to the managed local default', async () => { + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync( + configPath, + `[model_providers.cliproxy] +name = "CLIProxy Codex" +base_url = "not-a-url" +env_key = "CLIPROXY_API_KEY" +wire_api = "responses" +requires_openai_auth = false +supports_websockets = false +`, + 'utf8' + ); + + const result = await ensureCodexCliproxyProviderConfig(9321, env); + + expect(result.changed).toBe(true); + const rawText = fs.readFileSync(configPath, 'utf8'); + expect(rawText).toContain(`base_url = "${buildCodexCliproxyProviderBaseUrl(9321)}"`); + }); + it('rejects invalid non-table model_providers values without appending broken TOML', async () => { fs.mkdirSync(codexHome, { recursive: true }); const rawText = 'model_providers = "legacy"\n'; @@ -137,6 +161,25 @@ supports_websockets = false expect(fs.readFileSync(configPath, 'utf8')).toBe(rawText); }); + it('leaves a ready remote provider unchanged', async () => { + fs.mkdirSync(codexHome, { recursive: true }); + const rawText = `[model_providers.cliproxy] +name = "CLIProxy Codex" +base_url = "https://cliproxy.example.com/api/provider/codex" +env_key = "CCS_REMOTE_CLIPROXY_TOKEN" +wire_api = "responses" +requires_openai_auth = false +supports_websockets = false +`; + fs.writeFileSync(configPath, rawText, 'utf8'); + + const result = await ensureCodexCliproxyProviderConfig(8317, env); + + expect(result.changed).toBe(false); + expect(result.envKey).toBe('CCS_REMOTE_CLIPROXY_TOKEN'); + expect(fs.readFileSync(configPath, 'utf8')).toBe(rawText); + }); + it('normalizes a ready native Codex tuning alias before requests reach cliproxy', async () => { fs.mkdirSync(codexHome, { recursive: true }); fs.writeFileSync( diff --git a/tests/unit/targets/codex-runtime-integration.test.ts b/tests/unit/targets/codex-runtime-integration.test.ts index 3c0fc2a5..ab9cd0d5 100644 --- a/tests/unit/targets/codex-runtime-integration.test.ts +++ b/tests/unit/targets/codex-runtime-integration.test.ts @@ -828,6 +828,60 @@ supports_websockets = false ]); }); + it('preserves a custom cliproxy provider base_url for ccsxp launches', () => { + if (process.platform === 'win32') return; + + const codexHome = path.join(tmpHome, '.codex'); + fs.mkdirSync(codexHome, { recursive: true }); + fs.writeFileSync( + path.join(codexHome, 'config.toml'), + `[model_providers.cliproxy] +name = "CLIProxy Codex" +base_url = "https://cliproxy.example.com/api/provider/codex/responses" +env_key = "CCS_REMOTE_CLIPROXY_TOKEN" +wire_api = "responses" +requires_openai_auth = false +supports_websockets = false +`, + 'utf8' + ); + + const result = runCcsxpAlias(['fix failing tests'], { + ...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, + CCS_TEST_CODEX_LOG_ENV_KEYS: 'CCS_REMOTE_CLIPROXY_TOKEN', + }); + + expect(result.status).toBe(0); + expect(readLoggedCodexCalls(codexArgsLogPath)).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, + }, + ]); + }); + it('keeps ccsxp native when the CCS default profile is a Claude account', () => { if (process.platform === 'win32') return; From 5c767d5651cd10674ab40fd9d6efe302d1e2111a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 17 May 2026 11:02:27 +0000 Subject: [PATCH 7/7] chore(release): 7.79.1-dev.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ac69c15d..6b4c7df3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.79.1-dev.4", + "version": "7.79.1-dev.5", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli",