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.
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-06-15 22:16:54 -04:00
1 parent a472c44015
commit 499aec8a9b
13 files changed
+461 -5

No files matched your search

+23 -5
View File
@@ -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<string, string> {
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);
+9
View File
@@ -121,6 +121,15 @@ export function showConfigCommandHelp(): void {
console.log(' --provider-override <p> <t> <l> Set provider tier override');
console.log(' --clear-provider-override <p> [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)');
@@ -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<typeof import('../config-loader-facade')> {
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<string, string | undefined>;
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);
}
});
});
+1
View File
@@ -33,6 +33,7 @@ export {
setDefaultProfile,
getWebSearchConfig,
getGlobalEnvConfig,
getOutputLimitsEnv,
getContinuityInheritanceMap,
getCliproxySafetyConfig,
getThinkingConfig,
+14
View File
@@ -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<string, string> {
const config = getConfig();
return buildOutputLimitsEnv(config.runtime?.outputLimits);
}
/**
* Get continuity inheritance mapping.
* Returns empty mapping when not configured.
+4
View File
@@ -331,5 +331,9 @@ export function mergeWithDefaults(partial: Partial<UnifiedConfig>): 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,
};
}
+16
View File
@@ -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('# ----------------------------------------------------------------------------');
@@ -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');
});
});
+4
View File
@@ -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';
+83
View File
@@ -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<string, string> {
const env: Record<string, string> = {};
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;
}
+3
View File
@@ -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) */
+1
View File
@@ -94,6 +94,7 @@ export type { GeminiWebSearchInfo } from './loader/config-getters';
export {
getWebSearchConfig,
getGlobalEnvConfig,
getOutputLimitsEnv,
getContinuityInheritanceMap,
getCliproxySafetyConfig,
getThinkingConfig,
+6
View File
@@ -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);
}