From 8f9795bce24123786722d592d18dff8be59cc8d0 Mon Sep 17 00:00:00 2001 From: kcfang <2383020+kcfang@users.noreply.github.com> Date: Tue, 16 Jun 2026 09:57:23 +0800 Subject: [PATCH 01/56] fix(proxy): keep undici timeouts above the upstream request timeout (#1524) Sets undici headersTimeout/bodyTimeout to request_timeout+30s so the AbortController is the single authority on upstream request lifetime, preventing premature socket closes on slow self-hosted upstreams. Verified undici v5 ProxyAgent object-signature compat. --- docs/openai-compatible-providers.md | 8 ++++ src/proxy/server/messages-route.ts | 44 +++++++++++++++++++--- src/proxy/server/proxy-server.ts | 3 +- src/utils/fetch-proxy-setup.ts | 25 ++++++++++--- tests/unit/proxy/messages-route.test.ts | 49 +++++++++++++++++++++++++ 5 files changed, 116 insertions(+), 13 deletions(-) diff --git a/docs/openai-compatible-providers.md b/docs/openai-compatible-providers.md index a874d227..2456d98a 100644 --- a/docs/openai-compatible-providers.md +++ b/docs/openai-compatible-providers.md @@ -327,6 +327,14 @@ That flag is respected by both: - CCS now preserves upstream rate-limit errors and retry headers - Empty or malformed provider JSON is returned as Anthropic-style `api_error` +### Slow upstreams: `socket connection was closed unexpectedly` + +- Long-running upstreams (self-hosted LLMs with queue/prefill phases) can stay + silent for minutes before the first or next token. The proxy already allows up + to 10 minutes per request; set `CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS` in the + profile settings to raise or lower that ceiling. +- Restart the proxy after changing the setting. + ### Requests route to the wrong model/profile - Use an explicit selector such as `profile:model` diff --git a/src/proxy/server/messages-route.ts b/src/proxy/server/messages-route.ts index ab99b5e0..4a5f2f93 100644 --- a/src/proxy/server/messages-route.ts +++ b/src/proxy/server/messages-route.ts @@ -10,9 +10,16 @@ import { import { ProxySseStreamTransformer } from '../transformers/sse-stream-transformer'; import { isAnthropicPassthroughProfile, resolveOpenAIChatCompletionsUrl } from '../upstream-url'; import { createLogger } from '../../services/logging'; +import { + createGlobalFetchProxyDispatcher, + type UpstreamAgentTimeoutOptions, +} from '../../utils/fetch-proxy-setup'; import { pipeWebResponseToNode, readRawBody, writeJson } from './http-helpers'; const REQUEST_TIMEOUT_MS = 600_000; +// Keep undici's per-phase timeouts above the explicit request timeout so the +// AbortController is the single authority on when an upstream request dies. +const UPSTREAM_TIMEOUT_GRACE_MS = 30_000; const DIRECT_OPENAI_REASONING_CHAT_MODEL = /^(?:gpt-5|o[134])(?:[-.]|$)/; const logger = createLogger('proxy:openai-compat:messages'); @@ -375,7 +382,7 @@ function buildFetchInit( signal: AbortSignal, incomingHeaders: http.IncomingHttpHeaders, preserveUserAgent: boolean, - insecureDispatcher?: Dispatcher + dispatcher?: Dispatcher ): RequestInit { const init: RequestInit = { method: 'POST', @@ -384,8 +391,8 @@ function buildFetchInit( signal, }; - if (insecureDispatcher) { - (init as Record).dispatcher = insecureDispatcher; + if (dispatcher) { + (init as Record).dispatcher = dispatcher; } return init; @@ -401,6 +408,30 @@ function getRequestTimeoutMs(): number { return Number.isFinite(parsed) && parsed > 0 ? parsed : REQUEST_TIMEOUT_MS; } +/** + * undici defaults `headersTimeout`/`bodyTimeout` to 300s, which silently + * undercuts {@link REQUEST_TIMEOUT_MS} (600s) and the + * `CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS` override: slow upstreams (self-hosted + * LLMs with long queue + prefill phases) get their socket closed at 300s with + * a generic connection error instead of the proxy's timeout response. Every + * dispatcher used for upstream fetches must carry these options. + */ +export function buildUpstreamAgentTimeouts(): UpstreamAgentTimeoutOptions { + const ceiling = getRequestTimeoutMs() + UPSTREAM_TIMEOUT_GRACE_MS; + return { headersTimeout: ceiling, bodyTimeout: ceiling }; +} + +let defaultUpstreamDispatcher: Dispatcher | null = null; + +function getDefaultUpstreamDispatcher(): Dispatcher { + if (!defaultUpstreamDispatcher) { + const timeouts = buildUpstreamAgentTimeouts(); + // Honor HTTP(S)_PROXY routing when configured; otherwise a plain Agent. + defaultUpstreamDispatcher = createGlobalFetchProxyDispatcher(timeouts) ?? new Agent(timeouts); + } + return defaultUpstreamDispatcher; +} + function formatTimeoutDuration(timeoutMs: number): string { return timeoutMs % 1000 === 0 ? `${timeoutMs / 1000} seconds` : `${timeoutMs}ms`; } @@ -538,19 +569,20 @@ export async function handleProxyMessagesRequest( const useProfileInsecureTls = upstream.route.profile.insecure === true; const ephemeralInsecureDispatcher = useProfileInsecureTls && !useSharedInsecureDispatcher - ? new Agent({ connect: { rejectUnauthorized: false } }) + ? new Agent({ connect: { rejectUnauthorized: false }, ...buildUpstreamAgentTimeouts() }) : undefined; + const insecureTls = useSharedInsecureDispatcher || useProfileInsecureTls; const dispatcher = useSharedInsecureDispatcher ? insecureDispatcher : useProfileInsecureTls ? ephemeralInsecureDispatcher - : undefined; + : getDefaultUpstreamDispatcher(); try { logger.stage('dispatch', 'upstream.dispatch', 'Dispatching upstream fetch', { profileName: profile.profileName, routedProfileName: upstream.route.profile.profileName, - insecureTls: dispatcher !== undefined, + insecureTls, passthrough, }); const upstreamUrl = resolveOpenAIChatCompletionsUrl(upstream.route.profile.baseUrl, { diff --git a/src/proxy/server/proxy-server.ts b/src/proxy/server/proxy-server.ts index 134583d4..f9af6885 100644 --- a/src/proxy/server/proxy-server.ts +++ b/src/proxy/server/proxy-server.ts @@ -5,6 +5,7 @@ import type { OpenAICompatProfileConfig } from '../profile-router'; import { OPENAI_COMPAT_PROXY_SERVICE_NAME } from '../proxy-daemon-paths'; import { createLogger, withRequestContext } from '../../services/logging'; import { + buildUpstreamAgentTimeouts, handleProxyMessagesRequest, handleProxyModelsRequest, validateIncomingProxyAuth, @@ -39,7 +40,7 @@ export function startOpenAICompatProxyServer(options: OpenAICompatProxyServerOpt port: options.port, }); const insecureDispatcher = options.insecure - ? new Agent({ connect: { rejectUnauthorized: false } }) + ? new Agent({ connect: { rejectUnauthorized: false }, ...buildUpstreamAgentTimeouts() }) : undefined; const server = http.createServer((req, res) => { const requestId = resolveInboundRequestId(req.headers); diff --git a/src/utils/fetch-proxy-setup.ts b/src/utils/fetch-proxy-setup.ts index b52c6d74..d71b4039 100644 --- a/src/utils/fetch-proxy-setup.ts +++ b/src/utils/fetch-proxy-setup.ts @@ -12,6 +12,8 @@ const FETCH_PROXY_PROTOCOLS = ['http:', 'https:']; type RoutingDispatchOptions = Parameters[0]; type RoutingDispatchHandler = Parameters[1]; +export type UpstreamAgentTimeoutOptions = Pick; + type GlobalFetchProxyConfig = { httpProxyUrl?: string; httpsProxyUrl?: string; @@ -19,14 +21,23 @@ type GlobalFetchProxyConfig = { }; class RoutingProxyDispatcher extends Dispatcher { - private readonly directDispatcher = new Agent(); + private readonly directDispatcher: Agent; private readonly httpProxyDispatcher: ProxyAgent | null; private readonly httpsProxyDispatcher: ProxyAgent | null; - constructor(httpProxyUrl: string | undefined, httpsProxyUrl: string | undefined) { + constructor( + httpProxyUrl: string | undefined, + httpsProxyUrl: string | undefined, + agentOptions: UpstreamAgentTimeoutOptions = {} + ) { super(); - this.httpProxyDispatcher = httpProxyUrl ? new ProxyAgent(httpProxyUrl) : null; - this.httpsProxyDispatcher = httpsProxyUrl ? new ProxyAgent(httpsProxyUrl) : null; + this.directDispatcher = new Agent(agentOptions); + this.httpProxyDispatcher = httpProxyUrl + ? new ProxyAgent({ uri: httpProxyUrl, ...agentOptions }) + : null; + this.httpsProxyDispatcher = httpsProxyUrl + ? new ProxyAgent({ uri: httpsProxyUrl, ...agentOptions }) + : null; } dispatch(options: RoutingDispatchOptions, handler: RoutingDispatchHandler): boolean { @@ -114,14 +125,16 @@ class RoutingProxyDispatcher extends Dispatcher { } } -export function createGlobalFetchProxyDispatcher(): Dispatcher | null { +export function createGlobalFetchProxyDispatcher( + agentOptions: UpstreamAgentTimeoutOptions = {} +): Dispatcher | null { const { httpProxyUrl, httpsProxyUrl } = resolveGlobalFetchProxyConfig(); if (!httpProxyUrl && !httpsProxyUrl) { return null; } - return new RoutingProxyDispatcher(httpProxyUrl, httpsProxyUrl); + return new RoutingProxyDispatcher(httpProxyUrl, httpsProxyUrl, agentOptions); } export function applyGlobalFetchProxy(): { enabled: boolean; error?: string } { diff --git a/tests/unit/proxy/messages-route.test.ts b/tests/unit/proxy/messages-route.test.ts index e398ea33..eb66ee5a 100644 --- a/tests/unit/proxy/messages-route.test.ts +++ b/tests/unit/proxy/messages-route.test.ts @@ -8,6 +8,7 @@ import { Agent } from 'undici'; import { resolveOpenAICompatProfileConfig } from '../../../src/proxy/profile-router'; import { attachDisconnectAbortHandlers, + buildUpstreamAgentTimeouts, handleProxyMessagesRequest, } from '../../../src/proxy/server/messages-route'; import { loadSettings } from '../../../src/utils/config-manager'; @@ -171,7 +172,55 @@ describe('attachDisconnectAbortHandlers', () => { }); }); +describe('buildUpstreamAgentTimeouts', () => { + afterEach(() => { + delete process.env.CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS; + }); + + it('keeps undici per-phase timeouts above the default request timeout', () => { + const timeouts = buildUpstreamAgentTimeouts(); + expect(timeouts.headersTimeout).toBeGreaterThan(600_000); + expect(timeouts.bodyTimeout).toBeGreaterThan(600_000); + }); + + it('tracks CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS overrides', () => { + process.env.CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS = '120000'; + const timeouts = buildUpstreamAgentTimeouts(); + expect(timeouts.headersTimeout).toBeGreaterThan(120_000); + expect(timeouts.bodyTimeout).toBeGreaterThan(120_000); + }); +}); + describe('handleProxyMessagesRequest', () => { + it('dispatches secure upstream fetches with an explicit dispatcher (not undici defaults)', async () => { + const activeProfile = buildProfile('hf'); + let capturedDispatcher: unknown; + + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + capturedDispatcher = (init as RequestInit & { dispatcher?: unknown })?.dispatcher; + throw new Error('upstream exploded'); + }) as typeof globalThis.fetch; + + const req = new FakeRequest({ + 'x-api-key': 'local-token', + }); + const res = new FakeResponse(); + const pending = handleProxyMessagesRequest(req as never, res as never, activeProfile, 'local-token'); + req.end( + JSON.stringify({ + model: 'hf-default', + stream: true, + messages: [{ role: 'user', content: 'stay secure' }], + }) + ); + await pending; + + // A bare global-dispatcher fallback would reapply undici's 300s + // headersTimeout/bodyTimeout and undercut the proxy's request timeout. + expect(capturedDispatcher).toBeDefined(); + expect(res.statusCode).toBe(502); + }); + it('auto-passes through Kimi requests and preserves the coding-agent User-Agent', async () => { const activeProfile = buildProfile('kimic'); let capturedInput: RequestInfo | URL | undefined; From 5320b4f41808f4cfec97680a623d5f73584b1bf3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 16 Jun 2026 02:01:20 +0000 Subject: [PATCH 02/56] chore(release): 8.4.0-dev.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7dd3e72a..8199aeb2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.4.0", + "version": "8.4.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From a472c440156fc549c500c649ea0033220fb97e61 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:16:50 -0400 Subject: [PATCH 03/56] feat(api): add Fireworks AI provider preset (#1543) Promotes Fireworks AI to a first-class discoverable preset over the existing Anthropic-compatible Bearer path. Closes #890. --- README.md | 2 +- src/shared/provider-preset-catalog.ts | 14 +++++ .../api/provider-presets-fireworks.test.ts | 52 +++++++++++++++++++ 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 tests/unit/api/provider-presets-fireworks.test.ts diff --git a/README.md b/README.md index 1d1c24cb..dd086993 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ CCS gives you one stable command surface while letting you switch between: - OAuth providers like Codex, Kiro, Claude, Qwen, Kimi, and more, with legacy Copilot compatibility for existing setups - API and local-model profiles like GLM, Kimi, OpenRouter, Ollama, llama.cpp, - Novita, and Alibaba Coding Plan + Novita, Fireworks AI, and Alibaba Coding Plan The goal is simple: stop rewriting config files, stop breaking active sessions, and move between providers in seconds. diff --git a/src/shared/provider-preset-catalog.ts b/src/shared/provider-preset-catalog.ts index 5d934789..bdc9e88c 100644 --- a/src/shared/provider-preset-catalog.ts +++ b/src/shared/provider-preset-catalog.ts @@ -23,6 +23,7 @@ export const PROVIDER_PRESET_IDS = [ 'qwen', 'ollama-cloud', 'novita', + 'fireworks', ] as const; export type ProviderPresetId = (typeof PROVIDER_PRESET_IDS)[number]; @@ -266,6 +267,19 @@ const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [ badge: 'Anthropic-compatible', icon: '/icons/novita.svg', }, + { + id: 'fireworks', + name: 'Fireworks AI', + description: 'Anthropic-compatible inference (Kimi, Qwen, Llama) via api.fireworks.ai', + baseUrl: 'https://api.fireworks.ai/inference', + defaultProfileName: 'fireworks', + defaultModel: 'accounts/fireworks/models/kimi-k2p5', + apiKeyPlaceholder: 'fw_...', + apiKeyHint: 'Get your API key at fireworks.ai/api-keys', + category: 'alternative', + requiresApiKey: true, + badge: 'Anthropic-compatible', + }, ]; function clonePresetDefinition(preset: ProviderPresetDefinition): ProviderPresetDefinition { diff --git a/tests/unit/api/provider-presets-fireworks.test.ts b/tests/unit/api/provider-presets-fireworks.test.ts new file mode 100644 index 00000000..155f341d --- /dev/null +++ b/tests/unit/api/provider-presets-fireworks.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'bun:test'; +import { getPresetById, isValidPresetId } from '../../../src/api/services/provider-presets'; +import { PROVIDER_PRESET_IDS } from '../../../src/shared/provider-preset-catalog'; + +describe('provider-presets-fireworks', () => { + it('resolves fireworks preset id', () => { + const preset = getPresetById('fireworks'); + expect(preset?.id).toBe('fireworks'); + expect(preset?.baseUrl).toBe('https://api.fireworks.ai/inference'); + expect(preset?.defaultProfileName).toBe('fireworks'); + }); + + it('registers fireworks in PROVIDER_PRESET_IDS', () => { + expect(PROVIDER_PRESET_IDS).toContain('fireworks'); + }); + + it('uses the Anthropic-compatible base URL without a /v1 suffix', () => { + const preset = getPresetById('fireworks'); + expect(preset?.baseUrl).toBe('https://api.fireworks.ai/inference'); + expect(preset?.baseUrl.endsWith('/v1')).toBe(false); + }); + + it('pins a fully-qualified Fireworks model id as default', () => { + const preset = getPresetById('fireworks'); + expect(preset?.defaultModel).toMatch(/^accounts\/fireworks\/(models|routers)\//); + }); + + it('validates fireworks preset requires API key with the fw_ placeholder', () => { + const preset = getPresetById('fireworks'); + expect(preset?.requiresApiKey).toBe(true); + expect(preset?.apiKeyPlaceholder).toBe('fw_...'); + }); + + it('treats fireworks as a valid preset id', () => { + expect(isValidPresetId('fireworks')).toBe(true); + }); + + it('handles whitespace in fireworks preset id', () => { + const preset = getPresetById(' fireworks '); + expect(preset?.id).toBe('fireworks'); + }); + + it('handles uppercase fireworks preset id', () => { + const preset = getPresetById('FIREWORKS'); + expect(preset?.id).toBe('fireworks'); + }); + + it('does not resolve partial or invalid fireworks ids', () => { + expect(getPresetById('fireworks-invalid')).toBeUndefined(); + expect(isValidPresetId('fireworks-invalid')).toBe(false); + }); +}); From 499aec8a9ba9eeb27c1d183a27b3336f4e8794e8 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:16:54 -0400 Subject: [PATCH 04/56] feat(config): add opt-in output limits for spawned CLI (#1544) Adds opt-in config.runtime.outputLimits to inject MAX_MCP_OUTPUT_TOKENS/BASH_MAX_OUTPUT_LENGTH into the spawned CLI; defaults unchanged when unset. Closes #231. --- src/cliproxy/config/env-builder.ts | 28 ++- src/commands/config-command-options.ts | 9 + .../output-limits-env-injection.test.ts | 227 ++++++++++++++++++ src/config/config-loader-facade.ts | 1 + src/config/loader/config-getters.ts | 14 ++ src/config/loader/defaults-merger.ts | 4 + src/config/loader/yaml-serializer.ts | 16 ++ .../__tests__/runtime-output-limits.test.ts | 70 ++++++ src/config/schemas/index.ts | 4 + src/config/schemas/runtime.ts | 83 +++++++ src/config/schemas/unified-config.ts | 3 + src/config/unified-config-loader.ts | 1 + src/targets/claude-adapter.ts | 6 + 13 files changed, 461 insertions(+), 5 deletions(-) create mode 100644 src/config/__tests__/output-limits-env-injection.test.ts create mode 100644 src/config/schemas/__tests__/runtime-output-limits.test.ts create mode 100644 src/config/schemas/runtime.ts diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index 998a10f1..0a184622 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -31,7 +31,11 @@ import { normalizeIFlowLegacyModelAliases, normalizeModelIdForProvider, } from '../ai-providers/model-id-normalizer'; -import { getGlobalEnvConfig, getCcsDir } from '../../config/config-loader-facade'; +import { + getGlobalEnvConfig, + getOutputLimitsEnv, + getCcsDir, +} from '../../config/config-loader-facade'; /** Settings file structure for user overrides */ interface ProviderSettings { @@ -310,13 +314,21 @@ export function resolveProviderSettingsPath(provider: CLIProxyProvider): string /** * Get global env vars to inject into all third-party profiles. * Returns empty object if disabled. + * + * Opt-in output limits (issue #231) are merged in on top of the global env so + * they reach every cliproxy launch path (local, remote, composite). When unset, + * getOutputLimitsEnv() returns {} and nothing is injected, preserving the + * downstream CLI's own default caps. Output limits are independent of the + * global_env enable flag: a user can opt into limits without enabling global + * telemetry-disable env. */ function getGlobalEnvVars(): Record { + const outputLimitsEnv = getOutputLimitsEnv(); const globalEnvConfig = getGlobalEnvConfig(); if (!globalEnvConfig.enabled) { - return {}; + return { ...outputLimitsEnv }; } - return globalEnvConfig.env; + return { ...globalEnvConfig.env, ...outputLimitsEnv }; } /** @@ -465,7 +477,10 @@ export function getEffectiveEnvVars( migrateDeprecatedModelNames(expandedPath, provider, settings); // Migrate legacy iFlow placeholders to supported model IDs migrateIFlowPlaceholderModel(expandedPath, provider, settings); - // Custom variant settings found - merge with global env + // Custom variant settings found - merge with global env. + // settings.env is spread AFTER globalEnv, so an explicit per-variant + // value (e.g. MAX_MCP_OUTPUT_TOKENS) intentionally overrides the + // config.runtime.outputLimits value carried in globalEnv. envVars = { ...globalEnv, ...settings.env }; // Ensure required vars are present (fall back to defaults if missing) envVars = ensureRequiredEnvVars(envVars, provider, port); @@ -498,7 +513,10 @@ export function getEffectiveEnvVars( migrateDeprecatedModelNames(settingsPath, provider, settings); // Migrate legacy iFlow placeholders to supported model IDs migrateIFlowPlaceholderModel(settingsPath, provider, settings); - // User override found - merge with global env + // User override found - merge with global env. + // settings.env is spread AFTER globalEnv, so an explicit per-variant + // value (e.g. MAX_MCP_OUTPUT_TOKENS) intentionally overrides the + // config.runtime.outputLimits value carried in globalEnv. envVars = { ...globalEnv, ...settings.env }; // Ensure required vars are present (fall back to defaults if missing) envVars = ensureRequiredEnvVars(envVars, provider, port); diff --git a/src/commands/config-command-options.ts b/src/commands/config-command-options.ts index 0f4a6149..11e5aa5e 100644 --- a/src/commands/config-command-options.ts +++ b/src/commands/config-command-options.ts @@ -121,6 +121,15 @@ export function showConfigCommandHelp(): void { console.log(' --provider-override

Set provider tier override'); console.log(' --clear-provider-override

[t] Remove provider override'); console.log(''); + console.log(' Output limits (opt-in)'); + console.log(' Raise the spawned CLI output caps without hand-editing its settings.'); + console.log(' Edit ~/.ccs/config.yaml and add (both fields optional):'); + console.log(' runtime:'); + console.log(' outputLimits:'); + console.log(' maxMcpOutputTokens: 100000 # -> MAX_MCP_OUTPUT_TOKENS'); + console.log(' bashMaxOutputLength: 200000 # -> BASH_MAX_OUTPUT_LENGTH'); + console.log(' When unset, the spawned CLI keeps its own default caps.'); + console.log(''); console.log('Options:'); console.log(' --port, -p PORT Specify server port (default: auto-detect)'); console.log(' --host, -H HOST Bind dashboard server host (default: localhost)'); diff --git a/src/config/__tests__/output-limits-env-injection.test.ts b/src/config/__tests__/output-limits-env-injection.test.ts new file mode 100644 index 00000000..7e92093a --- /dev/null +++ b/src/config/__tests__/output-limits-env-injection.test.ts @@ -0,0 +1,227 @@ +/** + * Tests: opt-in output-limit env injection end to end (issue #231). + * + * Loads a real config.yaml from a temp CCS_HOME and verifies getOutputLimitsEnv() + * reads config.runtime.outputLimits correctly: + * - No runtime section => no env injected (downstream defaults preserved). + * - Configured values => correct downstream env var names, string values. + * + * Also asserts the limits are actually injected at BOTH consumer sites, not just + * read by the getter: + * - ClaudeAdapter.buildEnv() (account/default Claude launch path). + * - getEffectiveEnvVars() (cliproxy launch path, which spreads getGlobalEnvVars). + * Plus the critical opt-in invariant end to end: when runtime.outputLimits is + * ABSENT, NEITHER consumer's env carries the keys. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { ClaudeAdapter } from '../../targets/claude-adapter'; +import { getEffectiveEnvVars } from '../../cliproxy/config/env-builder'; + +/** Downstream env keys the output-limit feature injects. */ +const OUTPUT_LIMIT_KEYS = ['MAX_MCP_OUTPUT_TOKENS', 'BASH_MAX_OUTPUT_LENGTH'] as const; + +const CONFIGURED_YAML = [ + 'version: 1', + 'runtime:', + ' outputLimits:', + ' maxMcpOutputTokens: 100000', + ' bashMaxOutputLength: 200000', + '', +].join('\n'); + +/** Minimal valid credentials for ClaudeAdapter.buildEnv. */ +function makeCreds(): { + profile: string; + baseUrl: string; + apiKey: string; +} { + return { profile: 'default', baseUrl: '', apiKey: '' }; +} + +/** Write a config.yaml into a fresh temp CCS_HOME and return the home dir. */ +function createTestHome(configYaml: string): string { + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-output-limits-')); + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync(path.join(ccsDir, 'config.yaml'), configYaml, 'utf8'); + return tempHome; +} + +/** Re-import the facade with a cache-busting query so each test reads fresh config. */ +async function importFacade(): Promise { + return import(`../config-loader-facade?cachebust=${Date.now()}-${Math.random()}`); +} + +describe('getOutputLimitsEnv (config.runtime.outputLimits)', () => { + let tempHome: string; + let originalCcsHome: string | undefined; + + beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('injects nothing when no runtime section is present (defaults preserved)', async () => { + tempHome = createTestHome(`version: 1\n`); + process.env.CCS_HOME = tempHome; + const facade = await importFacade(); + expect(facade.getOutputLimitsEnv()).toEqual({}); + }); + + it('injects only configured keys as strings', async () => { + tempHome = createTestHome( + [ + 'version: 1', + 'runtime:', + ' outputLimits:', + ' maxMcpOutputTokens: 100000', + ' bashMaxOutputLength: 200000', + '', + ].join('\n') + ); + process.env.CCS_HOME = tempHome; + const facade = await importFacade(); + const env = facade.getOutputLimitsEnv(); + expect(env).toEqual({ + MAX_MCP_OUTPUT_TOKENS: '100000', + BASH_MAX_OUTPUT_LENGTH: '200000', + }); + for (const value of Object.values(env)) { + expect(typeof value).toBe('string'); + } + }); + + it('injects only the configured subset', async () => { + tempHome = createTestHome( + ['version: 1', 'runtime:', ' outputLimits:', ' maxMcpOutputTokens: 50000', ''].join('\n') + ); + process.env.CCS_HOME = tempHome; + const facade = await importFacade(); + const env = facade.getOutputLimitsEnv(); + expect(env).toEqual({ MAX_MCP_OUTPUT_TOKENS: '50000' }); + expect(env).not.toHaveProperty('BASH_MAX_OUTPUT_LENGTH'); + }); + + it('injects nothing when runtime.outputLimits is empty', async () => { + tempHome = createTestHome(['version: 1', 'runtime:', ' outputLimits: {}', ''].join('\n')); + process.env.CCS_HOME = tempHome; + const facade = await importFacade(); + expect(facade.getOutputLimitsEnv()).toEqual({}); + }); +}); + +describe('output limits reach the ClaudeAdapter consumer (buildEnv)', () => { + let tempHome: string; + let originalCcsHome: string | undefined; + let originalLimitEnv: Record; + + beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + // Ensure the parent shell isn't pre-seeding the limit keys: buildEnv spreads + // process.env, so a leaked value would defeat the negative assertion. + originalLimitEnv = {}; + for (const key of OUTPUT_LIMIT_KEYS) { + originalLimitEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + for (const key of OUTPUT_LIMIT_KEYS) { + if (originalLimitEnv[key] !== undefined) { + process.env[key] = originalLimitEnv[key]; + } else { + delete process.env[key]; + } + } + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('injects the configured limits as string values into the built env', () => { + tempHome = createTestHome(CONFIGURED_YAML); + process.env.CCS_HOME = tempHome; + + const env = new ClaudeAdapter().buildEnv(makeCreds(), 'default'); + + expect(env.MAX_MCP_OUTPUT_TOKENS).toBe('100000'); + expect(env.BASH_MAX_OUTPUT_LENGTH).toBe('200000'); + expect(typeof env.MAX_MCP_OUTPUT_TOKENS).toBe('string'); + expect(typeof env.BASH_MAX_OUTPUT_LENGTH).toBe('string'); + }); + + it('injects nothing when runtime.outputLimits is absent (opt-in invariant)', () => { + tempHome = createTestHome(`version: 1\n`); + process.env.CCS_HOME = tempHome; + + const env = new ClaudeAdapter().buildEnv(makeCreds(), 'default'); + + for (const key of OUTPUT_LIMIT_KEYS) { + expect(env).not.toHaveProperty(key); + } + }); +}); + +describe('output limits reach the cliproxy consumer (getEffectiveEnvVars)', () => { + let tempHome: string; + let originalCcsHome: string | undefined; + + beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('includes the configured limit keys (via getGlobalEnvVars) in the provider env', () => { + tempHome = createTestHome(CONFIGURED_YAML); + process.env.CCS_HOME = tempHome; + + // No provider settings file exists in the temp home, so this exercises the + // bundled-defaults path: { ...globalEnv, ...getClaudeEnvVars() }. globalEnv + // carries the opt-in output limits from config.runtime.outputLimits. + const env = getEffectiveEnvVars('gemini', 8317); + + expect(env.MAX_MCP_OUTPUT_TOKENS).toBe('100000'); + expect(env.BASH_MAX_OUTPUT_LENGTH).toBe('200000'); + }); + + it('omits the limit keys when runtime.outputLimits is absent (opt-in invariant)', () => { + tempHome = createTestHome(`version: 1\n`); + process.env.CCS_HOME = tempHome; + + const env = getEffectiveEnvVars('gemini', 8317); + + for (const key of OUTPUT_LIMIT_KEYS) { + expect(env).not.toHaveProperty(key); + } + }); +}); diff --git a/src/config/config-loader-facade.ts b/src/config/config-loader-facade.ts index 462efa37..5c187404 100644 --- a/src/config/config-loader-facade.ts +++ b/src/config/config-loader-facade.ts @@ -33,6 +33,7 @@ export { setDefaultProfile, getWebSearchConfig, getGlobalEnvConfig, + getOutputLimitsEnv, getContinuityInheritanceMap, getCliproxySafetyConfig, getThinkingConfig, diff --git a/src/config/loader/config-getters.ts b/src/config/loader/config-getters.ts index 28d87436..a37779a3 100644 --- a/src/config/loader/config-getters.ts +++ b/src/config/loader/config-getters.ts @@ -18,6 +18,7 @@ import { DEFAULT_LOGGING_CONFIG, DEFAULT_OFFICIAL_CHANNELS_CONFIG, DEFAULT_THINKING_CONFIG, + buildOutputLimitsEnv, } from '../unified-config-types'; import type { BrowserConfig, @@ -178,6 +179,19 @@ export function getGlobalEnvConfig(): GlobalEnvConfig { }; } +/** + * Get opt-in output-limit env vars for the spawned downstream CLI (issue #231). + * + * Returns ONLY the env vars the user has explicitly configured under + * config.runtime.outputLimits. When the section is absent or empty, returns an + * empty object so callers inject nothing and the downstream CLI keeps its own + * defaults. All values are strings. + */ +export function getOutputLimitsEnv(): Record { + const config = getConfig(); + return buildOutputLimitsEnv(config.runtime?.outputLimits); +} + /** * Get continuity inheritance mapping. * Returns empty mapping when not configured. diff --git a/src/config/loader/defaults-merger.ts b/src/config/loader/defaults-merger.ts index ae7487e9..7b45f4a1 100644 --- a/src/config/loader/defaults-merger.ts +++ b/src/config/loader/defaults-merger.ts @@ -331,5 +331,9 @@ export function mergeWithDefaults(partial: Partial): UnifiedConfi profile_backends: partial.image_analysis?.profile_backends ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.profile_backends, }), + // Runtime config (issue #231) - optional, opt-in spawned-CLI knobs. + // Passed through only when present so an absent section stays absent and + // no output-limit env is injected (downstream defaults preserved). + runtime: partial.runtime, }; } diff --git a/src/config/loader/yaml-serializer.ts b/src/config/loader/yaml-serializer.ts index 21f62373..7fc878e9 100644 --- a/src/config/loader/yaml-serializer.ts +++ b/src/config/loader/yaml-serializer.ts @@ -268,6 +268,22 @@ export function generateYamlWithComments(config: UnifiedConfig): string { lines.push(''); } + // Runtime section (opt-in spawned-CLI launch knobs, e.g. output limits) + if (config.runtime) { + lines.push('# ----------------------------------------------------------------------------'); + lines.push('# Runtime: opt-in knobs for how CCS launches the downstream CLI'); + lines.push('# outputLimits raises the spawned CLI output caps via env vars (issue #231):'); + lines.push('# maxMcpOutputTokens -> MAX_MCP_OUTPUT_TOKENS'); + lines.push('# bashMaxOutputLength -> BASH_MAX_OUTPUT_LENGTH'); + lines.push('# Each field is optional; when unset CCS injects nothing and the downstream'); + lines.push('# CLI keeps its own default caps.'); + lines.push('# ----------------------------------------------------------------------------'); + lines.push( + yaml.dump({ runtime: config.runtime }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim() + ); + lines.push(''); + } + // Official Channels section if (config.channels) { lines.push('# ----------------------------------------------------------------------------'); diff --git a/src/config/schemas/__tests__/runtime-output-limits.test.ts b/src/config/schemas/__tests__/runtime-output-limits.test.ts new file mode 100644 index 00000000..4420ce3f --- /dev/null +++ b/src/config/schemas/__tests__/runtime-output-limits.test.ts @@ -0,0 +1,70 @@ +/** + * Tests: runtime output-limits schema and env mapping (issue #231). + * + * Verifies the opt-in contract: + * - Absent/empty config injects NOTHING (downstream defaults preserved). + * - Configured values map to the correct downstream env var names. + * - All emitted values are strings. + */ + +import { describe, it, expect } from 'bun:test'; +import { buildOutputLimitsEnv, OUTPUT_LIMITS_ENV_KEYS, type OutputLimitsConfig } from '../runtime'; + +describe('buildOutputLimitsEnv', () => { + it('injects nothing when config is undefined (defaults preserved)', () => { + expect(buildOutputLimitsEnv(undefined)).toEqual({}); + }); + + it('injects nothing when config is an empty object', () => { + expect(buildOutputLimitsEnv({})).toEqual({}); + }); + + it('maps maxMcpOutputTokens to MAX_MCP_OUTPUT_TOKENS as a string', () => { + const env = buildOutputLimitsEnv({ maxMcpOutputTokens: 100000 }); + expect(env).toEqual({ MAX_MCP_OUTPUT_TOKENS: '100000' }); + expect(typeof env.MAX_MCP_OUTPUT_TOKENS).toBe('string'); + }); + + it('maps bashMaxOutputLength to BASH_MAX_OUTPUT_LENGTH as a string', () => { + const env = buildOutputLimitsEnv({ bashMaxOutputLength: 200000 }); + expect(env).toEqual({ BASH_MAX_OUTPUT_LENGTH: '200000' }); + expect(typeof env.BASH_MAX_OUTPUT_LENGTH).toBe('string'); + }); + + it('maps both keys when both are configured, all values strings', () => { + const cfg: OutputLimitsConfig = { + maxMcpOutputTokens: 100000, + bashMaxOutputLength: 200000, + }; + const env = buildOutputLimitsEnv(cfg); + expect(env).toEqual({ + MAX_MCP_OUTPUT_TOKENS: '100000', + BASH_MAX_OUTPUT_LENGTH: '200000', + }); + for (const value of Object.values(env)) { + expect(typeof value).toBe('string'); + } + }); + + it('injects only the configured subset, leaving the other absent', () => { + const env = buildOutputLimitsEnv({ maxMcpOutputTokens: 50000 }); + expect(env).toHaveProperty('MAX_MCP_OUTPUT_TOKENS', '50000'); + expect(env).not.toHaveProperty('BASH_MAX_OUTPUT_LENGTH'); + }); + + it('emits "0" for an explicit zero limit (finite, non-negative)', () => { + const env = buildOutputLimitsEnv({ maxMcpOutputTokens: 0 }); + expect(env).toEqual({ MAX_MCP_OUTPUT_TOKENS: '0' }); + }); + + it('ignores invalid (NaN / Infinity / negative) values', () => { + expect(buildOutputLimitsEnv({ maxMcpOutputTokens: Number.NaN })).toEqual({}); + expect(buildOutputLimitsEnv({ maxMcpOutputTokens: Number.POSITIVE_INFINITY })).toEqual({}); + expect(buildOutputLimitsEnv({ bashMaxOutputLength: -1 })).toEqual({}); + }); + + it('exposes the downstream env var names via the managed-env allowlist', () => { + expect(OUTPUT_LIMITS_ENV_KEYS.maxMcpOutputTokens).toBe('MAX_MCP_OUTPUT_TOKENS'); + expect(OUTPUT_LIMITS_ENV_KEYS.bashMaxOutputLength).toBe('BASH_MAX_OUTPUT_LENGTH'); + }); +}); diff --git a/src/config/schemas/index.ts b/src/config/schemas/index.ts index ff0313f3..a25aa156 100644 --- a/src/config/schemas/index.ts +++ b/src/config/schemas/index.ts @@ -51,6 +51,10 @@ export type { export { DEFAULT_THINKING_TIER_DEFAULTS, DEFAULT_THINKING_CONFIG } from './thinking'; export type { ThinkingMode, ThinkingTierDefaults, ThinkingConfig } from './thinking'; +// Runtime (spawned-CLI) types, output-limit env mapping (issue #231) +export { OUTPUT_LIMITS_ENV_KEYS, buildOutputLimitsEnv } from './runtime'; +export type { OutputLimitsConfig, RuntimeConfig } from './runtime'; + // Official channels types and defaults export { DEFAULT_OFFICIAL_CHANNELS_CONFIG } from './channels'; export type { OfficialChannelId, OfficialChannelsConfig } from './channels'; diff --git a/src/config/schemas/runtime.ts b/src/config/schemas/runtime.ts new file mode 100644 index 00000000..55857066 --- /dev/null +++ b/src/config/schemas/runtime.ts @@ -0,0 +1,83 @@ +/** + * Runtime configuration types and defaults. + * + * The runtime section holds opt-in knobs that affect how CCS launches the + * downstream CLI (Claude Code, Gemini CLI, etc.). Everything here is optional; + * an absent section preserves the downstream CLI's own defaults exactly. + * + * Output limits (issue #231): users hit the downstream CLI's low default caps + * for MCP tool output and Bash output during normal workflows and previously + * had to hand-edit the spawned CLI's settings file. When set, CCS injects the + * matching downstream env vars (as strings) into the spawned-CLI environment. + */ + +/** + * Opt-in output-limit overrides for the spawned downstream CLI. + * + * Each field is optional. When a field is unset, CCS injects NOTHING for it, + * so the downstream CLI keeps its own built-in default. This is intentional: + * always-injecting a high cap could regress context budget / OOM behavior. + */ +export interface OutputLimitsConfig { + /** + * Max MCP tool output tokens. + * Maps to the downstream env var MAX_MCP_OUTPUT_TOKENS (Claude Code default ~25000). + */ + maxMcpOutputTokens?: number; + /** + * Max Bash command output length in characters. + * Maps to the downstream env var BASH_MAX_OUTPUT_LENGTH. + */ + bashMaxOutputLength?: number; +} + +/** + * Runtime configuration section (optional everywhere). + * Absent = current behavior unchanged. + */ +export interface RuntimeConfig { + /** Opt-in output-limit overrides for the spawned CLI. */ + outputLimits?: OutputLimitsConfig; +} + +/** + * Downstream env var names that CCS is allowed to write for output limits. + * Used as the managed-env allowlist for this feature. + */ +export const OUTPUT_LIMITS_ENV_KEYS = { + maxMcpOutputTokens: 'MAX_MCP_OUTPUT_TOKENS', + bashMaxOutputLength: 'BASH_MAX_OUTPUT_LENGTH', +} as const; + +/** + * Map an OutputLimitsConfig to downstream env vars. + * + * Returns ONLY the keys that are explicitly configured with a finite, + * non-negative number. Values are emitted as strings (all env values written + * by CCS must be strings). When the config is undefined/empty, returns an empty + * object so callers inject nothing and downstream defaults are preserved. + */ +export function buildOutputLimitsEnv( + outputLimits: OutputLimitsConfig | undefined +): Record { + const env: Record = {}; + if (!outputLimits) { + return env; + } + + const { maxMcpOutputTokens, bashMaxOutputLength } = outputLimits; + + if (isUsableLimit(maxMcpOutputTokens)) { + env[OUTPUT_LIMITS_ENV_KEYS.maxMcpOutputTokens] = String(maxMcpOutputTokens); + } + if (isUsableLimit(bashMaxOutputLength)) { + env[OUTPUT_LIMITS_ENV_KEYS.bashMaxOutputLength] = String(bashMaxOutputLength); + } + + return env; +} + +/** A limit is usable only when it is a finite, non-negative number. */ +function isUsableLimit(value: number | undefined): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0; +} diff --git a/src/config/schemas/unified-config.ts b/src/config/schemas/unified-config.ts index 5290ccc0..a38db23a 100644 --- a/src/config/schemas/unified-config.ts +++ b/src/config/schemas/unified-config.ts @@ -34,6 +34,7 @@ import type { QuotaManagementConfig } from './quota'; import { DEFAULT_QUOTA_MANAGEMENT_CONFIG } from './quota'; import type { ThinkingConfig } from './thinking'; import { DEFAULT_THINKING_CONFIG } from './thinking'; +import type { RuntimeConfig } from './runtime'; import type { OfficialChannelsConfig } from './channels'; import { DEFAULT_OFFICIAL_CHANNELS_CONFIG } from './channels'; import type { BrowserConfig } from './browser'; @@ -78,6 +79,8 @@ export interface UnifiedConfig { quota_management?: QuotaManagementConfig; /** Thinking/reasoning budget configuration (v8+) */ thinking?: ThinkingConfig; + /** Runtime (spawned-CLI) configuration, e.g. opt-in output limits (issue #231) */ + runtime?: RuntimeConfig; /** Official Channels runtime auto-enable preferences (v11+) */ channels?: OfficialChannelsConfig; /** Dashboard authentication configuration (optional) */ diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index e7f8c87a..c355d9c3 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -94,6 +94,7 @@ export type { GeminiWebSearchInfo } from './loader/config-getters'; export { getWebSearchConfig, getGlobalEnvConfig, + getOutputLimitsEnv, getContinuityInheritanceMap, getCliproxySafetyConfig, getThinkingConfig, diff --git a/src/targets/claude-adapter.ts b/src/targets/claude-adapter.ts index 750a301d..39fcd83f 100644 --- a/src/targets/claude-adapter.ts +++ b/src/targets/claude-adapter.ts @@ -18,6 +18,7 @@ import { } from '../utils/shell-executor'; import { ErrorManager } from '../utils/error-manager'; import { getWebSearchHookEnv } from '../utils/websearch-manager'; +import { getOutputLimitsEnv } from '../config/config-loader-facade'; import { appendBrowserToolArgs } from '../utils/browser'; import { wireChildProcessSignals } from '../utils/signal-forwarder'; import { runCleanup } from '../errors'; @@ -77,6 +78,11 @@ export class ClaudeAdapter implements TargetAdapter { if (creds.apiKey) env['ANTHROPIC_AUTH_TOKEN'] = creds.apiKey; if (creds.model) env['ANTHROPIC_MODEL'] = creds.model; + // Opt-in output limits (issue #231): inject only the env vars the user has + // explicitly configured under config.runtime.outputLimits. When unset, this + // is {} and nothing is injected, so Claude Code keeps its own default caps. + Object.assign(env, getOutputLimitsEnv()); + return stripClaudeCodeEnv(env); } From 21e2dc1c241ea2c88cd6df46253b25714f5b8aff Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 16 Jun 2026 02:20:52 +0000 Subject: [PATCH 05/56] chore(release): 8.4.0-dev.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8199aeb2..cb7ba664 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.4.0-dev.1", + "version": "8.4.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From f74561fe9b48c478c002daec39b8797f1dd0ede6 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:48:11 -0400 Subject: [PATCH 06/56] fix: avoid quadratic cliproxy usage hydration (#1537) Replaces O(n^2) usage hydration in the cliproxy merge path with a linear lookup. --- src/web-server/usage/cliproxy-usage-transformer.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/web-server/usage/cliproxy-usage-transformer.ts b/src/web-server/usage/cliproxy-usage-transformer.ts index 0a6cfcdd..44c953a5 100644 --- a/src/web-server/usage/cliproxy-usage-transformer.ts +++ b/src/web-server/usage/cliproxy-usage-transformer.ts @@ -275,15 +275,19 @@ function hydrateProviderlessHistoryDetails( existing: CliproxyUsageHistoryDetail[], incoming: CliproxyUsageHistoryDetail[] ): CliproxyUsageHistoryDetail[] { + if (!existing.some((detail) => !detail.provider)) return existing; + const incomingByProviderlessSignature = new Map(); for (const detail of incoming) { if (!detail.provider) continue; const signature = createProviderlessHistorySignature(detail); - incomingByProviderlessSignature.set(signature, [ - ...(incomingByProviderlessSignature.get(signature) ?? []), - detail, - ]); + const matches = incomingByProviderlessSignature.get(signature); + if (matches) { + matches.push(detail); + } else { + incomingByProviderlessSignature.set(signature, [detail]); + } } return existing.map((detail) => { From c351261fe4a6d9b14b60a2a194943fd18d4a6ef5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 16 Jun 2026 02:54:04 +0000 Subject: [PATCH 07/56] chore(release): 8.4.0-dev.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cb7ba664..3dc26023 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "8.4.0-dev.2", + "version": "8.4.0-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From db44845857b62352058b07900de6c04c36c5ba27 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:12:46 -0400 Subject: [PATCH 08/56] fix(codex): sanitize config override probes (#1535) Sanitizes ccsxp support-probe config overrides to avoid env leak; parity with the real Codex launch path. --- src/targets/codex-detector.ts | 24 ++++++++++- tests/unit/targets/codex-detector.test.ts | 51 +++++++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/targets/codex-detector.ts b/src/targets/codex-detector.ts index 73cd79e4..89efbfa4 100644 --- a/src/targets/codex-detector.ts +++ b/src/targets/codex-detector.ts @@ -1,11 +1,19 @@ import * as fs from 'fs'; import * as childProcess from 'child_process'; import { expandPath } from '../utils/helpers'; -import { escapeShellArg, getWindowsEscapedCommandShell } from '../utils/shell-executor'; +import { + escapeShellArg, + getWindowsEscapedCommandShell, + stripAnthropicEnv, + stripBrowserEnv, + stripCodexSessionEnv, +} from '../utils/shell-executor'; import type { TargetBinaryInfo } from './target-adapter'; const CODEX_CONFIG_OVERRIDE_FEATURE = 'config-overrides'; const CODEX_CONFIG_OVERRIDE_PROBE_ARGS = ['-c', 'model="gpt-5"', '--version']; +const CCSXP_CLIPROXY_SHORTCUT_ENV = 'CCSXP_CLIPROXY_SHORTCUT'; +const CODEX_RUNTIME_ENV_KEY = 'CCS_CODEX_API_KEY'; function buildWindowsCodexCandidates(matches: string[]): string[] { const shellCandidates = matches.filter((entry) => /\.(exe|cmd|bat|ps1)$/i.test(entry)); @@ -32,11 +40,22 @@ function buildWindowsCodexCandidates(matches: string[]): string[] { return [...new Set([...prioritized, ...bareCandidates])]; } +function buildCodexProbeEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + ...stripBrowserEnv(stripCodexSessionEnv(stripAnthropicEnv(process.env))), + }; + delete env[CCSXP_CLIPROXY_SHORTCUT_ENV]; + delete env[CODEX_RUNTIME_ENV_KEY]; + return env; +} + function runCodexProbe(codexPath: string, args: string[]): string | undefined { const isWindows = process.platform === 'win32'; const isPowerShellScript = isWindows && /\.ps1$/i.test(codexPath); const needsShell = isWindows && /\.(cmd|bat)$/i.test(codexPath); + const env = buildCodexProbeEnv(); + try { if (isPowerShellScript) { return childProcess.execFileSync( @@ -44,6 +63,7 @@ function runCodexProbe(codexPath: string, args: string[]): string | undefined { ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', codexPath, ...args], { encoding: 'utf8', + env, stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, windowsHide: true, @@ -55,6 +75,7 @@ function runCodexProbe(codexPath: string, args: string[]): string | undefined { const cmdString = [codexPath, ...args].map(escapeShellArg).join(' '); const result = childProcess.spawnSync(cmdString, { encoding: 'utf8', + env, stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, windowsHide: true, @@ -65,6 +86,7 @@ function runCodexProbe(codexPath: string, args: string[]): string | undefined { return childProcess.execFileSync(codexPath, args, { encoding: 'utf8', + env, stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000, }); diff --git a/tests/unit/targets/codex-detector.test.ts b/tests/unit/targets/codex-detector.test.ts index b361d8ac..620b52e0 100644 --- a/tests/unit/targets/codex-detector.test.ts +++ b/tests/unit/targets/codex-detector.test.ts @@ -6,16 +6,29 @@ import * as path from 'path'; import { detectCodexCli, getCodexBinaryInfo } from '../../../src/targets/codex-detector'; +const CCSXP_CLIPROXY_SHORTCUT_ENV = 'CCSXP_CLIPROXY_SHORTCUT'; + describe('codex-detector', () => { let tmpDir: string; let originalPath: string | undefined; let originalCodexPath: string | undefined; + let originalProbeEnv: Record; + const probeEnvKeys = [ + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_API_KEY', + 'CCS_BROWSER_DEVTOOLS_URL', + 'CODEX_THREAD_ID', + 'CCS_CODEX_API_KEY', + CCSXP_CLIPROXY_SHORTCUT_ENV, + 'CCS_SAFE_VALUE', + ]; const originalPlatform = process.platform; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-detector-test-')); originalPath = process.env.PATH; originalCodexPath = process.env.CCS_CODEX_PATH; + originalProbeEnv = Object.fromEntries(probeEnvKeys.map((key) => [key, process.env[key]])); process.env.PATH = ''; }); @@ -28,6 +41,11 @@ describe('codex-detector', () => { if (originalCodexPath !== undefined) process.env.CCS_CODEX_PATH = originalCodexPath; else delete process.env.CCS_CODEX_PATH; + for (const key of probeEnvKeys) { + if (originalProbeEnv[key] !== undefined) process.env[key] = originalProbeEnv[key]; + else delete process.env[key]; + } + fs.rmSync(tmpDir, { recursive: true, force: true }); }); @@ -121,6 +139,39 @@ describe('codex-detector', () => { execSyncSpy.mockRestore(); }); + it('runs Codex feature probes with the same sensitive env stripping used for Codex launches', () => { + const fakeCodex = path.join(tmpDir, 'codex'); + fs.writeFileSync(fakeCodex, ''); + process.env.CCS_CODEX_PATH = fakeCodex; + process.env.ANTHROPIC_AUTH_TOKEN = 'secret-auth-token'; + process.env.ANTHROPIC_API_KEY = 'secret-api-key'; + process.env.CCS_BROWSER_DEVTOOLS_URL = 'ws://127.0.0.1:9222/devtools/browser/secret'; + process.env.CODEX_THREAD_ID = 'thread-secret'; + process.env.CCS_CODEX_API_KEY = 'runtime-secret'; + process.env[CCSXP_CLIPROXY_SHORTCUT_ENV] = '1'; + process.env.CCS_SAFE_VALUE = 'safe-value'; + + const execFileSyncSpy = spyOn(childProcess, 'execFileSync').mockImplementation(() => { + return 'codex-cli 0.119.0-alpha.1'; + }); + + const info = getCodexBinaryInfo(); + + expect(info?.features).toContain('config-overrides'); + const probeOptions = execFileSyncSpy.mock.calls + .map((call) => call[2] as { env?: NodeJS.ProcessEnv } | undefined) + .find((options) => options?.env); + expect(probeOptions?.env?.ANTHROPIC_AUTH_TOKEN).toBeUndefined(); + expect(probeOptions?.env?.ANTHROPIC_API_KEY).toBeUndefined(); + expect(probeOptions?.env?.CCS_BROWSER_DEVTOOLS_URL).toBeUndefined(); + expect(probeOptions?.env?.CODEX_THREAD_ID).toBeUndefined(); + expect(probeOptions?.env?.CCS_CODEX_API_KEY).toBeUndefined(); + expect(probeOptions?.env?.[CCSXP_CLIPROXY_SHORTCUT_ENV]).toBeUndefined(); + expect(probeOptions?.env?.CCS_SAFE_VALUE).toBe('safe-value'); + + execFileSyncSpy.mockRestore(); + }); + it('falls back to a direct -c probe when help text omits the config flag', () => { const fakeCodex = path.join(tmpDir, 'codex'); fs.writeFileSync(fakeCodex, ''); From d2848d39317950b36ac7038fcf22cb8e953787f9 Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:12:49 -0400 Subject: [PATCH 09/56] fix(bar): verify CCS Bar release archive digest (#1532) Verifies the GitHub release asset sha256 digest before extracting the CCS Bar archive. --- src/commands/bar/install-subcommand.ts | 62 +++++++--- tests/unit/commands/bar-command.test.ts | 148 ++++++++++++++++-------- 2 files changed, 148 insertions(+), 62 deletions(-) diff --git a/src/commands/bar/install-subcommand.ts b/src/commands/bar/install-subcommand.ts index f2d20790..a1fc58f8 100644 --- a/src/commands/bar/install-subcommand.ts +++ b/src/commands/bar/install-subcommand.ts @@ -36,11 +36,8 @@ const BAR_GITHUB_REPO = 'kaitranntt/ccs'; /** * Allowlist of hostnames from which we will accept asset downloads. * GitHub releases redirect from github.com to objects.githubusercontent.com. - * - * TODO(checksum-v2): once release assets ship a checksums.txt/.sha256 file, - * wire SHA-256 verification here. The download URL is already validated for - * host+HTTPS as a v1 minimum guard. The verifier hook below is the intended - * extension point. + * Artifact authenticity is enforced separately with the GitHub release asset + * SHA-256 digest before extraction. */ const DOWNLOAD_HOST_ALLOWLIST: ReadonlyArray = [ 'github.com', @@ -53,6 +50,7 @@ const DOWNLOAD_HOST_ALLOWLIST: ReadonlyArray = [ export interface ReleaseAssetResult { downloadUrl: string; + sha256: string; } export interface CompatResult { @@ -68,10 +66,10 @@ export interface InstallDeps { */ fetchReleaseAsset: (tag: string, asset: string) => Promise; /** - * Download the zip archive and extract the .app bundle into dest/. - * Production: uses undici to stream + extract (with redirect + status check). + * Download the zip archive, verify its SHA-256 digest, and extract the .app bundle into dest/. + * Production: uses undici to stream + verify + extract (with redirect + status check). */ - downloadAndExtract: (url: string, dest: string) => Promise; + downloadAndExtract: (url: string, dest: string, expectedSha256: string) => Promise; /** * GET {baseUrl}/api/bar/summary — capability handshake. * 200 → compatible; 404 → no-bar-api; else/unreachable → unreachable. @@ -190,7 +188,16 @@ async function defaultFetchReleaseAsset(tag: string, asset: string): Promise { +async function defaultDownloadAndExtract( + url: string, + dest: string, + expectedSha256: string +): Promise { const { request } = await import('undici'); - const { createWriteStream, mkdirSync } = fs; + const { createHash } = await import('crypto'); + const { createReadStream, createWriteStream, mkdirSync } = fs; const { promisify } = await import('util'); const { pipeline } = await import('stream'); const streamPipeline = promisify(pipeline); const { execFile } = await import('child_process'); const execFileAsync = promisify(execFile); - // Validate initial URL (Finding #9) + // Validate initial URL (Finding #9) and require an integrity pin. validateDownloadUrl(url); + if (!/^[a-fA-F0-9]{64}$/.test(expectedSha256)) { + throw new Error('Missing or invalid SHA-256 digest for CCS Bar archive. Refusing to install.'); + } mkdirSync(dest, { recursive: true }); @@ -263,10 +280,25 @@ async function defaultDownloadAndExtract(url: string, dest: string): Promise