fix(cliproxy): ignore stale executor exit code

This commit is contained in:
Tam Nhu Tran
2026-05-30 15:48:18 -04:00
parent 1f1cc056ef
commit bcc68992fa
4 changed files with 115 additions and 21 deletions
@@ -62,6 +62,15 @@ describe('execClaudeWithCLIProxy browser flag validation', () => {
process.env.CCS_HOME = tmpHome; 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(() => { afterEach(() => {
if (originalCcsHome !== undefined) { if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome; process.env.CCS_HOME = originalCcsHome;
@@ -145,4 +154,69 @@ describe('execClaudeWithCLIProxy browser flag validation', () => {
errorSpy.mockRestore(); 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()));
});
}
});
}); });
+24 -11
View File
@@ -8,7 +8,9 @@
* - parseExecutorFlags() — flag extraction block (lines ~411-639 in original) * - parseExecutorFlags() — flag extraction block (lines ~411-639 in original)
* - validateFlagCombinations() — cross-flag guard block (lines ~531-585) * - 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. * 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. */ /** Result of parsing CCS executor flags from args. */
export interface ParsedExecutorFlags { export interface ParsedExecutorFlags {
parseFailed?: boolean;
forceAuth: boolean; forceAuth: boolean;
pasteCallback: boolean; pasteCallback: boolean;
portForward: boolean; portForward: boolean;
@@ -181,7 +184,7 @@ export interface ParsedExecutorFlags {
/** /**
* Parse all CCS executor flags from args. * 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 * Exits with process.exit(1) on conflicting flag combinations — identical to
* the original index.ts behavior. * the original index.ts behavior.
* *
@@ -247,7 +250,7 @@ export function parseExecutorFlags(
console.error(fail('--kiro-auth-method requires a value')); console.error(fail('--kiro-auth-method requires a value'));
console.error(' Supported values: aws, aws-authcode, google, github, idc'); console.error(' Supported values: aws, aws-authcode, google, github, idc');
process.exitCode = 1; 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({ return buildPartialFlags({
forceAuth, forceAuth,
pasteCallback, pasteCallback,
@@ -272,6 +275,7 @@ export function parseExecutorFlags(
gitlabBaseUrl: undefined, gitlabBaseUrl: undefined,
extendedContextOverride: undefined, extendedContextOverride: undefined,
thinkingParse: parseThinkingOverride(args), thinkingParse: parseThinkingOverride(args),
parseFailed: true,
}); });
} }
const normalized = rawMethod.trim().toLowerCase(); const normalized = rawMethod.trim().toLowerCase();
@@ -303,6 +307,7 @@ export function parseExecutorFlags(
gitlabBaseUrl: undefined, gitlabBaseUrl: undefined,
extendedContextOverride: undefined, extendedContextOverride: undefined,
thinkingParse: parseThinkingOverride(args), thinkingParse: parseThinkingOverride(args),
parseFailed: true,
}); });
} }
kiroAuthMethod = normalizeKiroAuthMethod(normalized); kiroAuthMethod = normalizeKiroAuthMethod(normalized);
@@ -339,6 +344,7 @@ export function parseExecutorFlags(
gitlabBaseUrl: undefined, gitlabBaseUrl: undefined,
extendedContextOverride: undefined, extendedContextOverride: undefined,
thinkingParse: parseThinkingOverride(args), thinkingParse: parseThinkingOverride(args),
parseFailed: true,
}); });
} }
@@ -373,6 +379,7 @@ export function parseExecutorFlags(
gitlabBaseUrl: undefined, gitlabBaseUrl: undefined,
extendedContextOverride: undefined, extendedContextOverride: undefined,
thinkingParse: parseThinkingOverride(args), thinkingParse: parseThinkingOverride(args),
parseFailed: true,
}); });
} }
@@ -408,6 +415,7 @@ export function parseExecutorFlags(
gitlabBaseUrl: undefined, gitlabBaseUrl: undefined,
extendedContextOverride: undefined, extendedContextOverride: undefined,
thinkingParse: parseThinkingOverride(args), thinkingParse: parseThinkingOverride(args),
parseFailed: true,
}); });
} }
const normalized = rawFlow.trim().toLowerCase(); const normalized = rawFlow.trim().toLowerCase();
@@ -439,6 +447,7 @@ export function parseExecutorFlags(
gitlabBaseUrl: undefined, gitlabBaseUrl: undefined,
extendedContextOverride: undefined, extendedContextOverride: undefined,
thinkingParse: parseThinkingOverride(args), thinkingParse: parseThinkingOverride(args),
parseFailed: true,
}); });
} }
kiroIDCFlow = normalizeKiroIDCFlow(normalized); kiroIDCFlow = normalizeKiroIDCFlow(normalized);
@@ -475,6 +484,7 @@ export function parseExecutorFlags(
gitlabBaseUrl: undefined, gitlabBaseUrl: undefined,
extendedContextOverride: undefined, extendedContextOverride: undefined,
thinkingParse: parseThinkingOverride(args), thinkingParse: parseThinkingOverride(args),
parseFailed: true,
}); });
} }
@@ -538,6 +548,7 @@ export function parseExecutorFlags(
gitlabBaseUrl, gitlabBaseUrl,
extendedContextOverride, extendedContextOverride,
thinkingParse, thinkingParse,
parseFailed: false,
}; };
} }
@@ -550,8 +561,8 @@ function buildPartialFlags(fields: ParsedExecutorFlags): ParsedExecutorFlags {
/** /**
* Validate flag combinations that are mutually exclusive or provider-scoped. * Validate flag combinations that are mutually exclusive or provider-scoped.
* Calls process.exit(1) on any violation — identical to original index.ts. * Sets process.exitCode=1 and returns false on any violation.
* Call AFTER parseExecutorFlags() and only if process.exitCode is still 0. * Call AFTER parseExecutorFlags() and only if parseFailed is false.
* *
* @param parsed Result of parseExecutorFlags() * @param parsed Result of parseExecutorFlags()
* @param context Provider context (provider string + compositeProviders list) * @param context Provider context (provider string + compositeProviders list)
@@ -561,7 +572,7 @@ export function validateFlagCombinations(
parsed: ParsedExecutorFlags, parsed: ParsedExecutorFlags,
context: { provider: string; compositeProviders: string[] }, context: { provider: string; compositeProviders: string[] },
args: string[] args: string[]
): void { ): boolean {
const { provider, compositeProviders } = context; const { provider, compositeProviders } = context;
const { const {
kiroAuthMethod, kiroAuthMethod,
@@ -575,7 +586,7 @@ export function validateFlagCombinations(
if (kiroAuthMethod && provider !== 'kiro' && !compositeProviders.includes('kiro')) { if (kiroAuthMethod && provider !== 'kiro' && !compositeProviders.includes('kiro')) {
console.error(fail('--kiro-auth-method is only valid for ccs kiro')); console.error(fail('--kiro-auth-method is only valid for ccs kiro'));
process.exitCode = 1; process.exitCode = 1;
return; return false;
} }
if ( if (
@@ -589,7 +600,7 @@ export function validateFlagCombinations(
) )
); );
process.exitCode = 1; process.exitCode = 1;
return; return false;
} }
if (kiroAuthMethod === 'idc' && !kiroIDCStartUrl) { 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' ' Example: ccs kiro --auth --kiro-auth-method idc --kiro-idc-start-url https://d-xxx.awsapps.com/start'
); );
process.exitCode = 1; process.exitCode = 1;
return; return false;
} }
if ( if (
@@ -612,13 +623,15 @@ export function validateFlagCombinations(
) )
); );
process.exitCode = 1; process.exitCode = 1;
return; return false;
} }
if ((gitlabTokenLogin || gitlabBaseUrl) && provider !== 'gitlab') { if ((gitlabTokenLogin || gitlabBaseUrl) && provider !== 'gitlab') {
const flagName = gitlabTokenLogin ? getGitLabTokenLoginFlagName(args) : '--gitlab-url'; const flagName = gitlabTokenLogin ? getGitLabTokenLoginFlagName(args) : '--gitlab-url';
console.error(fail(`${flagName} is only valid for ccs gitlab`)); console.error(fail(`${flagName} is only valid for ccs gitlab`));
process.exitCode = 1; process.exitCode = 1;
return; return false;
} }
return true;
} }
@@ -41,6 +41,7 @@ export interface BrowserLaunchSetupResult {
export function resolveBrowserLaunchFlags(argsWithoutProxy: string[]): { export function resolveBrowserLaunchFlags(argsWithoutProxy: string[]): {
browserLaunchOverride: BrowserLaunchOverride | undefined; browserLaunchOverride: BrowserLaunchOverride | undefined;
argsWithoutBrowserFlags: string[]; argsWithoutBrowserFlags: string[];
parseFailed: boolean;
} { } {
let browserLaunchOverride: BrowserLaunchOverride | undefined; let browserLaunchOverride: BrowserLaunchOverride | undefined;
let argsWithoutBrowserFlags = argsWithoutProxy; let argsWithoutBrowserFlags = argsWithoutProxy;
@@ -52,7 +53,7 @@ export function resolveBrowserLaunchFlags(argsWithoutProxy: string[]): {
console.error(fail((error as Error).message)); console.error(fail((error as Error).message));
process.exitCode = 1; process.exitCode = 1;
process.exit(1); process.exit(1);
return { browserLaunchOverride: undefined, argsWithoutBrowserFlags }; return { browserLaunchOverride: undefined, argsWithoutBrowserFlags, parseFailed: true };
} }
const browserConfig = getBrowserConfig(); const browserConfig = getBrowserConfig();
@@ -72,7 +73,7 @@ export function resolveBrowserLaunchFlags(argsWithoutProxy: string[]): {
console.error(warn(blockedBrowserOverrideWarning)); console.error(warn(blockedBrowserOverrideWarning));
} }
return { browserLaunchOverride, argsWithoutBrowserFlags }; return { browserLaunchOverride, argsWithoutBrowserFlags, parseFailed: false };
} }
/** /**
+14 -8
View File
@@ -137,10 +137,12 @@ export async function execClaudeWithCLIProxy(
log, log,
}); });
const { browserLaunchOverride, argsWithoutBrowserFlags } = resolveBrowserLaunchFlags( const {
proxyResolution.argsWithoutProxy browserLaunchOverride,
); argsWithoutBrowserFlags,
if (process.exitCode === 1) return; parseFailed: browserLaunchParseFailed,
} = resolveBrowserLaunchFlags(proxyResolution.argsWithoutProxy);
if (browserLaunchParseFailed) return;
const { proxyConfig, useRemoteProxy, localBackend, binaryPath, argsWithoutProxy } = const { proxyConfig, useRemoteProxy, localBackend, binaryPath, argsWithoutProxy } =
await resolveExecutorProxy(proxyResolution, { await resolveExecutorProxy(proxyResolution, {
@@ -168,11 +170,15 @@ export async function execClaudeWithCLIProxy(
compositeProviders, compositeProviders,
unifiedConfig, unifiedConfig,
}); });
if (process.exitCode === 1) return; if (parsedFlags.parseFailed) return;
// Validate cross-flag combinations (exits with code 1 on violation) // Validate cross-flag combinations (reports failure without relying on ambient exitCode)
validateFlagCombinations(parsedFlags, { provider, compositeProviders }, argsWithoutProxy); const flagCombinationsValid = validateFlagCombinations(
if (process.exitCode === 1) return; parsedFlags,
{ provider, compositeProviders },
argsWithoutProxy
);
if (!flagCombinationsValid) return;
const { const {
forceConfig, forceConfig,