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;
});
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;
@@ -145,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()));
});
}
});
});
+24 -11
View File
@@ -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;
}
@@ -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;
@@ -52,7 +53,7 @@ export function resolveBrowserLaunchFlags(argsWithoutProxy: string[]): {
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();
@@ -72,7 +73,7 @@ export function resolveBrowserLaunchFlags(argsWithoutProxy: string[]): {
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,
});
const { browserLaunchOverride, argsWithoutBrowserFlags } = resolveBrowserLaunchFlags(
proxyResolution.argsWithoutProxy
);
if (process.exitCode === 1) return;
const {
browserLaunchOverride,
argsWithoutBrowserFlags,
parseFailed: browserLaunchParseFailed,
} = resolveBrowserLaunchFlags(proxyResolution.argsWithoutProxy);
if (browserLaunchParseFailed) return;
const { proxyConfig, useRemoteProxy, localBackend, binaryPath, argsWithoutProxy } =
await resolveExecutorProxy(proxyResolution, {
@@ -168,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,