mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
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:
1 parent
a472c44015
commit
499aec8a9b
13 files changed
+461
-5
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ export {
|
||||
setDefaultProfile,
|
||||
getWebSearchConfig,
|
||||
getGlobalEnvConfig,
|
||||
getOutputLimitsEnv,
|
||||
getContinuityInheritanceMap,
|
||||
getCliproxySafetyConfig,
|
||||
getThinkingConfig,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) */
|
||||
|
||||
@@ -94,6 +94,7 @@ export type { GeminiWebSearchInfo } from './loader/config-getters';
|
||||
export {
|
||||
getWebSearchConfig,
|
||||
getGlobalEnvConfig,
|
||||
getOutputLimitsEnv,
|
||||
getContinuityInheritanceMap,
|
||||
getCliproxySafetyConfig,
|
||||
getThinkingConfig,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user