fix(cliproxy): clean up launch-settings overlay on synchronous spawn failure

The runtime settings overlay cleanup was registered only on the child
'exit'/'error' events, which run after spawn() returns. A synchronous
spawn() throw (e.g. invalid arg/env) propagated out of launchClaude
before those handlers were wired, orphaning the secret-bearing 0600
overlay file in os.tmpdir(). Wrap the spawn in try/catch and run the
idempotent cleanup before rethrowing.
This commit is contained in:
Tam Nhu Tran committed 2026-06-27 10:54:50 -04:00
1 parent 97e16d1d1c
commit 454e1555a7
2 files changed
+48 -14

No files matched your search

@@ -87,6 +87,16 @@ mock.module('../../quota/quota-manager', () => ({
stopQuotaMonitor: jest.fn(), stopQuotaMonitor: jest.fn(),
})); }));
const mockCleanupLaunchSettings = jest.fn();
const mockPrepareLaunchSettings = jest.fn().mockReturnValue({
settingsPath: '/tmp/fake-settings-overlay.json',
cleanup: mockCleanupLaunchSettings,
});
mock.module('../launch-settings', () => ({
prepareLaunchSettings: mockPrepareLaunchSettings,
}));
// ── Subject under test ──────────────────────────────────────────────────────── // ── Subject under test ────────────────────────────────────────────────────────
import { launchClaude } from '../claude-launcher'; import { launchClaude } from '../claude-launcher';
@@ -131,6 +141,12 @@ describe('launchClaude', () => {
mockSpawn.mockClear(); mockSpawn.mockClear();
mockSetupCleanupHandlers.mockClear(); mockSetupCleanupHandlers.mockClear();
mockEscapeShellArg.mockClear(); mockEscapeShellArg.mockClear();
mockCleanupLaunchSettings.mockClear();
mockPrepareLaunchSettings.mockClear();
mockPrepareLaunchSettings.mockReturnValue({
settingsPath: '/tmp/fake-settings-overlay.json',
cleanup: mockCleanupLaunchSettings,
});
}); });
it('calls spawn with claudeCli and includes --settings arg', async () => { it('calls spawn with claudeCli and includes --settings arg', async () => {
@@ -189,6 +205,16 @@ describe('launchClaude', () => {
expect(result).toBe(mockSpawnResult); expect(result).toBe(mockSpawnResult);
}); });
it('calls cleanup and rethrows when spawn throws synchronously', async () => {
const spawnErr = new Error('ERR_INVALID_ARG_VALUE');
mockSpawn.mockImplementationOnce(() => {
throw spawnErr;
});
await expect(launchClaude(baseContext())).rejects.toThrow('ERR_INVALID_ARG_VALUE');
expect(mockCleanupLaunchSettings).toHaveBeenCalledTimes(1);
});
describe('Windows shell escaping', () => { describe('Windows shell escaping', () => {
const originalPlatform = process.platform; const originalPlatform = process.platform;
+22 -14
View File
@@ -144,20 +144,28 @@ export async function launchClaude(context: ClaudeLaunchContext): Promise<ChildP
// Spawn: Windows .cmd/.bat/.ps1 need shell escaping; all others spawn directly // Spawn: Windows .cmd/.bat/.ps1 need shell escaping; all others spawn directly
let claude: ChildProcess; let claude: ChildProcess;
if (needsShell) { try {
const cmdString = [claudeCli, ...launchArgs].map(escapeShellArg).join(' '); if (needsShell) {
claude = spawn(cmdString, { const cmdString = [claudeCli, ...launchArgs].map(escapeShellArg).join(' ');
stdio: 'inherit', claude = spawn(cmdString, {
windowsHide: true, stdio: 'inherit',
shell: getWindowsEscapedCommandShell(), windowsHide: true,
env: tracedEnv, shell: getWindowsEscapedCommandShell(),
}); env: tracedEnv,
} else { });
claude = spawn(claudeCli, launchArgs, { } else {
stdio: 'inherit', claude = spawn(claudeCli, launchArgs, {
windowsHide: true, stdio: 'inherit',
env: tracedEnv, windowsHide: true,
}); env: tracedEnv,
});
}
} catch (spawnError) {
// spawn() can throw synchronously (e.g. invalid arg/env). Remove the
// runtime settings overlay before propagating so the secret-bearing temp
// file is not orphaned. cleanup is idempotent.
cleanupLaunchSettings();
throw spawnError;
} }
// Remove the runtime settings overlay once Claude has read it and exited. // Remove the runtime settings overlay once Claude has read it and exited.