mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 16:15:13 +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,
|
normalizeIFlowLegacyModelAliases,
|
||||||
normalizeModelIdForProvider,
|
normalizeModelIdForProvider,
|
||||||
} from '../ai-providers/model-id-normalizer';
|
} 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 */
|
/** Settings file structure for user overrides */
|
||||||
interface ProviderSettings {
|
interface ProviderSettings {
|
||||||
@@ -310,13 +314,21 @@ export function resolveProviderSettingsPath(provider: CLIProxyProvider): string
|
|||||||
/**
|
/**
|
||||||
* Get global env vars to inject into all third-party profiles.
|
* Get global env vars to inject into all third-party profiles.
|
||||||
* Returns empty object if disabled.
|
* 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> {
|
function getGlobalEnvVars(): Record<string, string> {
|
||||||
|
const outputLimitsEnv = getOutputLimitsEnv();
|
||||||
const globalEnvConfig = getGlobalEnvConfig();
|
const globalEnvConfig = getGlobalEnvConfig();
|
||||||
if (!globalEnvConfig.enabled) {
|
if (!globalEnvConfig.enabled) {
|
||||||
return {};
|
return { ...outputLimitsEnv };
|
||||||
}
|
}
|
||||||
return globalEnvConfig.env;
|
return { ...globalEnvConfig.env, ...outputLimitsEnv };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -465,7 +477,10 @@ export function getEffectiveEnvVars(
|
|||||||
migrateDeprecatedModelNames(expandedPath, provider, settings);
|
migrateDeprecatedModelNames(expandedPath, provider, settings);
|
||||||
// Migrate legacy iFlow placeholders to supported model IDs
|
// Migrate legacy iFlow placeholders to supported model IDs
|
||||||
migrateIFlowPlaceholderModel(expandedPath, provider, settings);
|
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 };
|
envVars = { ...globalEnv, ...settings.env };
|
||||||
// Ensure required vars are present (fall back to defaults if missing)
|
// Ensure required vars are present (fall back to defaults if missing)
|
||||||
envVars = ensureRequiredEnvVars(envVars, provider, port);
|
envVars = ensureRequiredEnvVars(envVars, provider, port);
|
||||||
@@ -498,7 +513,10 @@ export function getEffectiveEnvVars(
|
|||||||
migrateDeprecatedModelNames(settingsPath, provider, settings);
|
migrateDeprecatedModelNames(settingsPath, provider, settings);
|
||||||
// Migrate legacy iFlow placeholders to supported model IDs
|
// Migrate legacy iFlow placeholders to supported model IDs
|
||||||
migrateIFlowPlaceholderModel(settingsPath, provider, settings);
|
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 };
|
envVars = { ...globalEnv, ...settings.env };
|
||||||
// Ensure required vars are present (fall back to defaults if missing)
|
// Ensure required vars are present (fall back to defaults if missing)
|
||||||
envVars = ensureRequiredEnvVars(envVars, provider, port);
|
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(' --provider-override <p> <t> <l> Set provider tier override');
|
||||||
console.log(' --clear-provider-override <p> [t] Remove provider override');
|
console.log(' --clear-provider-override <p> [t] Remove provider override');
|
||||||
console.log('');
|
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('Options:');
|
||||||
console.log(' --port, -p PORT Specify server port (default: auto-detect)');
|
console.log(' --port, -p PORT Specify server port (default: auto-detect)');
|
||||||
console.log(' --host, -H HOST Bind dashboard server host (default: localhost)');
|
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,
|
setDefaultProfile,
|
||||||
getWebSearchConfig,
|
getWebSearchConfig,
|
||||||
getGlobalEnvConfig,
|
getGlobalEnvConfig,
|
||||||
|
getOutputLimitsEnv,
|
||||||
getContinuityInheritanceMap,
|
getContinuityInheritanceMap,
|
||||||
getCliproxySafetyConfig,
|
getCliproxySafetyConfig,
|
||||||
getThinkingConfig,
|
getThinkingConfig,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
DEFAULT_LOGGING_CONFIG,
|
DEFAULT_LOGGING_CONFIG,
|
||||||
DEFAULT_OFFICIAL_CHANNELS_CONFIG,
|
DEFAULT_OFFICIAL_CHANNELS_CONFIG,
|
||||||
DEFAULT_THINKING_CONFIG,
|
DEFAULT_THINKING_CONFIG,
|
||||||
|
buildOutputLimitsEnv,
|
||||||
} from '../unified-config-types';
|
} from '../unified-config-types';
|
||||||
import type {
|
import type {
|
||||||
BrowserConfig,
|
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.
|
* Get continuity inheritance mapping.
|
||||||
* Returns empty mapping when not configured.
|
* Returns empty mapping when not configured.
|
||||||
|
|||||||
@@ -331,5 +331,9 @@ export function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfi
|
|||||||
profile_backends:
|
profile_backends:
|
||||||
partial.image_analysis?.profile_backends ?? DEFAULT_IMAGE_ANALYSIS_CONFIG.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('');
|
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
|
// Official Channels section
|
||||||
if (config.channels) {
|
if (config.channels) {
|
||||||
lines.push('# ----------------------------------------------------------------------------');
|
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 { DEFAULT_THINKING_TIER_DEFAULTS, DEFAULT_THINKING_CONFIG } from './thinking';
|
||||||
export type { ThinkingMode, ThinkingTierDefaults, ThinkingConfig } 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
|
// Official channels types and defaults
|
||||||
export { DEFAULT_OFFICIAL_CHANNELS_CONFIG } from './channels';
|
export { DEFAULT_OFFICIAL_CHANNELS_CONFIG } from './channels';
|
||||||
export type { OfficialChannelId, OfficialChannelsConfig } 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 { DEFAULT_QUOTA_MANAGEMENT_CONFIG } from './quota';
|
||||||
import type { ThinkingConfig } from './thinking';
|
import type { ThinkingConfig } from './thinking';
|
||||||
import { DEFAULT_THINKING_CONFIG } from './thinking';
|
import { DEFAULT_THINKING_CONFIG } from './thinking';
|
||||||
|
import type { RuntimeConfig } from './runtime';
|
||||||
import type { OfficialChannelsConfig } from './channels';
|
import type { OfficialChannelsConfig } from './channels';
|
||||||
import { DEFAULT_OFFICIAL_CHANNELS_CONFIG } from './channels';
|
import { DEFAULT_OFFICIAL_CHANNELS_CONFIG } from './channels';
|
||||||
import type { BrowserConfig } from './browser';
|
import type { BrowserConfig } from './browser';
|
||||||
@@ -78,6 +79,8 @@ export interface UnifiedConfig {
|
|||||||
quota_management?: QuotaManagementConfig;
|
quota_management?: QuotaManagementConfig;
|
||||||
/** Thinking/reasoning budget configuration (v8+) */
|
/** Thinking/reasoning budget configuration (v8+) */
|
||||||
thinking?: ThinkingConfig;
|
thinking?: ThinkingConfig;
|
||||||
|
/** Runtime (spawned-CLI) configuration, e.g. opt-in output limits (issue #231) */
|
||||||
|
runtime?: RuntimeConfig;
|
||||||
/** Official Channels runtime auto-enable preferences (v11+) */
|
/** Official Channels runtime auto-enable preferences (v11+) */
|
||||||
channels?: OfficialChannelsConfig;
|
channels?: OfficialChannelsConfig;
|
||||||
/** Dashboard authentication configuration (optional) */
|
/** Dashboard authentication configuration (optional) */
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export type { GeminiWebSearchInfo } from './loader/config-getters';
|
|||||||
export {
|
export {
|
||||||
getWebSearchConfig,
|
getWebSearchConfig,
|
||||||
getGlobalEnvConfig,
|
getGlobalEnvConfig,
|
||||||
|
getOutputLimitsEnv,
|
||||||
getContinuityInheritanceMap,
|
getContinuityInheritanceMap,
|
||||||
getCliproxySafetyConfig,
|
getCliproxySafetyConfig,
|
||||||
getThinkingConfig,
|
getThinkingConfig,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '../utils/shell-executor';
|
} from '../utils/shell-executor';
|
||||||
import { ErrorManager } from '../utils/error-manager';
|
import { ErrorManager } from '../utils/error-manager';
|
||||||
import { getWebSearchHookEnv } from '../utils/websearch-manager';
|
import { getWebSearchHookEnv } from '../utils/websearch-manager';
|
||||||
|
import { getOutputLimitsEnv } from '../config/config-loader-facade';
|
||||||
import { appendBrowserToolArgs } from '../utils/browser';
|
import { appendBrowserToolArgs } from '../utils/browser';
|
||||||
import { wireChildProcessSignals } from '../utils/signal-forwarder';
|
import { wireChildProcessSignals } from '../utils/signal-forwarder';
|
||||||
import { runCleanup } from '../errors';
|
import { runCleanup } from '../errors';
|
||||||
@@ -77,6 +78,11 @@ export class ClaudeAdapter implements TargetAdapter {
|
|||||||
if (creds.apiKey) env['ANTHROPIC_AUTH_TOKEN'] = creds.apiKey;
|
if (creds.apiKey) env['ANTHROPIC_AUTH_TOKEN'] = creds.apiKey;
|
||||||
if (creds.model) env['ANTHROPIC_MODEL'] = creds.model;
|
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);
|
return stripClaudeCodeEnv(env);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user