mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 18:16:29 +00:00
Merge pull request #1417 from kaitranntt/codex/propose-fix-for-proxy-validation-issue
fix: validate browser flags before proxy side effects
This commit is contained in:
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, jest } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as http from 'http';
|
||||
import { execClaudeWithCLIProxy, hasGitLabTokenLoginFlag, readOptionValue } from '../index';
|
||||
|
||||
describe('readOptionValue', () => {
|
||||
@@ -57,18 +58,84 @@ describe('execClaudeWithCLIProxy browser flag validation', () => {
|
||||
fs.writeFileSync(fakeClaudePath, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
||||
fs.chmodSync(fakeClaudePath, 0o755);
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.exitCode = 0;
|
||||
process.env.CCS_HOME = tmpHome;
|
||||
});
|
||||
|
||||
async function waitForFile(filePath: string): Promise<boolean> {
|
||||
const deadline = Date.now() + 2000;
|
||||
while (Date.now() < deadline) {
|
||||
if (fs.existsSync(filePath)) return true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
return fs.existsSync(filePath);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
process.exitCode = 0;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('validates conflicting browser launch flags before remote proxy checks', async () => {
|
||||
let requestCount = 0;
|
||||
const server = http.createServer((_req, res) => {
|
||||
requestCount += 1;
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end('{"ok":true}');
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close();
|
||||
throw new Error('Test server did not bind to a TCP port');
|
||||
}
|
||||
|
||||
const exitSpy = jest
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined as never) as typeof process.exit);
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
await execClaudeWithCLIProxy(
|
||||
fakeClaudePath,
|
||||
'gemini',
|
||||
[
|
||||
'--proxy-host',
|
||||
'127.0.0.1',
|
||||
'--proxy-port',
|
||||
String(address.port),
|
||||
'--proxy-auth-token',
|
||||
'SECRET_TOKEN_FOR_VALIDATION',
|
||||
'--remote-only',
|
||||
'--browser',
|
||||
'--no-browser',
|
||||
],
|
||||
{}
|
||||
);
|
||||
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'[X] Use either `--browser` or `--no-browser`, not both.'
|
||||
);
|
||||
expect(requestCount).toBe(0);
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('exits cleanly when conflicting browser launch flags are provided', async () => {
|
||||
const exitSpy = jest
|
||||
.spyOn(process, 'exit')
|
||||
@@ -87,4 +154,69 @@ describe('execClaudeWithCLIProxy browser flag validation', () => {
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not treat a stale global exitCode as a current parse failure', async () => {
|
||||
const markerPath = path.join(tmpHome, 'fake-claude-launched');
|
||||
fs.writeFileSync(
|
||||
fakeClaudePath,
|
||||
`#!/bin/sh\nprintf launched > ${JSON.stringify(markerPath)}\nexit 0\n`,
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
fs.chmodSync(fakeClaudePath, 0o755);
|
||||
|
||||
let requestCount = 0;
|
||||
const server = http.createServer((_req, res) => {
|
||||
requestCount += 1;
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end('{"ok":true}');
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close();
|
||||
throw new Error('Test server did not bind to a TCP port');
|
||||
}
|
||||
|
||||
const exitSpy = jest
|
||||
.spyOn(process, 'exit')
|
||||
.mockImplementation((() => undefined as never) as typeof process.exit);
|
||||
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
process.exitCode = 1;
|
||||
|
||||
await execClaudeWithCLIProxy(
|
||||
fakeClaudePath,
|
||||
'gemini',
|
||||
[
|
||||
'--proxy-host',
|
||||
'127.0.0.1',
|
||||
'--proxy-port',
|
||||
String(address.port),
|
||||
'--proxy-auth-token',
|
||||
'SECRET_TOKEN_FOR_VALIDATION',
|
||||
'--remote-only',
|
||||
'--print',
|
||||
'hello',
|
||||
],
|
||||
{}
|
||||
);
|
||||
|
||||
expect(await waitForFile(markerPath)).toBe(true);
|
||||
expect(requestCount).toBeGreaterThan(0);
|
||||
expect(exitSpy).toHaveBeenCalledWith(0);
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
logSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* logic extracted from executor/index.ts.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, jest } from 'bun:test';
|
||||
import { afterEach, beforeEach, describe, expect, it, jest } from 'bun:test';
|
||||
import type { ResolveExecutorProxyContext } from '../proxy-resolver';
|
||||
import type { ExecutorConfig } from '../../types';
|
||||
import type { UnifiedConfig } from '../../../config/schemas/unified-config';
|
||||
@@ -15,11 +15,29 @@ import type { UnifiedConfig } from '../../../config/schemas/unified-config';
|
||||
const mockEnsureCLIProxyBinary = jest.fn().mockResolvedValue('/usr/local/bin/cliproxy');
|
||||
const mockGetConfiguredBackend = jest.fn().mockReturnValue('original');
|
||||
const mockGetPlusBackendUnavailableMessage = jest.fn().mockReturnValue('Plus backend unavailable');
|
||||
const mockInstallCliproxyVersion = jest.fn().mockResolvedValue(undefined);
|
||||
const mockFetchLatestCliproxyVersion = jest.fn().mockResolvedValue('test-version');
|
||||
const mockCheckCliproxyUpdate = jest.fn().mockResolvedValue({ available: false });
|
||||
|
||||
jest.mock('../../binary-manager', () => ({
|
||||
ensureCLIProxyBinary: mockEnsureCLIProxyBinary,
|
||||
getConfiguredBackend: mockGetConfiguredBackend,
|
||||
getPlusBackendUnavailableMessage: mockGetPlusBackendUnavailableMessage,
|
||||
getStoredConfiguredBackend: mockGetConfiguredBackend,
|
||||
getCLIProxyPath: jest.fn().mockReturnValue('/usr/local/bin/cliproxy'),
|
||||
getInstalledCliproxyVersion: jest.fn().mockReturnValue('test-version'),
|
||||
isCLIProxyInstalled: jest.fn().mockReturnValue(true),
|
||||
resolveLocalBackend: mockGetConfiguredBackend,
|
||||
syncPlusFallbackStateIfNeeded: jest.fn(),
|
||||
installCliproxyVersion: mockInstallCliproxyVersion,
|
||||
fetchLatestCliproxyVersion: mockFetchLatestCliproxyVersion,
|
||||
checkCliproxyUpdate: mockCheckCliproxyUpdate,
|
||||
getPinnedVersion: jest.fn().mockReturnValue(null),
|
||||
savePinnedVersion: jest.fn(),
|
||||
clearPinnedVersion: jest.fn(),
|
||||
isVersionPinned: jest.fn().mockReturnValue(false),
|
||||
getVersionPinPath: jest.fn().mockReturnValue('/tmp/cliproxy-version-pin'),
|
||||
BinaryManager: class {},
|
||||
}));
|
||||
|
||||
const mockCheckRemoteProxy = jest.fn();
|
||||
@@ -30,21 +48,28 @@ jest.mock('../../services/remote-proxy-client', () => ({
|
||||
jest.mock('../retry-handler', () => ({
|
||||
isNetworkError: jest.fn().mockReturnValue(false),
|
||||
handleNetworkError: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockResolveProxyConfig = jest.fn();
|
||||
jest.mock('../../proxy/proxy-config-resolver', () => ({
|
||||
resolveProxyConfig: mockResolveProxyConfig,
|
||||
}));
|
||||
|
||||
jest.mock('../../config/config-generator', () => ({
|
||||
CLIPROXY_DEFAULT_PORT: 8317,
|
||||
validatePort: jest.fn((port: number | undefined) => port ?? 8317),
|
||||
handleTokenExpiration: jest.fn(),
|
||||
handleQuotaCheck: jest.fn(),
|
||||
PROVIDER_ERROR_PATTERNS: [],
|
||||
detectFailedTier: jest.fn().mockReturnValue(null),
|
||||
isProviderError: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
// ── Import after mocks ────────────────────────────────────────────────────────
|
||||
|
||||
const { resolveExecutorProxy } = await import('../proxy-resolver');
|
||||
const { resolveExecutorProxy, resolveExecutorProxyConfig } = await import('../proxy-resolver');
|
||||
|
||||
const PROXY_ENV_KEYS = [
|
||||
'CCS_PROXY_HOST',
|
||||
'CCS_PROXY_PORT',
|
||||
'CCS_PROXY_PROTOCOL',
|
||||
'CCS_PROXY_AUTH_TOKEN',
|
||||
'CCS_PROXY_TIMEOUT',
|
||||
'CCS_PROXY_FALLBACK_ENABLED',
|
||||
'CCS_ALLOW_SELF_SIGNED',
|
||||
] as const;
|
||||
|
||||
let proxyEnvSnapshot: Record<string, string | undefined> = {};
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -76,51 +101,37 @@ function makeContext(
|
||||
};
|
||||
}
|
||||
|
||||
/** Mock resolveProxyConfig to return a local-mode config */
|
||||
function mockLocalProxyConfig(remainingArgs: string[] = []): void {
|
||||
mockResolveProxyConfig.mockReturnValue({
|
||||
config: {
|
||||
mode: 'local',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
fallbackEnabled: false,
|
||||
autoStartLocal: false,
|
||||
remoteOnly: false,
|
||||
forceLocal: true,
|
||||
},
|
||||
remainingArgs,
|
||||
});
|
||||
}
|
||||
|
||||
/** Mock resolveProxyConfig to return a remote-mode config */
|
||||
function mockRemoteProxyConfig(remainingArgs: string[] = []): void {
|
||||
mockResolveProxyConfig.mockReturnValue({
|
||||
config: {
|
||||
mode: 'remote',
|
||||
host: '192.168.1.100',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
fallbackEnabled: false,
|
||||
autoStartLocal: false,
|
||||
remoteOnly: false,
|
||||
forceLocal: false,
|
||||
},
|
||||
remainingArgs,
|
||||
});
|
||||
async function resolveProxyForTest(args: string[], context = makeContext()) {
|
||||
const resolvedConfig = resolveExecutorProxyConfig(args, context);
|
||||
return resolveExecutorProxy(resolvedConfig, context);
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
proxyEnvSnapshot = Object.fromEntries(PROXY_ENV_KEYS.map((key) => [key, process.env[key]]));
|
||||
for (const key of PROXY_ENV_KEYS) {
|
||||
delete process.env[key];
|
||||
}
|
||||
mockEnsureCLIProxyBinary.mockResolvedValue('/usr/local/bin/cliproxy');
|
||||
mockGetConfiguredBackend.mockReturnValue('original');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of PROXY_ENV_KEYS) {
|
||||
const value = proxyEnvSnapshot[key];
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe('resolveExecutorProxy — local mode', () => {
|
||||
it('returns useRemoteProxy=false and correct binary for local mode', async () => {
|
||||
mockLocalProxyConfig(['--verbose']);
|
||||
const result = await resolveExecutorProxy(['--verbose'], makeContext());
|
||||
const result = await resolveProxyForTest(['--local-proxy', '--verbose']);
|
||||
|
||||
expect(result.useRemoteProxy).toBe(false);
|
||||
expect(result.localBackend).toBe('original');
|
||||
@@ -129,16 +140,14 @@ describe('resolveExecutorProxy — local mode', () => {
|
||||
});
|
||||
|
||||
it('strips proxy flags and passes remainingArgs through', async () => {
|
||||
mockLocalProxyConfig(['clean-arg']);
|
||||
const result = await resolveExecutorProxy(['--local-proxy', 'clean-arg'], makeContext());
|
||||
const result = await resolveProxyForTest(['--local-proxy', 'clean-arg']);
|
||||
|
||||
expect(result.argsWithoutProxy).toEqual(['clean-arg']);
|
||||
expect(result.useRemoteProxy).toBe(false);
|
||||
});
|
||||
|
||||
it('does not call checkRemoteProxy in local mode', async () => {
|
||||
mockLocalProxyConfig();
|
||||
await resolveExecutorProxy([], makeContext());
|
||||
await resolveProxyForTest(['--local-proxy']);
|
||||
|
||||
expect(mockCheckRemoteProxy).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -146,19 +155,17 @@ describe('resolveExecutorProxy — local mode', () => {
|
||||
|
||||
describe('resolveExecutorProxy — remote mode reachable', () => {
|
||||
it('returns useRemoteProxy=true when remote proxy is reachable', async () => {
|
||||
mockRemoteProxyConfig();
|
||||
mockCheckRemoteProxy.mockResolvedValue({ reachable: true, latencyMs: 12, error: undefined });
|
||||
|
||||
const result = await resolveExecutorProxy([], makeContext());
|
||||
const result = await resolveProxyForTest(['--proxy-host', '192.168.1.100']);
|
||||
|
||||
expect(result.useRemoteProxy).toBe(true);
|
||||
});
|
||||
|
||||
it('skips binary acquisition when remote proxy is reachable', async () => {
|
||||
mockRemoteProxyConfig();
|
||||
mockCheckRemoteProxy.mockResolvedValue({ reachable: true, latencyMs: 5, error: undefined });
|
||||
|
||||
const result = await resolveExecutorProxy([], makeContext());
|
||||
const result = await resolveProxyForTest(['--proxy-host', '192.168.1.100']);
|
||||
|
||||
expect(result.binaryPath).toBeUndefined();
|
||||
expect(mockEnsureCLIProxyBinary).not.toHaveBeenCalled();
|
||||
@@ -167,65 +174,27 @@ describe('resolveExecutorProxy — remote mode reachable', () => {
|
||||
|
||||
describe('resolveExecutorProxy — remote mode unreachable', () => {
|
||||
it('throws expected message when remoteOnly=true and remote is unreachable', async () => {
|
||||
mockResolveProxyConfig.mockReturnValue({
|
||||
config: {
|
||||
mode: 'remote',
|
||||
host: '192.168.1.100',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
fallbackEnabled: false,
|
||||
autoStartLocal: false,
|
||||
remoteOnly: true,
|
||||
forceLocal: false,
|
||||
},
|
||||
remainingArgs: [],
|
||||
});
|
||||
mockCheckRemoteProxy.mockResolvedValue({ reachable: false, error: 'Connection refused' });
|
||||
|
||||
await expect(resolveExecutorProxy([], makeContext())).rejects.toThrow(
|
||||
'Remote proxy unreachable and --remote-only specified'
|
||||
);
|
||||
await expect(
|
||||
resolveProxyForTest(['--proxy-host', '192.168.1.100', '--remote-only'])
|
||||
).rejects.toThrow('Remote proxy unreachable and --remote-only specified');
|
||||
});
|
||||
|
||||
it('throws when fallback disabled and remote is unreachable', async () => {
|
||||
mockResolveProxyConfig.mockReturnValue({
|
||||
config: {
|
||||
mode: 'remote',
|
||||
host: '192.168.1.100',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
fallbackEnabled: false,
|
||||
autoStartLocal: false,
|
||||
remoteOnly: false,
|
||||
forceLocal: false,
|
||||
},
|
||||
remainingArgs: [],
|
||||
});
|
||||
process.env.CCS_PROXY_FALLBACK_ENABLED = '0';
|
||||
mockCheckRemoteProxy.mockResolvedValue({ reachable: false, error: 'Timeout' });
|
||||
|
||||
await expect(resolveExecutorProxy([], makeContext())).rejects.toThrow(
|
||||
await expect(resolveProxyForTest(['--proxy-host', '192.168.1.100'])).rejects.toThrow(
|
||||
'Remote proxy unreachable and fallback disabled'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to local and acquires binary when autoStartLocal=true', async () => {
|
||||
mockResolveProxyConfig.mockReturnValue({
|
||||
config: {
|
||||
mode: 'remote',
|
||||
host: '192.168.1.100',
|
||||
port: 8317,
|
||||
protocol: 'http',
|
||||
fallbackEnabled: true,
|
||||
autoStartLocal: true,
|
||||
remoteOnly: false,
|
||||
forceLocal: false,
|
||||
},
|
||||
remainingArgs: [],
|
||||
});
|
||||
mockCheckRemoteProxy.mockResolvedValue({ reachable: false, error: 'Timeout' });
|
||||
mockEnsureCLIProxyBinary.mockResolvedValue('/usr/local/bin/cliproxy');
|
||||
|
||||
const result = await resolveExecutorProxy([], makeContext());
|
||||
const result = await resolveProxyForTest(['--proxy-host', '192.168.1.100']);
|
||||
|
||||
expect(result.useRemoteProxy).toBe(false);
|
||||
expect(result.binaryPath).toBe('/usr/local/bin/cliproxy');
|
||||
@@ -235,9 +204,7 @@ describe('resolveExecutorProxy — remote mode unreachable', () => {
|
||||
|
||||
describe('resolveExecutorProxy — proxyConfig propagated in result', () => {
|
||||
it('returns the resolved proxyConfig object', async () => {
|
||||
mockLocalProxyConfig();
|
||||
|
||||
const result = await resolveExecutorProxy([], makeContext());
|
||||
const result = await resolveProxyForTest(['--local-proxy']);
|
||||
|
||||
expect(result.proxyConfig).toBeDefined();
|
||||
expect(result.proxyConfig.mode).toBe('local');
|
||||
@@ -245,10 +212,9 @@ describe('resolveExecutorProxy — proxyConfig propagated in result', () => {
|
||||
});
|
||||
|
||||
it('returns mutated cfg with validated port', async () => {
|
||||
mockLocalProxyConfig();
|
||||
const ctx = makeContext();
|
||||
|
||||
const result = await resolveExecutorProxy([], ctx);
|
||||
const result = await resolveProxyForTest(['--local-proxy'], ctx);
|
||||
|
||||
// cfg is mutated in place and also returned
|
||||
expect(result.cfg).toBe(ctx.cfg);
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
* - parseExecutorFlags() — flag extraction block (lines ~411-639 in original)
|
||||
* - validateFlagCombinations() — cross-flag guard block (lines ~531-585)
|
||||
*
|
||||
* IMPORTANT: process.exit semantics are kept identical to original index.ts.
|
||||
* IMPORTANT: process.exit semantics are kept identical to original index.ts,
|
||||
* with explicit parseFailed/validation return state for callers that must not
|
||||
* depend on ambient process.exitCode.
|
||||
* All console.error messages are byte-identical.
|
||||
*/
|
||||
|
||||
@@ -153,6 +155,7 @@ export function filterCcsFlags(args: string[]): string[] {
|
||||
|
||||
/** Result of parsing CCS executor flags from args. */
|
||||
export interface ParsedExecutorFlags {
|
||||
parseFailed?: boolean;
|
||||
forceAuth: boolean;
|
||||
pasteCallback: boolean;
|
||||
portForward: boolean;
|
||||
@@ -181,7 +184,7 @@ export interface ParsedExecutorFlags {
|
||||
/**
|
||||
* Parse all CCS executor flags from args.
|
||||
*
|
||||
* Exits with code 1 (process.exitCode = 1 + return) on invalid flag values.
|
||||
* Exits with code 1 (process.exitCode = 1 + parseFailed return) on invalid flag values.
|
||||
* Exits with process.exit(1) on conflicting flag combinations — identical to
|
||||
* the original index.ts behavior.
|
||||
*
|
||||
@@ -247,7 +250,7 @@ export function parseExecutorFlags(
|
||||
console.error(fail('--kiro-auth-method requires a value'));
|
||||
console.error(' Supported values: aws, aws-authcode, google, github, idc');
|
||||
process.exitCode = 1;
|
||||
// Caller must check process.exitCode = 1 and bail — matching original return behavior
|
||||
// Caller must check parseFailed and bail — matching original return behavior
|
||||
return buildPartialFlags({
|
||||
forceAuth,
|
||||
pasteCallback,
|
||||
@@ -272,6 +275,7 @@ export function parseExecutorFlags(
|
||||
gitlabBaseUrl: undefined,
|
||||
extendedContextOverride: undefined,
|
||||
thinkingParse: parseThinkingOverride(args),
|
||||
parseFailed: true,
|
||||
});
|
||||
}
|
||||
const normalized = rawMethod.trim().toLowerCase();
|
||||
@@ -303,6 +307,7 @@ export function parseExecutorFlags(
|
||||
gitlabBaseUrl: undefined,
|
||||
extendedContextOverride: undefined,
|
||||
thinkingParse: parseThinkingOverride(args),
|
||||
parseFailed: true,
|
||||
});
|
||||
}
|
||||
kiroAuthMethod = normalizeKiroAuthMethod(normalized);
|
||||
@@ -339,6 +344,7 @@ export function parseExecutorFlags(
|
||||
gitlabBaseUrl: undefined,
|
||||
extendedContextOverride: undefined,
|
||||
thinkingParse: parseThinkingOverride(args),
|
||||
parseFailed: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -373,6 +379,7 @@ export function parseExecutorFlags(
|
||||
gitlabBaseUrl: undefined,
|
||||
extendedContextOverride: undefined,
|
||||
thinkingParse: parseThinkingOverride(args),
|
||||
parseFailed: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -408,6 +415,7 @@ export function parseExecutorFlags(
|
||||
gitlabBaseUrl: undefined,
|
||||
extendedContextOverride: undefined,
|
||||
thinkingParse: parseThinkingOverride(args),
|
||||
parseFailed: true,
|
||||
});
|
||||
}
|
||||
const normalized = rawFlow.trim().toLowerCase();
|
||||
@@ -439,6 +447,7 @@ export function parseExecutorFlags(
|
||||
gitlabBaseUrl: undefined,
|
||||
extendedContextOverride: undefined,
|
||||
thinkingParse: parseThinkingOverride(args),
|
||||
parseFailed: true,
|
||||
});
|
||||
}
|
||||
kiroIDCFlow = normalizeKiroIDCFlow(normalized);
|
||||
@@ -475,6 +484,7 @@ export function parseExecutorFlags(
|
||||
gitlabBaseUrl: undefined,
|
||||
extendedContextOverride: undefined,
|
||||
thinkingParse: parseThinkingOverride(args),
|
||||
parseFailed: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -538,6 +548,7 @@ export function parseExecutorFlags(
|
||||
gitlabBaseUrl,
|
||||
extendedContextOverride,
|
||||
thinkingParse,
|
||||
parseFailed: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -550,8 +561,8 @@ function buildPartialFlags(fields: ParsedExecutorFlags): ParsedExecutorFlags {
|
||||
|
||||
/**
|
||||
* Validate flag combinations that are mutually exclusive or provider-scoped.
|
||||
* Calls process.exit(1) on any violation — identical to original index.ts.
|
||||
* Call AFTER parseExecutorFlags() and only if process.exitCode is still 0.
|
||||
* Sets process.exitCode=1 and returns false on any violation.
|
||||
* Call AFTER parseExecutorFlags() and only if parseFailed is false.
|
||||
*
|
||||
* @param parsed Result of parseExecutorFlags()
|
||||
* @param context Provider context (provider string + compositeProviders list)
|
||||
@@ -561,7 +572,7 @@ export function validateFlagCombinations(
|
||||
parsed: ParsedExecutorFlags,
|
||||
context: { provider: string; compositeProviders: string[] },
|
||||
args: string[]
|
||||
): void {
|
||||
): boolean {
|
||||
const { provider, compositeProviders } = context;
|
||||
const {
|
||||
kiroAuthMethod,
|
||||
@@ -575,7 +586,7 @@ export function validateFlagCombinations(
|
||||
if (kiroAuthMethod && provider !== 'kiro' && !compositeProviders.includes('kiro')) {
|
||||
console.error(fail('--kiro-auth-method is only valid for ccs kiro'));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -589,7 +600,7 @@ export function validateFlagCombinations(
|
||||
)
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (kiroAuthMethod === 'idc' && !kiroIDCStartUrl) {
|
||||
@@ -598,7 +609,7 @@ export function validateFlagCombinations(
|
||||
' Example: ccs kiro --auth --kiro-auth-method idc --kiro-idc-start-url https://d-xxx.awsapps.com/start'
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -612,13 +623,15 @@ export function validateFlagCombinations(
|
||||
)
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((gitlabTokenLogin || gitlabBaseUrl) && provider !== 'gitlab') {
|
||||
const flagName = gitlabTokenLogin ? getGitLabTokenLoginFlagName(args) : '--gitlab-url';
|
||||
console.error(fail(`${flagName} is only valid for ccs gitlab`));
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* 4. Browser MCP ensure + sync-to-config-dir
|
||||
*/
|
||||
|
||||
import { warn } from '../../utils/ui';
|
||||
import { fail, warn } from '../../utils/ui';
|
||||
import {
|
||||
type BrowserLaunchOverride,
|
||||
ensureBrowserMcpOrThrow,
|
||||
@@ -41,6 +41,7 @@ export interface BrowserLaunchSetupResult {
|
||||
export function resolveBrowserLaunchFlags(argsWithoutProxy: string[]): {
|
||||
browserLaunchOverride: BrowserLaunchOverride | undefined;
|
||||
argsWithoutBrowserFlags: string[];
|
||||
parseFailed: boolean;
|
||||
} {
|
||||
let browserLaunchOverride: BrowserLaunchOverride | undefined;
|
||||
let argsWithoutBrowserFlags = argsWithoutProxy;
|
||||
@@ -49,9 +50,10 @@ export function resolveBrowserLaunchFlags(argsWithoutProxy: string[]): {
|
||||
browserLaunchOverride = browserLaunchFlags.override;
|
||||
argsWithoutBrowserFlags = browserLaunchFlags.argsWithoutFlags;
|
||||
} catch (error) {
|
||||
console.error(warn((error as Error).message));
|
||||
console.error(fail((error as Error).message));
|
||||
process.exitCode = 1;
|
||||
process.exit(1);
|
||||
return { browserLaunchOverride: undefined, argsWithoutBrowserFlags };
|
||||
return { browserLaunchOverride: undefined, argsWithoutBrowserFlags, parseFailed: true };
|
||||
}
|
||||
|
||||
const browserConfig = getBrowserConfig();
|
||||
@@ -71,7 +73,7 @@ export function resolveBrowserLaunchFlags(argsWithoutProxy: string[]): {
|
||||
console.error(warn(blockedBrowserOverrideWarning));
|
||||
}
|
||||
|
||||
return { browserLaunchOverride, argsWithoutBrowserFlags };
|
||||
return { browserLaunchOverride, argsWithoutBrowserFlags, parseFailed: false };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -63,7 +63,7 @@ import {
|
||||
} from './thinking-override-resolver';
|
||||
import { shouldStartHttpsTunnel } from './https-tunnel-policy';
|
||||
import { filterCcsFlags, parseExecutorFlags, validateFlagCombinations } from './arg-parser';
|
||||
import { resolveExecutorProxy } from './proxy-resolver';
|
||||
import { resolveExecutorProxy, resolveExecutorProxyConfig } from './proxy-resolver';
|
||||
import { buildProxyChain } from './proxy-chain-builder';
|
||||
import { warnBrokenModels } from './model-warnings';
|
||||
import { launchClaude } from './claude-launcher';
|
||||
@@ -129,8 +129,23 @@ export async function execClaudeWithCLIProxy(
|
||||
// Collect all providers to validate (default + composite tiers)
|
||||
const allProviders = [provider, ...compositeProviders];
|
||||
|
||||
const proxyResolution = resolveExecutorProxyConfig(args, {
|
||||
unifiedConfig,
|
||||
allProviders,
|
||||
verbose,
|
||||
cfg,
|
||||
log,
|
||||
});
|
||||
|
||||
const {
|
||||
browserLaunchOverride,
|
||||
argsWithoutBrowserFlags,
|
||||
parseFailed: browserLaunchParseFailed,
|
||||
} = resolveBrowserLaunchFlags(proxyResolution.argsWithoutProxy);
|
||||
if (browserLaunchParseFailed) return;
|
||||
|
||||
const { proxyConfig, useRemoteProxy, localBackend, binaryPath, argsWithoutProxy } =
|
||||
await resolveExecutorProxy(args, {
|
||||
await resolveExecutorProxy(proxyResolution, {
|
||||
unifiedConfig,
|
||||
allProviders,
|
||||
verbose,
|
||||
@@ -138,9 +153,6 @@ export async function execClaudeWithCLIProxy(
|
||||
log,
|
||||
});
|
||||
|
||||
const { browserLaunchOverride, argsWithoutBrowserFlags } =
|
||||
resolveBrowserLaunchFlags(argsWithoutProxy);
|
||||
|
||||
// Setup first-class CCS WebSearch runtime
|
||||
ensureWebSearchMcpOrThrow();
|
||||
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
|
||||
@@ -158,11 +170,15 @@ export async function execClaudeWithCLIProxy(
|
||||
compositeProviders,
|
||||
unifiedConfig,
|
||||
});
|
||||
if (process.exitCode === 1) return;
|
||||
if (parsedFlags.parseFailed) return;
|
||||
|
||||
// Validate cross-flag combinations (exits with code 1 on violation)
|
||||
validateFlagCombinations(parsedFlags, { provider, compositeProviders }, argsWithoutProxy);
|
||||
if (process.exitCode === 1) return;
|
||||
// Validate cross-flag combinations (reports failure without relying on ambient exitCode)
|
||||
const flagCombinationsValid = validateFlagCombinations(
|
||||
parsedFlags,
|
||||
{ provider, compositeProviders },
|
||||
argsWithoutProxy
|
||||
);
|
||||
if (!flagCombinationsValid) return;
|
||||
|
||||
const {
|
||||
forceConfig,
|
||||
|
||||
@@ -24,20 +24,23 @@ import type { ResolvedProxyConfig } from '../types';
|
||||
import type { UnifiedConfig } from '../../config/schemas/unified-config';
|
||||
import { isNetworkError, handleNetworkError } from './retry-handler';
|
||||
|
||||
/** Result returned from resolveExecutorProxy */
|
||||
export interface ResolvedProxy {
|
||||
export interface ResolvedExecutorProxyConfig {
|
||||
/** Resolved proxy config after merging CLI > ENV > config.yaml > defaults */
|
||||
proxyConfig: ResolvedProxyConfig;
|
||||
/** Args after proxy-related flags are stripped out */
|
||||
argsWithoutProxy: string[];
|
||||
/** Mutated executor config (port resolved and validated) */
|
||||
cfg: ExecutorConfig;
|
||||
}
|
||||
|
||||
/** Result returned from resolveExecutorProxy */
|
||||
export interface ResolvedProxy extends ResolvedExecutorProxyConfig {
|
||||
/** Whether to use the remote proxy (vs spawning a local one) */
|
||||
useRemoteProxy: boolean;
|
||||
/** Which local backend binary to use ('original' | 'plus') */
|
||||
localBackend: CLIProxyBackend;
|
||||
/** Absolute path to CLIProxy binary; undefined when useRemoteProxy=true */
|
||||
binaryPath: string | undefined;
|
||||
/** Args after proxy-related flags are stripped out */
|
||||
argsWithoutProxy: string[];
|
||||
/** Mutated executor config (port resolved and validated) */
|
||||
cfg: ExecutorConfig;
|
||||
}
|
||||
|
||||
/** Dependencies injected by the orchestrator */
|
||||
@@ -50,16 +53,15 @@ export interface ResolveExecutorProxyContext {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves proxy configuration, checks remote reachability, selects the local
|
||||
* backend, and ensures the CLIProxy binary is present when running locally.
|
||||
* Resolves side-effect-free proxy configuration and strips proxy flags.
|
||||
*
|
||||
* Mutates `context.cfg.port` in-place (same as original orchestrator behaviour).
|
||||
*/
|
||||
export async function resolveExecutorProxy(
|
||||
export function resolveExecutorProxyConfig(
|
||||
args: string[],
|
||||
context: ResolveExecutorProxyContext
|
||||
): Promise<ResolvedProxy> {
|
||||
const { unifiedConfig, allProviders, verbose: _verbose, cfg, log } = context;
|
||||
): ResolvedExecutorProxyConfig {
|
||||
const { unifiedConfig, cfg, log } = context;
|
||||
|
||||
// Resolve proxy config from CLI flags > ENV > config.yaml > defaults
|
||||
const cliproxyServerConfig = unifiedConfig.cliproxy_server;
|
||||
@@ -98,6 +100,20 @@ export async function resolveExecutorProxy(
|
||||
log(`Remote host: ${proxyConfig.host}:${proxyConfig.port} (${proxyConfig.protocol})`);
|
||||
}
|
||||
|
||||
return { proxyConfig, argsWithoutProxy, cfg };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves proxy configuration, checks remote reachability, selects the local
|
||||
* backend, and ensures the CLIProxy binary is present when running locally.
|
||||
*/
|
||||
export async function resolveExecutorProxy(
|
||||
resolvedConfig: ResolvedExecutorProxyConfig,
|
||||
context: ResolveExecutorProxyContext
|
||||
): Promise<ResolvedProxy> {
|
||||
const { allProviders, verbose: _verbose } = context;
|
||||
const { proxyConfig, argsWithoutProxy, cfg } = resolvedConfig;
|
||||
|
||||
// Check remote proxy reachability
|
||||
let useRemoteProxy = false;
|
||||
let localBackend: CLIProxyBackend = 'original';
|
||||
|
||||
Reference in New Issue
Block a user