feat(release): CLI flag passthrough, proxy fixes, and UI improvements (#239)

* fix(cliproxy): pass variant port to executor for isolation

Variants configured with dedicated ports (8318-8417) were not using
their assigned port. The executor always defaulted to 8317.

Changes:
- Add port field to ProfileDetectionResult interface
- Pass variant.port from profile-detector to ccs.ts
- Forward port to execClaudeWithCLIProxy options
- Update executor priority: CLI flags > variant port > config.yaml > default

Closes #228

* fix(cliproxy): propagate port in unified config and UI preset handlers

Edge case fixes identified in codebase review:
- Unified config variant detection: add settingsPath and port fields
- Provider editor: use variant port in handleApplyPreset/handleCustomPresetApply
- preset-utils: add optional port parameter to applyDefaultPreset()

* chore(release): 7.11.1-dev.1 [skip ci]

* fix(cliproxy): use correct default port (8317) for remote HTTP connections

Root cause: Inconsistent default port logic across code paths.
- Test Connection used 8317 (correct)
- Actual API calls used 80 (wrong)

Changes:
- Add centralized getRemoteDefaultPort() helper in config-generator.ts
- Fix proxy-target-resolver.ts to use shared helper
- Fix rewriteLocalhostUrls() and getRemoteEnvVars() in config-generator.ts
- Update remote-proxy-client.ts to use shared helper (DRY)

Fixes all 3 reported issues:
1. Port empty → now correctly uses :8317 instead of :80
2. BASEURL construction → now includes correct port
3. CLIProxy Plus auth → now fetches from remote on correct port

* chore(release): 7.11.1-dev.2 [skip ci]

* feat(delegation): add Claude Code CLI flag passthrough

Add explicit passthrough support for key Claude Code CLI flags:
- --max-turns: Limit agentic turns (prevents infinite loops)
- --fallback-model: Auto-fallback when model overloaded
- --agents: Dynamic subagent JSON injection
- --betas: Enable experimental features

Maintain extraArgs catch-all for future Claude Code flags.
Update help command with new "Delegation Flags" section.

Closes #89

* test(delegation): add comprehensive CLI flag passthrough tests

Add 45 test cases covering all edge cases for CLI flag passthrough:
- DelegationHandler: timeout/max-turns/fallback-model/agents/betas validation
- HeadlessExecutor: duplicate flag filtering, undefined vs truthy checks

* chore(release): 7.11.1-dev.3 [skip ci]

* fix(ui): enable cancel button during OAuth authentication

Resolves #234 - Cancel button was disabled during authentication flow,
preventing users from canceling the OAuth process.

Changes:
- Add auth-session-manager.ts for tracking active OAuth sessions
- Add POST /cliproxy/auth/:provider/cancel endpoint to abort sessions
- Kill spawned CLIProxy auth process when cancel is triggered
- Enable Cancel button in AddAccountDialog during authentication
- Add cancel support to QuickSetupWizard auth step
- Update useCancelAuth hook to call backend cancel endpoint

* chore(release): 7.11.1-dev.4 [skip ci]

* fix(prompt): add stdin.pause() to prevent process hang after password input

Fixes #236. The password() method called resume() on stdin but never
paused it in cleanup, keeping the event loop alive indefinitely.

* chore(release): 7.11.1-dev.5 [skip ci]

* feat(cliproxy): add --allow-self-signed flag for HTTPS connections (#227)

Previously, allowSelfSigned was hardcoded to true for all HTTPS protocol
connections, forcing use of the native https module which has issues with
Cloudflare-proxied connections causing timeouts.

This change:
- Adds --allow-self-signed CLI flag (default: false)
- Adds CCS_ALLOW_SELF_SIGNED environment variable
- Uses standard fetch API by default for HTTPS (works with valid certs)
- Only uses native https module when --allow-self-signed is specified

Usage:
- For production HTTPS proxies with valid certs: no flag needed
- For dev proxies with self-signed certs: use --allow-self-signed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

* chore(release): 7.11.1-dev.6 [skip ci]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Shun Kakinoki <39187513+shunkakinoki@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2025-12-31 16:36:18 -08:00
1 parent cf83425bcf
commit b3ef76a07b
26 files changed
+882 -55

No files matched your search

@@ -0,0 +1,211 @@
/**
* Tests for delegation-handler CLI flag passthrough feature
* Covers: parseStringFlag, timeout validation, max-turns validation, agents JSON validation
*/
import { describe, it, expect, beforeEach, afterEach, spyOn } from 'bun:test';
// Import the DelegationHandler class
import { DelegationHandler } from '../../../src/delegation/delegation-handler';
describe('DelegationHandler', () => {
let handler: DelegationHandler;
let consoleErrorSpy: ReturnType<typeof spyOn>;
beforeEach(() => {
handler = new DelegationHandler();
consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
describe('_extractOptions - timeout validation', () => {
it('accepts valid positive timeout', () => {
const options = handler._extractOptions(['glm', '-p', 'test', '--timeout', '30000']);
expect(options.timeout).toBe(30000);
});
it('rejects NaN timeout with warning', () => {
const options = handler._extractOptions(['glm', '-p', 'test', '--timeout', 'abc']);
expect(options.timeout).toBeUndefined();
expect(consoleErrorSpy).toHaveBeenCalled();
});
it('rejects negative timeout with warning', () => {
const options = handler._extractOptions(['glm', '-p', 'test', '--timeout', '-5000']);
expect(options.timeout).toBeUndefined();
expect(consoleErrorSpy).toHaveBeenCalled();
});
it('rejects zero timeout with warning', () => {
const options = handler._extractOptions(['glm', '-p', 'test', '--timeout', '0']);
expect(options.timeout).toBeUndefined();
expect(consoleErrorSpy).toHaveBeenCalled();
});
it('rejects timeout exceeding max (600000ms) with warning', () => {
const options = handler._extractOptions(['glm', '-p', 'test', '--timeout', '700000']);
expect(options.timeout).toBeUndefined();
expect(consoleErrorSpy).toHaveBeenCalled();
});
it('ignores missing timeout value at end of args', () => {
const options = handler._extractOptions(['glm', '-p', 'test', '--timeout']);
expect(options.timeout).toBeUndefined();
});
});
describe('_extractOptions - max-turns validation', () => {
it('accepts valid positive max-turns', () => {
const options = handler._extractOptions(['glm', '-p', 'test', '--max-turns', '10']);
expect(options.maxTurns).toBe(10);
});
it('rejects NaN max-turns with warning', () => {
const options = handler._extractOptions(['glm', '-p', 'test', '--max-turns', 'abc']);
expect(options.maxTurns).toBeUndefined();
expect(consoleErrorSpy).toHaveBeenCalled();
});
it('rejects negative max-turns with warning', () => {
const options = handler._extractOptions(['glm', '-p', 'test', '--max-turns', '-5']);
expect(options.maxTurns).toBeUndefined();
expect(consoleErrorSpy).toHaveBeenCalled();
});
it('rejects zero max-turns with warning', () => {
const options = handler._extractOptions(['glm', '-p', 'test', '--max-turns', '0']);
expect(options.maxTurns).toBeUndefined();
expect(consoleErrorSpy).toHaveBeenCalled();
});
it('caps max-turns at 100 when exceeding limit', () => {
const options = handler._extractOptions(['glm', '-p', 'test', '--max-turns', '500']);
expect(options.maxTurns).toBe(100);
expect(consoleErrorSpy).toHaveBeenCalled();
});
it('accepts max-turns at exactly 100', () => {
const options = handler._extractOptions(['glm', '-p', 'test', '--max-turns', '100']);
expect(options.maxTurns).toBe(100);
});
});
describe('_extractOptions - fallback-model validation', () => {
it('accepts valid fallback-model', () => {
const options = handler._extractOptions(['glm', '-p', 'test', '--fallback-model', 'sonnet']);
expect(options.fallbackModel).toBe('sonnet');
});
it('rejects dash-prefixed value with warning', () => {
const options = handler._extractOptions([
'glm',
'-p',
'test',
'--fallback-model',
'--other-flag',
]);
expect(options.fallbackModel).toBeUndefined();
expect(consoleErrorSpy).toHaveBeenCalled();
});
it('rejects empty string value', () => {
const options = handler._extractOptions(['glm', '-p', 'test', '--fallback-model', '']);
expect(options.fallbackModel).toBeUndefined();
});
it('rejects whitespace-only value', () => {
const options = handler._extractOptions(['glm', '-p', 'test', '--fallback-model', ' ']);
expect(options.fallbackModel).toBeUndefined();
});
});
describe('_extractOptions - agents JSON validation', () => {
it('accepts valid JSON for agents', () => {
const options = handler._extractOptions([
'glm',
'-p',
'test',
'--agents',
'{"name":"test"}',
]);
expect(options.agents).toBe('{"name":"test"}');
});
it('rejects invalid JSON with warning', () => {
const options = handler._extractOptions(['glm', '-p', 'test', '--agents', '{invalid json}']);
expect(options.agents).toBeUndefined();
expect(consoleErrorSpy).toHaveBeenCalled();
});
it('rejects dash-prefixed value', () => {
const options = handler._extractOptions(['glm', '-p', 'test', '--agents', '--other']);
expect(options.agents).toBeUndefined();
});
it('accepts JSON array for agents', () => {
const options = handler._extractOptions(['glm', '-p', 'test', '--agents', '[{"a":1}]']);
expect(options.agents).toBe('[{"a":1}]');
});
});
describe('_extractOptions - betas validation', () => {
it('accepts valid betas value', () => {
const options = handler._extractOptions(['glm', '-p', 'test', '--betas', 'feature1,feature2']);
expect(options.betas).toBe('feature1,feature2');
});
it('rejects dash-prefixed value', () => {
const options = handler._extractOptions(['glm', '-p', 'test', '--betas', '--feature']);
expect(options.betas).toBeUndefined();
});
});
describe('_extractOptions - extraArgs passthrough', () => {
it('passes unknown flags through to extraArgs', () => {
const options = handler._extractOptions([
'glm',
'-p',
'test',
'--unknown-flag',
'value',
]);
expect(options.extraArgs).toContain('--unknown-flag');
expect(options.extraArgs).toContain('value');
});
it('excludes CCS-handled flags from extraArgs', () => {
const options = handler._extractOptions([
'glm',
'-p',
'test',
'--max-turns',
'10',
'--unknown',
'val',
]);
expect(options.extraArgs).not.toContain('--max-turns');
expect(options.extraArgs).not.toContain('10');
expect(options.extraArgs).toContain('--unknown');
});
});
describe('_extractProfile', () => {
it('extracts profile name from first non-flag arg', () => {
const profile = handler._extractProfile(['glm', '-p', 'test']);
expect(profile).toBe('glm');
});
it('returns empty string when no profile found', () => {
const profile = handler._extractProfile(['-p', 'test']);
expect(profile).toBe('');
});
it('skips flag values correctly', () => {
const profile = handler._extractProfile(['-p', 'test', 'kimi']);
expect(profile).toBe('kimi');
});
});
});
@@ -0,0 +1,169 @@
/**
* Tests for headless-executor CLI flag passthrough feature
* Covers: duplicate flag filtering, undefined checks, flag construction
*/
import { describe, it, expect } from 'bun:test';
describe('HeadlessExecutor flag construction', () => {
// Test the flag construction logic by simulating the filtering behavior
// Since HeadlessExecutor.execute() spawns a process, we test the logic directly
describe('Duplicate flag filtering', () => {
function filterExtraArgs(
extraArgs: string[],
explicitFlags: Set<string>
): string[] {
const filteredExtras: string[] = [];
for (let i = 0; i < extraArgs.length; i++) {
if (explicitFlags.has(extraArgs[i])) {
// Skip this flag and its value (next element)
if (i + 1 < extraArgs.length && !extraArgs[i + 1].startsWith('-')) {
i++; // Skip value too
}
continue;
}
filteredExtras.push(extraArgs[i]);
}
return filteredExtras;
}
const explicitFlags = new Set(['--max-turns', '--fallback-model', '--agents', '--betas']);
it('removes duplicate --max-turns from extraArgs', () => {
const result = filterExtraArgs(['--max-turns', '10', '--verbose'], explicitFlags);
expect(result).not.toContain('--max-turns');
expect(result).not.toContain('10');
expect(result).toContain('--verbose');
});
it('removes duplicate --fallback-model from extraArgs', () => {
const result = filterExtraArgs(['--fallback-model', 'opus', '--debug'], explicitFlags);
expect(result).not.toContain('--fallback-model');
expect(result).not.toContain('opus');
expect(result).toContain('--debug');
});
it('removes duplicate --agents from extraArgs', () => {
const result = filterExtraArgs(['--agents', '{"x":1}', '--json'], explicitFlags);
expect(result).not.toContain('--agents');
expect(result).not.toContain('{"x":1}');
expect(result).toContain('--json');
});
it('removes duplicate --betas from extraArgs', () => {
const result = filterExtraArgs(['--betas', 'feature1', '--output', 'json'], explicitFlags);
expect(result).not.toContain('--betas');
expect(result).not.toContain('feature1');
expect(result).toContain('--output');
expect(result).toContain('json');
});
it('handles flag at end of array without value', () => {
const result = filterExtraArgs(['--verbose', '--max-turns'], explicitFlags);
expect(result).toContain('--verbose');
expect(result).not.toContain('--max-turns');
});
it('handles flag with dash-prefixed next arg (not a value)', () => {
const result = filterExtraArgs(['--max-turns', '--verbose'], explicitFlags);
// --max-turns is filtered, but --verbose is kept because it starts with -
expect(result).not.toContain('--max-turns');
expect(result).toContain('--verbose');
});
it('preserves unknown flags', () => {
const result = filterExtraArgs(['--custom-flag', 'value', '--another'], explicitFlags);
expect(result).toEqual(['--custom-flag', 'value', '--another']);
});
it('handles empty array', () => {
const result = filterExtraArgs([], explicitFlags);
expect(result).toEqual([]);
});
it('removes multiple duplicate flags', () => {
const result = filterExtraArgs(
['--max-turns', '5', '--agents', '{}', '--fallback-model', 'sonnet'],
explicitFlags
);
expect(result).toEqual([]);
});
});
describe('Flag undefined vs truthy checks', () => {
// Simulate the flag construction logic
function buildArgs(options: {
maxTurns?: number;
fallbackModel?: string;
agents?: string;
betas?: string;
}): string[] {
const args: string[] = [];
const { maxTurns, fallbackModel, agents, betas } = options;
if (maxTurns !== undefined && maxTurns > 0) {
args.push('--max-turns', String(maxTurns));
}
if (fallbackModel !== undefined && fallbackModel) {
args.push('--fallback-model', fallbackModel);
}
if (agents !== undefined && agents) {
args.push('--agents', agents);
}
if (betas !== undefined && betas) {
args.push('--betas', betas);
}
return args;
}
it('handles all undefined options', () => {
const args = buildArgs({});
expect(args).toEqual([]);
});
it('handles maxTurns = 0 (should not add flag)', () => {
const args = buildArgs({ maxTurns: 0 });
expect(args).toEqual([]);
});
it('handles maxTurns > 0', () => {
const args = buildArgs({ maxTurns: 10 });
expect(args).toEqual(['--max-turns', '10']);
});
it('handles empty string fallbackModel (should not add flag)', () => {
const args = buildArgs({ fallbackModel: '' });
expect(args).toEqual([]);
});
it('handles valid fallbackModel', () => {
const args = buildArgs({ fallbackModel: 'sonnet' });
expect(args).toEqual(['--fallback-model', 'sonnet']);
});
it('handles empty string agents (should not add flag)', () => {
const args = buildArgs({ agents: '' });
expect(args).toEqual([]);
});
it('handles valid JSON agents', () => {
const args = buildArgs({ agents: '{"name":"test"}' });
expect(args).toEqual(['--agents', '{"name":"test"}']);
});
it('handles all valid options', () => {
const args = buildArgs({
maxTurns: 5,
fallbackModel: 'opus',
agents: '{}',
betas: 'feature1',
});
expect(args).toContain('--max-turns');
expect(args).toContain('--fallback-model');
expect(args).toContain('--agents');
expect(args).toContain('--betas');
});
});
});