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] 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); }