diff --git a/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts b/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts index 207d0b39d1..efa6e2e1f1 100644 --- a/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts +++ b/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts @@ -4,7 +4,18 @@ import path from 'path'; import { describe, expect, it, vi, beforeEach } from 'vitest'; import type * as pty from '@lydell/node-pty'; import type { TerminalProcess } from '../types'; -import { buildCdCommand } from '../../../shared/utils/shell-escape'; +import { buildCdCommand, escapeShellArg } from '../../../shared/utils/shell-escape'; + +// Mock the platform module (main/platform/index.ts) +vi.mock('../../platform', () => ({ + isWindows: vi.fn(() => false), + isMacOS: vi.fn(() => false), + isLinux: vi.fn(() => false), + isUnix: vi.fn(() => false), + getCurrentOS: vi.fn(() => 'linux'), +})); + +import { isWindows } from '../../platform'; /** Escape special regex characters in a string for safe use in RegExp constructor */ const escapeForRegex = (str: string): string => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -74,6 +85,114 @@ vi.mock('os', async (importOriginal) => { }; }); +/** + * Helper to set the current platform for testing + */ +function mockPlatform(platform: 'win32' | 'darwin' | 'linux') { + const mockIsWindows = vi.mocked(isWindows); + mockIsWindows.mockReturnValue(platform === 'win32'); +} + +/** + * Helper to get platform-specific expectations for PATH prefix + */ +function getPathPrefixExpectation(platform: 'win32' | 'darwin' | 'linux', pathValue: string): string { + if (platform === 'win32') { + // Windows: set "PATH=value" && + return `set "PATH=${pathValue}" && `; + } + // Unix/macOS: PATH='value' ' + return `PATH='${pathValue}' `; +} + +/** + * Helper to get platform-specific expectations for command quoting + */ +function getQuotedCommand(platform: 'win32' | 'darwin' | 'linux', command: string): string { + if (platform === 'win32') { + // Windows: double quotes, use escapeForWindowsDoubleQuote logic + // Inside double quotes, only " needs escaping (as "") + const escaped = command.replace(/"/g, '""'); + return `"${escaped}"`; + } + // Unix/macOS: use escapeShellArg which properly handles embedded single quotes + return escapeShellArg(command); +} + +/** + * Helper to get platform-specific clear command + */ +function getClearCommand(platform: 'win32' | 'darwin' | 'linux'): string { + return platform === 'win32' ? 'cls' : 'clear'; +} + +/** + * Helper to get platform-specific history prefix + */ +function getHistoryPrefix(platform: 'win32' | 'darwin' | 'linux'): string { + return platform === 'win32' ? '' : 'HISTFILE= HISTCONTROL=ignorespace '; +} + +/** + * Helper to get platform-specific temp file extension + */ +function getTempFileExtension(platform: 'win32' | 'darwin' | 'linux'): string { + return platform === 'win32' ? '.bat' : ''; +} + +/** + * Helper to get platform-specific token file content + */ +function getTokenFileContent(platform: 'win32' | 'darwin' | 'linux', token: string): string { + if (platform === 'win32') { + return `@echo off\r\nset "CLAUDE_CODE_OAUTH_TOKEN=${token}"\r\n`; + } + return `export CLAUDE_CODE_OAUTH_TOKEN='${token}'\n`; +} + +/** + * Helper to get platform-specific temp file invocation + */ +function getTempFileInvocation(platform: 'win32' | 'darwin' | 'linux', tokenPath: string): string { + if (platform === 'win32') { + return `call "${tokenPath}"`; + } + return `source '${tokenPath}'`; +} + +/** + * Helper to get platform-specific temp file cleanup + * + * Note: Windows now deletes BEFORE the command runs (synchronous) + * for security - environment variables persist in memory after deletion. + */ +function getTempFileCleanup(platform: 'win32' | 'darwin' | 'linux', tokenPath: string): string { + if (platform === 'win32') { + return `&& del "${tokenPath}" &&`; + } + return `&& rm -f '${tokenPath}' &&`; +} + +/** + * Helper to get platform-specific exec command + */ +function getExecCommand(platform: 'win32' | 'darwin' | 'linux', command: string): string { + if (platform === 'win32') { + return command; // Windows doesn't use exec + } + return `exec ${command}`; +} + +/** + * Helper to get platform-specific config dir command + */ +function getConfigDirCommand(platform: 'win32' | 'darwin' | 'linux', configDir: string): string { + if (platform === 'win32') { + return `set "CLAUDE_CONFIG_DIR=${configDir}"`; + } + return `CLAUDE_CONFIG_DIR='${configDir}'`; +} + describe('claude-integration-handler', () => { beforeEach(() => { mockGetClaudeCliInvocation.mockClear(); @@ -83,42 +202,15 @@ describe('claude-integration-handler', () => { vi.mocked(writeFileSync).mockClear(); }); - it('uses the resolved CLI path and PATH prefix when invoking Claude', async () => { - mockGetClaudeCliInvocation.mockReturnValue({ - command: "/opt/claude bin/claude's", - env: { PATH: '/opt/claude/bin:/usr/bin' }, + describe.each(['win32', 'darwin', 'linux'] as const)('on %s', (platform) => { + beforeEach(() => { + mockPlatform(platform); }); - const profileManager = { - getActiveProfile: vi.fn(() => ({ id: 'default', name: 'Default', isDefault: true })), - getProfile: vi.fn(), - getProfileToken: vi.fn(() => null), - markProfileUsed: vi.fn(), - }; - mockGetClaudeProfileManager.mockReturnValue(profileManager); - const terminal = createMockTerminal(); - - const { invokeClaude } = await import('../claude-integration-handler'); - invokeClaude(terminal, '/tmp/project', undefined, () => null, vi.fn()); - - const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; - expect(written).toContain(buildCdCommand('/tmp/project')); - expect(written).toContain("PATH='/opt/claude/bin:/usr/bin' "); - expect(written).toContain("'/opt/claude bin/claude'\\''s'"); - expect(mockReleaseSessionId).toHaveBeenCalledWith('term-1'); - expect(mockPersistSession).toHaveBeenCalledWith(terminal); - expect(profileManager.getActiveProfile).toHaveBeenCalled(); - expect(profileManager.markProfileUsed).toHaveBeenCalledWith('default'); - }); - - it('converts Windows PATH separators to colons for bash invocations', async () => { - const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); - Object.defineProperty(process, 'platform', { value: 'win32' }); - - try { + it('uses the resolved CLI path and PATH prefix when invoking Claude', async () => { mockGetClaudeCliInvocation.mockReturnValue({ - command: 'C:\\Tools\\claude\\claude.exe', - env: { PATH: 'C:\\Tools\\claude;C:\\Windows' }, + command: "/opt/claude bin/claude's", + env: { PATH: '/opt/claude/bin:/usr/bin' }, }); const profileManager = { getActiveProfile: vi.fn(() => ({ id: 'default', name: 'Default', isDefault: true })), @@ -134,13 +226,245 @@ describe('claude-integration-handler', () => { invokeClaude(terminal, '/tmp/project', undefined, () => null, vi.fn()); const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; - expect(written).toContain("PATH='C:\\Tools\\claude:C:\\Windows' "); - expect(written).not.toContain('C:\\Tools\\claude;C:\\Windows'); - } finally { - if (originalPlatform) { - Object.defineProperty(process, 'platform', originalPlatform); - } - } + expect(written).toContain(buildCdCommand('/tmp/project')); + expect(written).toContain(getPathPrefixExpectation(platform, '/opt/claude/bin:/usr/bin')); + expect(written).toContain(getQuotedCommand(platform, "/opt/claude bin/claude's")); + expect(mockReleaseSessionId).toHaveBeenCalledWith('term-1'); + expect(mockPersistSession).toHaveBeenCalledWith(terminal); + expect(profileManager.getActiveProfile).toHaveBeenCalled(); + expect(profileManager.markProfileUsed).toHaveBeenCalledWith('default'); + }); + + it('uses the temp token flow when the active profile has an oauth token', async () => { + const command = '/opt/claude/bin/claude'; + const profileManager = { + getActiveProfile: vi.fn(), + getProfile: vi.fn(() => ({ + id: 'prof-1', + name: 'Work', + isDefault: false, + oauthToken: 'token-value', + })), + getProfileToken: vi.fn(() => 'token-value'), + markProfileUsed: vi.fn(), + }; + + mockGetClaudeCliInvocation.mockReturnValue({ + command, + env: { PATH: '/opt/claude/bin:/usr/bin' }, + }); + mockGetClaudeProfileManager.mockReturnValue(profileManager); + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1234); + + const terminal = createMockTerminal({ id: 'term-3' }); + + const { invokeClaude } = await import('../claude-integration-handler'); + invokeClaude(terminal, '/tmp/project', 'prof-1', () => null, vi.fn()); + + const tokenPath = vi.mocked(writeFileSync).mock.calls[0]?.[0] as string; + const tokenContents = vi.mocked(writeFileSync).mock.calls[0]?.[1] as string; + const tokenPrefix = path.join(tmpdir(), '.claude-token-1234-'); + const tokenExt = getTempFileExtension(platform); + expect(tokenPath).toMatch(new RegExp(`^${escapeForRegex(tokenPrefix)}[0-9a-f]{16}${escapeForRegex(tokenExt)}$`)); + expect(tokenContents).toBe(getTokenFileContent(platform, 'token-value')); + + const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; + const clearCmd = getClearCommand(platform); + const histPrefix = getHistoryPrefix(platform); + const cmdQuote = platform === 'win32' ? '"' : "'"; + + expect(written).toContain(histPrefix); + expect(written).toContain(clearCmd); + expect(written).toContain(getTempFileInvocation(platform, tokenPath)); + expect(written).toContain(getTempFileCleanup(platform, tokenPath)); + expect(written).toContain(`${cmdQuote}${command}${cmdQuote}`); + expect(profileManager.getProfile).toHaveBeenCalledWith('prof-1'); + expect(mockPersistSession).toHaveBeenCalledWith(terminal); + + nowSpy.mockRestore(); + }); + + it('prefers the temp token flow when profile has both oauth token and config dir', async () => { + const command = '/opt/claude/bin/claude'; + const profileManager = { + getActiveProfile: vi.fn(), + getProfile: vi.fn(() => ({ + id: 'prof-both', + name: 'Work', + isDefault: false, + oauthToken: 'token-value', + configDir: '/tmp/claude-config', + })), + getProfileToken: vi.fn(() => 'token-value'), + markProfileUsed: vi.fn(), + }; + + mockGetClaudeCliInvocation.mockReturnValue({ + command, + env: { PATH: '/opt/claude/bin:/usr/bin' }, + }); + mockGetClaudeProfileManager.mockReturnValue(profileManager); + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(5678); + + const terminal = createMockTerminal({ id: 'term-both' }); + + const { invokeClaude } = await import('../claude-integration-handler'); + invokeClaude(terminal, '/tmp/project', 'prof-both', () => null, vi.fn()); + + const tokenPath = vi.mocked(writeFileSync).mock.calls[0]?.[0] as string; + const tokenContents = vi.mocked(writeFileSync).mock.calls[0]?.[1] as string; + const tokenPrefix = path.join(tmpdir(), '.claude-token-5678-'); + const tokenExt = getTempFileExtension(platform); + expect(tokenPath).toMatch(new RegExp(`^${escapeForRegex(tokenPrefix)}[0-9a-f]{16}${escapeForRegex(tokenExt)}$`)); + expect(tokenContents).toBe(getTokenFileContent(platform, 'token-value')); + + const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; + expect(written).toContain(getTempFileInvocation(platform, tokenPath)); + expect(written).toContain(getTempFileCleanup(platform, tokenPath)); + expect(written).toContain(getQuotedCommand(platform, command)); + expect(written).not.toContain('CLAUDE_CONFIG_DIR='); + expect(profileManager.getProfile).toHaveBeenCalledWith('prof-both'); + expect(mockPersistSession).toHaveBeenCalledWith(terminal); + expect(profileManager.markProfileUsed).toHaveBeenCalledWith('prof-both'); + + nowSpy.mockRestore(); + }); + + it('handles missing profiles by falling back to the default command', async () => { + const command = '/opt/claude/bin/claude'; + const profileManager = { + getActiveProfile: vi.fn(), + getProfile: vi.fn(() => undefined), + getProfileToken: vi.fn(() => null), + markProfileUsed: vi.fn(), + }; + + mockGetClaudeCliInvocation.mockReturnValue({ + command, + env: { PATH: '/opt/claude/bin:/usr/bin' }, + }); + mockGetClaudeProfileManager.mockReturnValue(profileManager); + + const terminal = createMockTerminal({ id: 'term-6' }); + + const { invokeClaude } = await import('../claude-integration-handler'); + invokeClaude(terminal, '/tmp/project', 'missing', () => null, vi.fn()); + + const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; + expect(written).toContain(getQuotedCommand(platform, command)); + expect(profileManager.getProfile).toHaveBeenCalledWith('missing'); + expect(profileManager.markProfileUsed).not.toHaveBeenCalled(); + }); + + it('uses the config dir flow when the active profile has a config dir', async () => { + const command = '/opt/claude/bin/claude'; + const profileManager = { + getActiveProfile: vi.fn(), + getProfile: vi.fn(() => ({ + id: 'prof-2', + name: 'Work', + isDefault: false, + configDir: '/tmp/claude-config', + })), + getProfileToken: vi.fn(() => null), + markProfileUsed: vi.fn(), + }; + + mockGetClaudeCliInvocation.mockReturnValue({ + command, + env: { PATH: '/opt/claude/bin:/usr/bin' }, + }); + mockGetClaudeProfileManager.mockReturnValue(profileManager); + + const terminal = createMockTerminal({ id: 'term-4' }); + + const { invokeClaude } = await import('../claude-integration-handler'); + invokeClaude(terminal, '/tmp/project', 'prof-2', () => null, vi.fn()); + + const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; + const clearCmd = getClearCommand(platform); + const histPrefix = getHistoryPrefix(platform); + const configDir = getConfigDirCommand(platform, '/tmp/claude-config'); + + expect(written).toContain(histPrefix); + expect(written).toContain(configDir); + expect(written).toContain(getPathPrefixExpectation(platform, '/opt/claude/bin:/usr/bin')); + expect(written).toContain(getQuotedCommand(platform, command)); + expect(written).toContain(clearCmd); + expect(profileManager.getProfile).toHaveBeenCalledWith('prof-2'); + expect(profileManager.markProfileUsed).toHaveBeenCalledWith('prof-2'); + expect(mockPersistSession).toHaveBeenCalledWith(terminal); + }); + + it('uses profile switching when a non-default profile is requested', async () => { + const command = '/opt/claude/bin/claude'; + const profileManager = { + getActiveProfile: vi.fn(), + getProfile: vi.fn(() => ({ + id: 'prof-3', + name: 'Team', + isDefault: false, + })), + getProfileToken: vi.fn(() => null), + markProfileUsed: vi.fn(), + }; + + mockGetClaudeCliInvocation.mockReturnValue({ + command, + env: { PATH: '/opt/claude/bin:/usr/bin' }, + }); + mockGetClaudeProfileManager.mockReturnValue(profileManager); + + const terminal = createMockTerminal({ id: 'term-5' }); + + const { invokeClaude } = await import('../claude-integration-handler'); + invokeClaude(terminal, '/tmp/project', 'prof-3', () => null, vi.fn()); + + const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; + expect(written).toContain(getQuotedCommand(platform, command)); + expect(written).toContain(getPathPrefixExpectation(platform, '/opt/claude/bin:/usr/bin')); + expect(profileManager.getProfile).toHaveBeenCalledWith('prof-3'); + expect(profileManager.markProfileUsed).toHaveBeenCalledWith('prof-3'); + expect(mockPersistSession).toHaveBeenCalledWith(terminal); + }); + + it('uses --continue regardless of sessionId (sessionId is deprecated)', async () => { + mockGetClaudeCliInvocation.mockReturnValue({ + command: '/opt/claude/bin/claude', + env: { PATH: '/opt/claude/bin:/usr/bin' }, + }); + + const terminal = createMockTerminal({ + id: 'term-2', + cwd: undefined, + projectPath: '/tmp/project', + }); + + const { resumeClaude } = await import('../claude-integration-handler'); + + // Even when sessionId is passed, it should be ignored and --continue used + resumeClaude(terminal, 'abc123', () => null); + + const resumeCall = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; + expect(resumeCall).toContain(getPathPrefixExpectation(platform, '/opt/claude/bin:/usr/bin')); + expect(resumeCall).toContain(getQuotedCommand(platform, '/opt/claude/bin/claude') + ' --continue'); + expect(resumeCall).not.toContain('--resume'); + // sessionId is cleared because --continue doesn't track specific sessions + expect(terminal.claudeSessionId).toBeUndefined(); + expect(terminal.isClaudeMode).toBe(true); + expect(mockPersistSession).toHaveBeenCalledWith(terminal); + + vi.mocked(terminal.pty.write).mockClear(); + mockPersistSession.mockClear(); + terminal.projectPath = undefined; + terminal.isClaudeMode = false; + resumeClaude(terminal, undefined, () => null); + const continueCall = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; + expect(continueCall).toContain(getQuotedCommand(platform, '/opt/claude/bin/claude') + ' --continue'); + expect(terminal.isClaudeMode).toBe(true); + expect(terminal.claudeSessionId).toBeUndefined(); + expect(mockPersistSession).not.toHaveBeenCalled(); + }); }); it('throws when invokeClaude cannot resolve the CLI invocation', async () => { @@ -206,222 +530,6 @@ describe('claude-integration-handler', () => { expect(() => invokeClaude(terminal, '/tmp/project', 'prof-err', () => null, vi.fn())).toThrow('disk full'); expect(terminal.pty.write).not.toHaveBeenCalled(); }); - - it('uses the temp token flow when the active profile has an oauth token', async () => { - const command = '/opt/claude/bin/claude'; - const profileManager = { - getActiveProfile: vi.fn(), - getProfile: vi.fn(() => ({ - id: 'prof-1', - name: 'Work', - isDefault: false, - oauthToken: 'token-value', - })), - getProfileToken: vi.fn(() => 'token-value'), - markProfileUsed: vi.fn(), - }; - - mockGetClaudeCliInvocation.mockReturnValue({ - command, - env: { PATH: '/opt/claude/bin:/usr/bin' }, - }); - mockGetClaudeProfileManager.mockReturnValue(profileManager); - const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1234); - - const terminal = createMockTerminal({ id: 'term-3' }); - - const { invokeClaude } = await import('../claude-integration-handler'); - invokeClaude(terminal, '/tmp/project', 'prof-1', () => null, vi.fn()); - - const tokenPath = vi.mocked(writeFileSync).mock.calls[0]?.[0] as string; - const tokenContents = vi.mocked(writeFileSync).mock.calls[0]?.[1] as string; - const tokenPrefix = path.join(tmpdir(), '.claude-token-1234-'); - expect(tokenPath).toMatch(new RegExp(`^${escapeForRegex(tokenPrefix)}[0-9a-f]{16}$`)); - expect(tokenContents).toBe("export CLAUDE_CODE_OAUTH_TOKEN='token-value'\n"); - const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; - expect(written).toContain("HISTFILE= HISTCONTROL=ignorespace "); - expect(written).toContain(`source '${tokenPath}'`); - expect(written).toContain(`rm -f '${tokenPath}'`); - expect(written).toContain(`exec '${command}'`); - expect(profileManager.getProfile).toHaveBeenCalledWith('prof-1'); - expect(mockPersistSession).toHaveBeenCalledWith(terminal); - - nowSpy.mockRestore(); - }); - - it('prefers the temp token flow when profile has both oauth token and config dir', async () => { - const command = '/opt/claude/bin/claude'; - const profileManager = { - getActiveProfile: vi.fn(), - getProfile: vi.fn(() => ({ - id: 'prof-both', - name: 'Work', - isDefault: false, - oauthToken: 'token-value', - configDir: '/tmp/claude-config', - })), - getProfileToken: vi.fn(() => 'token-value'), - markProfileUsed: vi.fn(), - }; - - mockGetClaudeCliInvocation.mockReturnValue({ - command, - env: { PATH: '/opt/claude/bin:/usr/bin' }, - }); - mockGetClaudeProfileManager.mockReturnValue(profileManager); - const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(5678); - - const terminal = createMockTerminal({ id: 'term-both' }); - - const { invokeClaude } = await import('../claude-integration-handler'); - invokeClaude(terminal, '/tmp/project', 'prof-both', () => null, vi.fn()); - - const tokenPath = vi.mocked(writeFileSync).mock.calls[0]?.[0] as string; - const tokenContents = vi.mocked(writeFileSync).mock.calls[0]?.[1] as string; - const tokenPrefix = path.join(tmpdir(), '.claude-token-5678-'); - expect(tokenPath).toMatch(new RegExp(`^${escapeForRegex(tokenPrefix)}[0-9a-f]{16}$`)); - expect(tokenContents).toBe("export CLAUDE_CODE_OAUTH_TOKEN='token-value'\n"); - const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; - expect(written).toContain(`source '${tokenPath}'`); - expect(written).toContain(`rm -f '${tokenPath}'`); - expect(written).toContain(`exec '${command}'`); - expect(written).not.toContain('CLAUDE_CONFIG_DIR='); - expect(profileManager.getProfile).toHaveBeenCalledWith('prof-both'); - expect(mockPersistSession).toHaveBeenCalledWith(terminal); - expect(profileManager.markProfileUsed).toHaveBeenCalledWith('prof-both'); - - nowSpy.mockRestore(); - }); - - it('handles missing profiles by falling back to the default command', async () => { - const command = '/opt/claude/bin/claude'; - const profileManager = { - getActiveProfile: vi.fn(), - getProfile: vi.fn(() => undefined), - getProfileToken: vi.fn(() => null), - markProfileUsed: vi.fn(), - }; - - mockGetClaudeCliInvocation.mockReturnValue({ - command, - env: { PATH: '/opt/claude/bin:/usr/bin' }, - }); - mockGetClaudeProfileManager.mockReturnValue(profileManager); - - const terminal = createMockTerminal({ id: 'term-6' }); - - const { invokeClaude } = await import('../claude-integration-handler'); - invokeClaude(terminal, '/tmp/project', 'missing', () => null, vi.fn()); - - const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; - expect(written).toContain(`'${command}'`); - expect(profileManager.getProfile).toHaveBeenCalledWith('missing'); - expect(profileManager.markProfileUsed).not.toHaveBeenCalled(); - }); - - it('uses the config dir flow when the active profile has a config dir', async () => { - const command = '/opt/claude/bin/claude'; - const profileManager = { - getActiveProfile: vi.fn(), - getProfile: vi.fn(() => ({ - id: 'prof-2', - name: 'Work', - isDefault: false, - configDir: '/tmp/claude-config', - })), - getProfileToken: vi.fn(() => null), - markProfileUsed: vi.fn(), - }; - - mockGetClaudeCliInvocation.mockReturnValue({ - command, - env: { PATH: '/opt/claude/bin:/usr/bin' }, - }); - mockGetClaudeProfileManager.mockReturnValue(profileManager); - - const terminal = createMockTerminal({ id: 'term-4' }); - - const { invokeClaude } = await import('../claude-integration-handler'); - invokeClaude(terminal, '/tmp/project', 'prof-2', () => null, vi.fn()); - - const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; - expect(written).toContain("HISTFILE= HISTCONTROL=ignorespace "); - expect(written).toContain("CLAUDE_CONFIG_DIR='/tmp/claude-config'"); - expect(written).toContain(`exec '${command}'`); - expect(profileManager.getProfile).toHaveBeenCalledWith('prof-2'); - expect(profileManager.markProfileUsed).toHaveBeenCalledWith('prof-2'); - expect(mockPersistSession).toHaveBeenCalledWith(terminal); - }); - - it('uses profile switching when a non-default profile is requested', async () => { - const command = '/opt/claude/bin/claude'; - const profileManager = { - getActiveProfile: vi.fn(), - getProfile: vi.fn(() => ({ - id: 'prof-3', - name: 'Team', - isDefault: false, - })), - getProfileToken: vi.fn(() => null), - markProfileUsed: vi.fn(), - }; - - mockGetClaudeCliInvocation.mockReturnValue({ - command, - env: { PATH: '/opt/claude/bin:/usr/bin' }, - }); - mockGetClaudeProfileManager.mockReturnValue(profileManager); - - const terminal = createMockTerminal({ id: 'term-5' }); - - const { invokeClaude } = await import('../claude-integration-handler'); - invokeClaude(terminal, '/tmp/project', 'prof-3', () => null, vi.fn()); - - const written = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; - expect(written).toContain(`'${command}'`); - expect(written).toContain("PATH='/opt/claude/bin:/usr/bin' "); - expect(profileManager.getProfile).toHaveBeenCalledWith('prof-3'); - expect(profileManager.markProfileUsed).toHaveBeenCalledWith('prof-3'); - expect(mockPersistSession).toHaveBeenCalledWith(terminal); - }); - - it('uses --continue regardless of sessionId (sessionId is deprecated)', async () => { - mockGetClaudeCliInvocation.mockReturnValue({ - command: '/opt/claude/bin/claude', - env: { PATH: '/opt/claude/bin:/usr/bin' }, - }); - - const terminal = createMockTerminal({ - id: 'term-2', - cwd: undefined, - projectPath: '/tmp/project', - }); - - const { resumeClaude } = await import('../claude-integration-handler'); - - // Even when sessionId is passed, it should be ignored and --continue used - resumeClaude(terminal, 'abc123', () => null); - - const resumeCall = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; - expect(resumeCall).toContain("PATH='/opt/claude/bin:/usr/bin' "); - expect(resumeCall).toContain("'/opt/claude/bin/claude' --continue"); - expect(resumeCall).not.toContain('--resume'); - // sessionId is cleared because --continue doesn't track specific sessions - expect(terminal.claudeSessionId).toBeUndefined(); - expect(terminal.isClaudeMode).toBe(true); - expect(mockPersistSession).toHaveBeenCalledWith(terminal); - - vi.mocked(terminal.pty.write).mockClear(); - mockPersistSession.mockClear(); - terminal.projectPath = undefined; - terminal.isClaudeMode = false; - resumeClaude(terminal, undefined, () => null); - const continueCall = vi.mocked(terminal.pty.write).mock.calls[0][0] as string; - expect(continueCall).toContain("'/opt/claude/bin/claude' --continue"); - expect(terminal.isClaudeMode).toBe(true); - expect(terminal.claudeSessionId).toBeUndefined(); - expect(mockPersistSession).not.toHaveBeenCalled(); - }); }); /** @@ -429,75 +537,102 @@ describe('claude-integration-handler', () => { */ describe('claude-integration-handler - Helper Functions', () => { describe('buildClaudeShellCommand', () => { - it('should build default command without cwd or PATH prefix', async () => { - const { buildClaudeShellCommand } = await import('../claude-integration-handler'); - const result = buildClaudeShellCommand('', '', "'/opt/bin/claude'", { method: 'default' }); + describe.each(['win32', 'darwin', 'linux'] as const)('on %s', (platform) => { + beforeEach(() => { + mockPlatform(platform); + }); - expect(result).toBe("'/opt/bin/claude'\r"); - }); + it('should build default command without cwd or PATH prefix', async () => { + const { buildClaudeShellCommand } = await import('../claude-integration-handler'); + const result = buildClaudeShellCommand('', '', "'/opt/bin/claude'", { method: 'default' }); + + expect(result).toBe("'/opt/bin/claude'\r"); + }); - it('should build command with cwd', async () => { - const { buildClaudeShellCommand } = await import('../claude-integration-handler'); - const result = buildClaudeShellCommand("cd '/tmp/project' && ", '', "'/opt/bin/claude'", { method: 'default' }); + it('should build command with cwd', async () => { + const { buildClaudeShellCommand } = await import('../claude-integration-handler'); + const result = buildClaudeShellCommand("cd '/tmp/project' && ", '', "'/opt/bin/claude'", { method: 'default' }); - expect(result).toBe("cd '/tmp/project' && '/opt/bin/claude'\r"); - }); + expect(result).toBe("cd '/tmp/project' && '/opt/bin/claude'\r"); + }); - it('should build command with PATH prefix', async () => { - const { buildClaudeShellCommand } = await import('../claude-integration-handler'); - const result = buildClaudeShellCommand('', "PATH='/custom/path' ", "'/opt/bin/claude'", { method: 'default' }); + it('should build command with PATH prefix', async () => { + const { buildClaudeShellCommand } = await import('../claude-integration-handler'); + const result = buildClaudeShellCommand('', "PATH='/custom/path' ", "'/opt/bin/claude'", { method: 'default' }); - expect(result).toBe("PATH='/custom/path' '/opt/bin/claude'\r"); - }); + expect(result).toBe("PATH='/custom/path' '/opt/bin/claude'\r"); + }); - it('should build temp-file method command with history-safe prefixes', async () => { - const { buildClaudeShellCommand } = await import('../claude-integration-handler'); - const result = buildClaudeShellCommand( - "cd '/tmp/project' && ", - "PATH='/opt/bin' ", - "'/opt/bin/claude'", - { method: 'temp-file', escapedTempFile: "'/tmp/.token-123'" } - ); + it('should build temp-file method command with history-safe prefixes', async () => { + const { buildClaudeShellCommand } = await import('../claude-integration-handler'); + const result = buildClaudeShellCommand( + "cd '/tmp/project' && ", + "PATH='/opt/bin' ", + "'/opt/bin/claude'", + { method: 'temp-file', tempFile: '/tmp/.token-123' } + ); - expect(result).toContain('clear && '); - expect(result).toContain("cd '/tmp/project' && "); - expect(result).toContain('HISTFILE= HISTCONTROL=ignorespace'); - expect(result).toContain("PATH='/opt/bin' "); - expect(result).toContain("source '/tmp/.token-123'"); - expect(result).toContain("rm -f '/tmp/.token-123'"); - expect(result).toContain("exec '/opt/bin/claude'"); - }); + const clearCmd = getClearCommand(platform); + const histPrefix = getHistoryPrefix(platform); + const tempCmd = getTempFileInvocation(platform, '/tmp/.token-123'); + const cleanupCmd = getTempFileCleanup(platform, '/tmp/.token-123'); + const execCmd = getExecCommand(platform, "'/opt/bin/claude'"); + + expect(result).toContain(`${clearCmd} && `); + expect(result).toContain("cd '/tmp/project' && "); + if (platform !== 'win32') { + expect(result).toContain(histPrefix); + } + expect(result).toContain("PATH='/opt/bin' "); + expect(result).toContain(tempCmd); + expect(result).toContain(cleanupCmd); + expect(result).toContain(execCmd); + }); - it('should build config-dir method command with CLAUDE_CONFIG_DIR', async () => { - const { buildClaudeShellCommand } = await import('../claude-integration-handler'); - const result = buildClaudeShellCommand( - "cd '/tmp/project' && ", - "PATH='/opt/bin' ", - "'/opt/bin/claude'", - { method: 'config-dir', escapedConfigDir: "'/home/user/.claude-work'" } - ); + it('should build config-dir method command with CLAUDE_CONFIG_DIR', async () => { + const { buildClaudeShellCommand } = await import('../claude-integration-handler'); + const result = buildClaudeShellCommand( + "cd '/tmp/project' && ", + "PATH='/opt/bin' ", + "'/opt/bin/claude'", + { method: 'config-dir', configDir: '/home/user/.claude-work' } + ); - expect(result).toContain('clear && '); - expect(result).toContain("cd '/tmp/project' && "); - expect(result).toContain('HISTFILE= HISTCONTROL=ignorespace'); - expect(result).toContain("CLAUDE_CONFIG_DIR='/home/user/.claude-work'"); - expect(result).toContain("PATH='/opt/bin' "); - expect(result).toContain("exec '/opt/bin/claude'"); - }); + const clearCmd = getClearCommand(platform); + const histPrefix = getHistoryPrefix(platform); + const configDirVar = getConfigDirCommand(platform, '/home/user/.claude-work'); + const execCmd = getExecCommand(platform, "'/opt/bin/claude'"); + + expect(result).toContain(`${clearCmd} && `); + expect(result).toContain("cd '/tmp/project' && "); + if (platform !== 'win32') { + expect(result).toContain(histPrefix); + } + expect(result).toContain(configDirVar); + expect(result).toContain("PATH='/opt/bin' "); + expect(result).toContain(execCmd); + }); - it('should handle empty cwdCommand for temp-file method', async () => { - const { buildClaudeShellCommand } = await import('../claude-integration-handler'); - const result = buildClaudeShellCommand( - '', - '', - "'/opt/bin/claude'", - { method: 'temp-file', escapedTempFile: "'/tmp/.token'" } - ); + it('should handle empty cwdCommand for temp-file method', async () => { + const { buildClaudeShellCommand } = await import('../claude-integration-handler'); + const result = buildClaudeShellCommand( + '', + '', + "'/opt/bin/claude'", + { method: 'temp-file', tempFile: '/tmp/.token' } + ); - expect(result).toContain('clear && '); - expect(result).toContain('HISTFILE= HISTCONTROL=ignorespace'); - expect(result).not.toContain('cd '); - expect(result).toContain("source '/tmp/.token'"); + const clearCmd = getClearCommand(platform); + const histPrefix = getHistoryPrefix(platform); + const tempCmd = getTempFileInvocation(platform, '/tmp/.token'); + + expect(result).toContain(`${clearCmd} && `); + if (platform !== 'win32') { + expect(result).toContain(histPrefix); + } + expect(result).not.toContain('cd '); + expect(result).toContain(tempCmd); + }); }); }); diff --git a/apps/frontend/src/main/terminal/claude-integration-handler.ts b/apps/frontend/src/main/terminal/claude-integration-handler.ts index c9502474fc..f9813b8325 100644 --- a/apps/frontend/src/main/terminal/claude-integration-handler.ts +++ b/apps/frontend/src/main/terminal/claude-integration-handler.ts @@ -13,8 +13,9 @@ import { getClaudeProfileManager, initializeClaudeProfileManager } from '../clau import * as OutputParser from './output-parser'; import * as SessionHandler from './session-handler'; import { debugLog, debugError } from '../../shared/utils/debug-logger'; -import { escapeShellArg, buildCdCommand } from '../../shared/utils/shell-escape'; +import { escapeShellArg, escapeForWindowsDoubleQuote, buildCdCommand } from '../../shared/utils/shell-escape'; import { getClaudeCliInvocation, getClaudeCliInvocationAsync } from '../claude-cli-utils'; +import { isWindows } from '../platform'; import type { TerminalProcess, WindowGetter, @@ -23,7 +24,89 @@ import type { } from './types'; function normalizePathForBash(envPath: string): string { - return process.platform === 'win32' ? envPath.replace(/;/g, ':') : envPath; + return isWindows() ? envPath.replace(/;/g, ':') : envPath; +} + +/** + * Generate temp file content for OAuth token based on platform + * + * On Windows, creates a .bat file with set command using double-quote syntax; + * on Unix, creates a shell script with export. + * + * @param token - OAuth token value + * @returns Content string for the temp file + */ +function generateTokenTempFileContent(token: string): string { + if (isWindows()) { + // Windows: Use double-quote syntax for set command to handle special characters + // Format: set "VARNAME=value" - quotes allow spaces and special chars in value + // For values inside double quotes, use escapeForWindowsDoubleQuote() because + // caret is literal inside double quotes in cmd.exe (only " needs escaping). + const escapedToken = escapeForWindowsDoubleQuote(token); + return `@echo off\r\nset "CLAUDE_CODE_OAUTH_TOKEN=${escapedToken}"\r\n`; + } + // Unix/macOS: Use export with single-quoted value + return `export CLAUDE_CODE_OAUTH_TOKEN=${escapeShellArg(token)}\n`; +} + +/** + * Get the file extension for temp files based on platform + * + * @returns File extension including the dot (e.g., '.bat' on Windows, '' on Unix) + */ +function getTempFileExtension(): string { + return isWindows() ? '.bat' : ''; +} + +/** + * Build PATH environment variable prefix for Claude CLI invocation. + * + * On Windows, uses semicolon separators and cmd.exe escaping. + * On Unix/macOS, uses colon separators and bash escaping. + * + * @param pathEnv - PATH environment variable value + * @returns Empty string if no PATH, otherwise platform-specific PATH prefix + */ +function buildPathPrefix(pathEnv: string): string { + if (!pathEnv) { + return ''; + } + + if (isWindows()) { + // Windows: Use semicolon-separated PATH with double-quote escaping + // Format: set "PATH=value" where value uses semicolons + // For values inside double quotes, use escapeForWindowsDoubleQuote() because + // caret is literal inside double quotes in cmd.exe (only " needs escaping). + const escapedPath = escapeForWindowsDoubleQuote(pathEnv); + return `set "PATH=${escapedPath}" && `; + } + + // Unix/macOS: Use colon-separated PATH with bash escaping + // Format: PATH='value' where value uses colons + const normalizedPath = normalizePathForBash(pathEnv); + return `PATH=${escapeShellArg(normalizedPath)} `; +} + +/** + * Escape a command for safe use in shell commands. + * + * On Windows, wraps in double quotes for cmd.exe. Since the value is inside + * double quotes, we use escapeForWindowsDoubleQuote() (only escapes embedded + * double quotes as ""). Caret escaping is NOT used inside double quotes. + * On Unix/macOS, wraps in single quotes for bash. + * + * @param cmd - The command to escape + * @returns The escaped command safe for use in shell commands + */ +function escapeShellCommand(cmd: string): string { + if (isWindows()) { + // Windows: Wrap in double quotes and escape only embedded double quotes + // Inside double quotes, caret is literal, so use escapeForWindowsDoubleQuote() + const escapedCmd = escapeForWindowsDoubleQuote(cmd); + return `"${escapedCmd}"`; + } + // Unix/macOS: Wrap in single quotes for bash + return escapeShellArg(cmd); } /** @@ -39,11 +122,13 @@ const YOLO_MODE_FLAG = ' --dangerously-skip-permissions'; /** * Configuration for building Claude shell commands using discriminated union. * This provides type safety by ensuring the correct options are provided for each method. + * + * Note: Paths are NOT escaped - buildClaudeShellCommand handles platform-specific escaping. */ type ClaudeCommandConfig = | { method: 'default' } - | { method: 'temp-file'; escapedTempFile: string } - | { method: 'config-dir'; escapedConfigDir: string }; + | { method: 'temp-file'; tempFile: string } + | { method: 'config-dir'; configDir: string }; /** * Build the shell command for invoking Claude CLI. @@ -54,7 +139,10 @@ type ClaudeCommandConfig = * - 'config-dir': Sets CLAUDE_CONFIG_DIR for custom profile location * * All non-default methods include history-safe prefixes (HISTFILE=, HISTCONTROL=) - * to prevent sensitive data from appearing in shell history. + * to prevent sensitive data from appearing in shell history (Unix/macOS only). + * + * On Windows, uses cmd.exe/PowerShell compatible syntax without bash-specific commands. + * The temp file method on Windows uses a batch file approach with inline environment setup. * * @param cwdCommand - Command to change directory (empty string if no change needed) * @param pathPrefix - PATH prefix for Claude CLI (empty string if not needed) @@ -64,13 +152,17 @@ type ClaudeCommandConfig = * @returns Complete shell command string ready for terminal.pty.write() * * @example - * // Default method + * // Default method (Unix/macOS) * buildClaudeShellCommand('cd /path && ', 'PATH=/bin ', 'claude', { method: 'default' }); * // Returns: 'cd /path && PATH=/bin claude\r' * - * // Temp file method - * buildClaudeShellCommand('', '', 'claude', { method: 'temp-file', escapedTempFile: '/tmp/token' }); + * // Temp file method (Unix/macOS) + * buildClaudeShellCommand('', '', 'claude', { method: 'temp-file', tempFile: '/tmp/token' }); * // Returns: 'clear && HISTFILE= HISTCONTROL=ignorespace bash -c "source /tmp/token && rm -f /tmp/token && exec claude"\r' + * + * // Temp file method (Windows) + * buildClaudeShellCommand('', '', 'claude.cmd', { method: 'temp-file', tempFile: 'C:\\Users\\...\\token.bat' }); + * // Returns: 'cls && call C:\\Users\\...\\token.bat && claude.cmd\r' */ export function buildClaudeShellCommand( cwdCommand: string, @@ -80,12 +172,43 @@ export function buildClaudeShellCommand( extraFlags?: string ): string { const fullCmd = extraFlags ? `${escapedClaudeCmd}${extraFlags}` : escapedClaudeCmd; + const isWin = isWindows(); + switch (config.method) { case 'temp-file': - return `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace ${pathPrefix}bash -c "source ${config.escapedTempFile} && rm -f ${config.escapedTempFile} && exec ${fullCmd}"\r`; + if (isWin) { + // Windows: Use batch file approach with 'call' command + // The temp file on Windows is a .bat file that sets CLAUDE_CODE_OAUTH_TOKEN + // We use 'cls' instead of 'clear', and 'call' to execute the batch file + // + // SECURITY: Environment variables set via 'call' persist in memory + // after the batch file is deleted, so we can safely delete the file + // immediately after sourcing it (before running Claude). + // + // For paths inside double quotes (call "..." and del "..."), use + // escapeForWindowsDoubleQuote() instead of escapeShellArgWindows() + // because caret is literal inside double quotes in cmd.exe. + const escapedTempFile = escapeForWindowsDoubleQuote(config.tempFile); + return `cls && ${cwdCommand}${pathPrefix}call "${escapedTempFile}" && del "${escapedTempFile}" && ${fullCmd}\r`; + } else { + // Unix/macOS: Use bash with source command and history-safe prefixes + const escapedTempFile = escapeShellArg(config.tempFile); + return `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace ${pathPrefix}bash -c "source ${escapedTempFile} && rm -f ${escapedTempFile} && exec ${fullCmd}"\r`; + } case 'config-dir': - return `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace CLAUDE_CONFIG_DIR=${config.escapedConfigDir} ${pathPrefix}bash -c "exec ${fullCmd}"\r`; + if (isWin) { + // Windows: Set environment variable using double-quote syntax + // For values inside double quotes (set "VAR=value"), use + // escapeForWindowsDoubleQuote() because caret is literal inside + // double quotes in cmd.exe (only double quotes need escaping). + const escapedConfigDir = escapeForWindowsDoubleQuote(config.configDir); + return `cls && ${cwdCommand}set "CLAUDE_CONFIG_DIR=${escapedConfigDir}" && ${pathPrefix}${fullCmd}\r`; + } else { + // Unix/macOS: Use bash with config dir and history-safe prefixes + const escapedConfigDir = escapeShellArg(config.configDir); + return `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace CLAUDE_CONFIG_DIR=${escapedConfigDir} ${pathPrefix}bash -c "exec ${fullCmd}"\r`; + } default: return `${cwdCommand}${pathPrefix}${fullCmd}\r`; @@ -416,98 +539,113 @@ export function invokeClaude( // Compute extra flags for YOLO mode const extraFlags = dangerouslySkipPermissions ? YOLO_MODE_FLAG : undefined; - terminal.isClaudeMode = true; - // Store YOLO mode setting so it persists across profile switches - terminal.dangerouslySkipPermissions = dangerouslySkipPermissions; - SessionHandler.releaseSessionId(terminal.id); - terminal.claudeSessionId = undefined; + // Track terminal state for cleanup on error + const wasClaudeMode = terminal.isClaudeMode; + const previousProfileId = terminal.claudeProfileId; - const startTime = Date.now(); - const projectPath = cwd || terminal.projectPath || terminal.cwd; + try { + terminal.isClaudeMode = true; + // Store YOLO mode setting so it persists across profile switches + terminal.dangerouslySkipPermissions = dangerouslySkipPermissions; + SessionHandler.releaseSessionId(terminal.id); + terminal.claudeSessionId = undefined; - const profileManager = getClaudeProfileManager(); - const activeProfile = profileId - ? profileManager.getProfile(profileId) - : profileManager.getActiveProfile(); + const startTime = Date.now(); + const projectPath = cwd || terminal.projectPath || terminal.cwd; - const previousProfileId = terminal.claudeProfileId; - terminal.claudeProfileId = activeProfile?.id; - - debugLog('[ClaudeIntegration:invokeClaude] Profile resolution:', { - previousProfileId, - newProfileId: activeProfile?.id, - profileName: activeProfile?.name, - hasOAuthToken: !!activeProfile?.oauthToken, - isDefault: activeProfile?.isDefault - }); + const profileManager = getClaudeProfileManager(); + const activeProfile = profileId + ? profileManager.getProfile(profileId) + : profileManager.getActiveProfile(); + + terminal.claudeProfileId = activeProfile?.id; + + debugLog('[ClaudeIntegration:invokeClaude] Profile resolution:', { + previousProfileId, + newProfileId: activeProfile?.id, + profileName: activeProfile?.name, + hasOAuthToken: !!activeProfile?.oauthToken, + isDefault: activeProfile?.isDefault + }); - const cwdCommand = buildCdCommand(cwd); - const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation(); - const escapedClaudeCmd = escapeShellArg(claudeCmd); - const pathPrefix = claudeEnv.PATH - ? `PATH=${escapeShellArg(normalizePathForBash(claudeEnv.PATH))} ` - : ''; - const needsEnvOverride = profileId && profileId !== previousProfileId; - - debugLog('[ClaudeIntegration:invokeClaude] Environment override check:', { - profileIdProvided: !!profileId, - previousProfileId, - needsEnvOverride - }); + const cwdCommand = buildCdCommand(cwd); + const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation(); + const escapedClaudeCmd = escapeShellCommand(claudeCmd); + const pathPrefix = buildPathPrefix(claudeEnv.PATH || ''); + const needsEnvOverride = profileId && profileId !== previousProfileId; - if (needsEnvOverride && activeProfile && !activeProfile.isDefault) { - const token = profileManager.getProfileToken(activeProfile.id); - debugLog('[ClaudeIntegration:invokeClaude] Token retrieval:', { - hasToken: !!token, - tokenLength: token?.length + debugLog('[ClaudeIntegration:invokeClaude] Environment override check:', { + profileIdProvided: !!profileId, + previousProfileId, + needsEnvOverride }); - if (token) { - const nonce = crypto.randomBytes(8).toString('hex'); - const tempFile = path.join(os.tmpdir(), `.claude-token-${Date.now()}-${nonce}`); - const escapedTempFile = escapeShellArg(tempFile); - debugLog('[ClaudeIntegration:invokeClaude] Writing token to temp file:', tempFile); - fs.writeFileSync( - tempFile, - `export CLAUDE_CODE_OAUTH_TOKEN=${escapeShellArg(token)}\n`, - { mode: 0o600 } - ); - - const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'temp-file', escapedTempFile }, extraFlags); - debugLog('[ClaudeIntegration:invokeClaude] Executing command (temp file method, history-safe)'); - terminal.pty.write(command); - profileManager.markProfileUsed(activeProfile.id); - finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); - debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE COMPLETE (temp file) =========='); - return; - } else if (activeProfile.configDir) { - const escapedConfigDir = escapeShellArg(activeProfile.configDir); - const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'config-dir', escapedConfigDir }, extraFlags); - debugLog('[ClaudeIntegration:invokeClaude] Executing command (configDir method, history-safe)'); - terminal.pty.write(command); - profileManager.markProfileUsed(activeProfile.id); - finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); - debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE COMPLETE (configDir) =========='); - return; - } else { - debugLog('[ClaudeIntegration:invokeClaude] WARNING: No token or configDir available for non-default profile'); + if (needsEnvOverride && activeProfile && !activeProfile.isDefault) { + const token = profileManager.getProfileToken(activeProfile.id); + debugLog('[ClaudeIntegration:invokeClaude] Token retrieval:', { + hasToken: !!token, + tokenLength: token?.length + }); + + if (token) { + const nonce = crypto.randomBytes(8).toString('hex'); + const tempFile = path.join(os.tmpdir(), `.claude-token-${Date.now()}-${nonce}${getTempFileExtension()}`); + debugLog('[ClaudeIntegration:invokeClaude] Writing token to temp file:', tempFile); + fs.writeFileSync( + tempFile, + generateTokenTempFileContent(token), + { mode: 0o600 } + ); + + const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'temp-file', tempFile }, extraFlags); + debugLog('[ClaudeIntegration:invokeClaude] Executing command (temp file method, history-safe)'); + terminal.pty.write(command); + profileManager.markProfileUsed(activeProfile.id); + finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); + debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE COMPLETE (temp file) =========='); + return; + } else if (activeProfile.configDir) { + const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'config-dir', configDir: activeProfile.configDir }, extraFlags); + debugLog('[ClaudeIntegration:invokeClaude] Executing command (configDir method, history-safe)'); + terminal.pty.write(command); + profileManager.markProfileUsed(activeProfile.id); + finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); + debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE COMPLETE (configDir) =========='); + return; + } else { + debugLog('[ClaudeIntegration:invokeClaude] WARNING: No token or configDir available for non-default profile'); + } } - } - if (activeProfile && !activeProfile.isDefault) { - debugLog('[ClaudeIntegration:invokeClaude] Using terminal environment for non-default profile:', activeProfile.name); - } + if (activeProfile && !activeProfile.isDefault) { + debugLog('[ClaudeIntegration:invokeClaude] Using terminal environment for non-default profile:', activeProfile.name); + } - const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'default' }, extraFlags); - debugLog('[ClaudeIntegration:invokeClaude] Executing command (default method):', command); - terminal.pty.write(command); + const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'default' }, extraFlags); + debugLog('[ClaudeIntegration:invokeClaude] Executing command (default method):', command); + terminal.pty.write(command); - if (activeProfile) { - profileManager.markProfileUsed(activeProfile.id); - } + if (activeProfile) { + profileManager.markProfileUsed(activeProfile.id); + } - finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); - debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE COMPLETE (default) =========='); + finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); + debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE COMPLETE (default) =========='); + } catch (error) { + // Reset terminal state on error to prevent inconsistent state + terminal.isClaudeMode = wasClaudeMode; + terminal.claudeSessionId = undefined; + terminal.claudeProfileId = previousProfileId; + debugError('[ClaudeIntegration:invokeClaude] Invocation failed:', error); + debugError('[ClaudeIntegration:invokeClaude] Error details:', { + terminalId: terminal.id, + profileId, + cwd, + errorName: error instanceof Error ? error.name : 'Unknown', + errorMessage: error instanceof Error ? error.message : String(error) + }); + throw error; // Re-throw to allow caller to handle + } } /** @@ -526,45 +664,54 @@ export function resumeClaude( _sessionId: string | undefined, getWindow: WindowGetter ): void { - terminal.isClaudeMode = true; - SessionHandler.releaseSessionId(terminal.id); - - const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation(); - const escapedClaudeCmd = escapeShellArg(claudeCmd); - const pathPrefix = claudeEnv.PATH - ? `PATH=${escapeShellArg(normalizePathForBash(claudeEnv.PATH))} ` - : ''; - - // Always use --continue which resumes the most recent session in the current directory. - // This is more reliable than --resume with session IDs since Auto Claude already restores - // terminals to their correct cwd/projectPath. - // - // Note: We clear claudeSessionId because --continue doesn't track specific sessions, - // and we don't want stale IDs persisting through SessionHandler.persistSession(). - terminal.claudeSessionId = undefined; + // Track terminal state for cleanup on error + const wasClaudeMode = terminal.isClaudeMode; - // Deprecation warning for callers still passing sessionId - if (_sessionId) { - console.warn('[ClaudeIntegration:resumeClaude] sessionId parameter is deprecated and ignored; using claude --continue instead'); - } + try { + terminal.isClaudeMode = true; + SessionHandler.releaseSessionId(terminal.id); + + const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation(); + const escapedClaudeCmd = escapeShellCommand(claudeCmd); + const pathPrefix = buildPathPrefix(claudeEnv.PATH || ''); + + // Always use --continue which resumes the most recent session in the current directory. + // This is more reliable than --resume with session IDs since Auto Claude already restores + // terminals to their correct cwd/projectPath. + // + // Note: We clear claudeSessionId because --continue doesn't track specific sessions, + // and we don't want stale IDs persisting through SessionHandler.persistSession(). + terminal.claudeSessionId = undefined; + + // Deprecation warning for callers still passing sessionId + if (_sessionId) { + console.warn('[ClaudeIntegration:resumeClaude] sessionId parameter is deprecated and ignored; using claude --continue instead'); + } - const command = `${pathPrefix}${escapedClaudeCmd} --continue`; + const command = `${pathPrefix}${escapedClaudeCmd} --continue`; - terminal.pty.write(`${command}\r`); + terminal.pty.write(`${command}\r`); - // Only auto-rename if terminal has default name - // This preserves user-customized names and prevents renaming on every resume - if (shouldAutoRenameTerminal(terminal.title)) { - terminal.title = 'Claude'; - const win = getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, 'Claude'); + // Only auto-rename if terminal has default name + // This preserves user-customized names and prevents renaming on every resume + if (shouldAutoRenameTerminal(terminal.title)) { + terminal.title = 'Claude'; + const win = getWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, 'Claude'); + } } - } - // Persist session - if (terminal.projectPath) { - SessionHandler.persistSession(terminal); + // Persist session + if (terminal.projectPath) { + SessionHandler.persistSession(terminal); + } + } catch (error) { + // Reset terminal state on error to prevent inconsistent state + terminal.isClaudeMode = wasClaudeMode; + // Note: Don't restore claudeSessionId since --continue doesn't use session IDs + debugError('[ClaudeIntegration:resumeClaude] Resume failed:', error); + throw error; // Re-throw to allow caller to handle } } @@ -577,6 +724,7 @@ export function resumeClaude( * * Safe to call from Electron main process without blocking the event loop. * Uses async CLI detection which doesn't block on subprocess calls. + * Includes error handling and timeout protection to prevent hangs. */ export async function invokeClaudeAsync( terminal: TerminalProcess, @@ -586,109 +734,138 @@ export async function invokeClaudeAsync( onSessionCapture: (terminalId: string, projectPath: string, startTime: number) => void, dangerouslySkipPermissions?: boolean ): Promise { - debugLog('[ClaudeIntegration:invokeClaudeAsync] ========== INVOKE CLAUDE START (async) =========='); - debugLog('[ClaudeIntegration:invokeClaudeAsync] Terminal ID:', terminal.id); - debugLog('[ClaudeIntegration:invokeClaudeAsync] Requested profile ID:', profileId); - debugLog('[ClaudeIntegration:invokeClaudeAsync] CWD:', cwd); - debugLog('[ClaudeIntegration:invokeClaudeAsync] Dangerously skip permissions:', dangerouslySkipPermissions); - - // Compute extra flags for YOLO mode - const extraFlags = dangerouslySkipPermissions ? YOLO_MODE_FLAG : undefined; - - terminal.isClaudeMode = true; - // Store YOLO mode setting so it persists across profile switches - terminal.dangerouslySkipPermissions = dangerouslySkipPermissions; - SessionHandler.releaseSessionId(terminal.id); - terminal.claudeSessionId = undefined; + // Track terminal state for cleanup on error + const wasClaudeMode = terminal.isClaudeMode; + const previousProfileId = terminal.claudeProfileId; const startTime = Date.now(); - const projectPath = cwd || terminal.projectPath || terminal.cwd; - // Ensure profile manager is initialized (async, yields to event loop) - const profileManager = await initializeClaudeProfileManager(); - const activeProfile = profileId - ? profileManager.getProfile(profileId) - : profileManager.getActiveProfile(); - - const previousProfileId = terminal.claudeProfileId; - terminal.claudeProfileId = activeProfile?.id; - - debugLog('[ClaudeIntegration:invokeClaudeAsync] Profile resolution:', { - previousProfileId, - newProfileId: activeProfile?.id, - profileName: activeProfile?.name, - hasOAuthToken: !!activeProfile?.oauthToken, - isDefault: activeProfile?.isDefault - }); + try { + debugLog('[ClaudeIntegration:invokeClaudeAsync] ========== INVOKE CLAUDE START (async) =========='); + debugLog('[ClaudeIntegration:invokeClaudeAsync] Terminal ID:', terminal.id); + debugLog('[ClaudeIntegration:invokeClaudeAsync] Requested profile ID:', profileId); + debugLog('[ClaudeIntegration:invokeClaudeAsync] CWD:', cwd); + debugLog('[ClaudeIntegration:invokeClaudeAsync] Dangerously skip permissions:', dangerouslySkipPermissions); + + // Compute extra flags for YOLO mode + const extraFlags = dangerouslySkipPermissions ? YOLO_MODE_FLAG : undefined; + + terminal.isClaudeMode = true; + // Store YOLO mode setting so it persists across profile switches + terminal.dangerouslySkipPermissions = dangerouslySkipPermissions; + SessionHandler.releaseSessionId(terminal.id); + terminal.claudeSessionId = undefined; + + const projectPath = cwd || terminal.projectPath || terminal.cwd; + + // Ensure profile manager is initialized (async, yields to event loop) + const profileManager = await initializeClaudeProfileManager(); + const activeProfile = profileId + ? profileManager.getProfile(profileId) + : profileManager.getActiveProfile(); + + terminal.claudeProfileId = activeProfile?.id; + + debugLog('[ClaudeIntegration:invokeClaudeAsync] Profile resolution:', { + previousProfileId, + newProfileId: activeProfile?.id, + profileName: activeProfile?.name, + hasOAuthToken: !!activeProfile?.oauthToken, + isDefault: activeProfile?.isDefault + }); - // Async CLI invocation - non-blocking - const cwdCommand = buildCdCommand(cwd); - const { command: claudeCmd, env: claudeEnv } = await getClaudeCliInvocationAsync(); - const escapedClaudeCmd = escapeShellArg(claudeCmd); - const pathPrefix = claudeEnv.PATH - ? `PATH=${escapeShellArg(normalizePathForBash(claudeEnv.PATH))} ` - : ''; - const needsEnvOverride = profileId && profileId !== previousProfileId; - - debugLog('[ClaudeIntegration:invokeClaudeAsync] Environment override check:', { - profileIdProvided: !!profileId, - previousProfileId, - needsEnvOverride - }); + // Async CLI invocation - non-blocking + const cwdCommand = buildCdCommand(cwd); - if (needsEnvOverride && activeProfile && !activeProfile.isDefault) { - const token = profileManager.getProfileToken(activeProfile.id); - debugLog('[ClaudeIntegration:invokeClaudeAsync] Token retrieval:', { - hasToken: !!token, - tokenLength: token?.length + // Add timeout protection for CLI detection (10s timeout) + const cliInvocationPromise = getClaudeCliInvocationAsync(); + let timeoutId: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error('CLI invocation timeout after 10s')), 10000); + }); + const { command: claudeCmd, env: claudeEnv } = await Promise.race([cliInvocationPromise, timeoutPromise]) + .finally(() => { + if (timeoutId) clearTimeout(timeoutId); + }); + + const escapedClaudeCmd = escapeShellCommand(claudeCmd); + const pathPrefix = buildPathPrefix(claudeEnv.PATH || ''); + const needsEnvOverride = profileId && profileId !== previousProfileId; + + debugLog('[ClaudeIntegration:invokeClaudeAsync] Environment override check:', { + profileIdProvided: !!profileId, + previousProfileId, + needsEnvOverride }); - if (token) { - const nonce = crypto.randomBytes(8).toString('hex'); - const tempFile = path.join(os.tmpdir(), `.claude-token-${Date.now()}-${nonce}`); - const escapedTempFile = escapeShellArg(tempFile); - debugLog('[ClaudeIntegration:invokeClaudeAsync] Writing token to temp file:', tempFile); - await fsPromises.writeFile( - tempFile, - `export CLAUDE_CODE_OAUTH_TOKEN=${escapeShellArg(token)}\n`, - { mode: 0o600 } - ); - - const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'temp-file', escapedTempFile }, extraFlags); - debugLog('[ClaudeIntegration:invokeClaudeAsync] Executing command (temp file method, history-safe)'); - terminal.pty.write(command); - profileManager.markProfileUsed(activeProfile.id); - finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); - debugLog('[ClaudeIntegration:invokeClaudeAsync] ========== INVOKE CLAUDE COMPLETE (temp file) =========='); - return; - } else if (activeProfile.configDir) { - const escapedConfigDir = escapeShellArg(activeProfile.configDir); - const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'config-dir', escapedConfigDir }, extraFlags); - debugLog('[ClaudeIntegration:invokeClaudeAsync] Executing command (configDir method, history-safe)'); - terminal.pty.write(command); - profileManager.markProfileUsed(activeProfile.id); - finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); - debugLog('[ClaudeIntegration:invokeClaudeAsync] ========== INVOKE CLAUDE COMPLETE (configDir) =========='); - return; - } else { - debugLog('[ClaudeIntegration:invokeClaudeAsync] WARNING: No token or configDir available for non-default profile'); + if (needsEnvOverride && activeProfile && !activeProfile.isDefault) { + const token = profileManager.getProfileToken(activeProfile.id); + debugLog('[ClaudeIntegration:invokeClaudeAsync] Token retrieval:', { + hasToken: !!token, + tokenLength: token?.length + }); + + if (token) { + const nonce = crypto.randomBytes(8).toString('hex'); + const tempFile = path.join(os.tmpdir(), `.claude-token-${Date.now()}-${nonce}${getTempFileExtension()}`); + debugLog('[ClaudeIntegration:invokeClaudeAsync] Writing token to temp file:', tempFile); + await fsPromises.writeFile( + tempFile, + generateTokenTempFileContent(token), + { mode: 0o600 } + ); + + const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'temp-file', tempFile }, extraFlags); + debugLog('[ClaudeIntegration:invokeClaudeAsync] Executing command (temp file method, history-safe)'); + terminal.pty.write(command); + profileManager.markProfileUsed(activeProfile.id); + finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); + debugLog('[ClaudeIntegration:invokeClaudeAsync] ========== INVOKE CLAUDE COMPLETE (temp file) =========='); + return; + } else if (activeProfile.configDir) { + const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'config-dir', configDir: activeProfile.configDir }, extraFlags); + debugLog('[ClaudeIntegration:invokeClaudeAsync] Executing command (configDir method, history-safe)'); + terminal.pty.write(command); + profileManager.markProfileUsed(activeProfile.id); + finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); + debugLog('[ClaudeIntegration:invokeClaudeAsync] ========== INVOKE CLAUDE COMPLETE (configDir) =========='); + return; + } else { + debugLog('[ClaudeIntegration:invokeClaudeAsync] WARNING: No token or configDir available for non-default profile'); + } } - } - if (activeProfile && !activeProfile.isDefault) { - debugLog('[ClaudeIntegration:invokeClaudeAsync] Using terminal environment for non-default profile:', activeProfile.name); - } + if (activeProfile && !activeProfile.isDefault) { + debugLog('[ClaudeIntegration:invokeClaudeAsync] Using terminal environment for non-default profile:', activeProfile.name); + } - const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'default' }, extraFlags); - debugLog('[ClaudeIntegration:invokeClaudeAsync] Executing command (default method):', command); - terminal.pty.write(command); + const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'default' }, extraFlags); + debugLog('[ClaudeIntegration:invokeClaudeAsync] Executing command (default method):', command); + terminal.pty.write(command); - if (activeProfile) { - profileManager.markProfileUsed(activeProfile.id); - } + if (activeProfile) { + profileManager.markProfileUsed(activeProfile.id); + } - finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); - debugLog('[ClaudeIntegration:invokeClaudeAsync] ========== INVOKE CLAUDE COMPLETE (default) =========='); + finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); + debugLog('[ClaudeIntegration:invokeClaudeAsync] ========== INVOKE CLAUDE COMPLETE (default) =========='); + } catch (error) { + // Reset terminal state on error to prevent inconsistent state + terminal.isClaudeMode = wasClaudeMode; + terminal.claudeSessionId = undefined; + terminal.claudeProfileId = previousProfileId; + const elapsed = Date.now() - startTime; + debugError('[ClaudeIntegration:invokeClaudeAsync] Invocation failed:', error); + debugError('[ClaudeIntegration:invokeClaudeAsync] Error details:', { + terminalId: terminal.id, + profileId, + cwd, + elapsedMs: elapsed, + errorName: error instanceof Error ? error.name : 'Unknown', + errorMessage: error instanceof Error ? error.message : String(error) + }); + throw error; // Re-throw to allow caller to handle + } } /** @@ -702,45 +879,65 @@ export async function resumeClaudeAsync( sessionId: string | undefined, getWindow: WindowGetter ): Promise { - terminal.isClaudeMode = true; - SessionHandler.releaseSessionId(terminal.id); - - // Async CLI invocation - non-blocking - const { command: claudeCmd, env: claudeEnv } = await getClaudeCliInvocationAsync(); - const escapedClaudeCmd = escapeShellArg(claudeCmd); - const pathPrefix = claudeEnv.PATH - ? `PATH=${escapeShellArg(normalizePathForBash(claudeEnv.PATH))} ` - : ''; - - // Always use --continue which resumes the most recent session in the current directory. - // This is more reliable than --resume with session IDs since Auto Claude already restores - // terminals to their correct cwd/projectPath. - // - // Note: We clear claudeSessionId because --continue doesn't track specific sessions, - // and we don't want stale IDs persisting through SessionHandler.persistSession(). - terminal.claudeSessionId = undefined; + // Track terminal state for cleanup on error + const wasClaudeMode = terminal.isClaudeMode; - // Deprecation warning for callers still passing sessionId - if (sessionId) { - console.warn('[ClaudeIntegration:resumeClaudeAsync] sessionId parameter is deprecated and ignored; using claude --continue instead'); - } + try { + terminal.isClaudeMode = true; + SessionHandler.releaseSessionId(terminal.id); + + // Async CLI invocation - non-blocking + // Add timeout protection for CLI detection (10s timeout) + const cliInvocationPromise = getClaudeCliInvocationAsync(); + let timeoutId: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error('CLI invocation timeout after 10s')), 10000); + }); + + const { command: claudeCmd, env: claudeEnv } = await Promise.race([cliInvocationPromise, timeoutPromise]) + .finally(() => { + if (timeoutId) clearTimeout(timeoutId); + }); + + const escapedClaudeCmd = escapeShellCommand(claudeCmd); + const pathPrefix = buildPathPrefix(claudeEnv.PATH || ''); + + // Always use --continue which resumes the most recent session in the current directory. + // This is more reliable than --resume with session IDs since Auto Claude already restores + // terminals to their correct cwd/projectPath. + // + // Note: We clear claudeSessionId because --continue doesn't track specific sessions, + // and we don't want stale IDs persisting through SessionHandler.persistSession(). + terminal.claudeSessionId = undefined; + + // Deprecation warning for callers still passing sessionId + if (sessionId) { + console.warn('[ClaudeIntegration:resumeClaudeAsync] sessionId parameter is deprecated and ignored; using claude --continue instead'); + } - const command = `${pathPrefix}${escapedClaudeCmd} --continue`; + const command = `${pathPrefix}${escapedClaudeCmd} --continue`; - terminal.pty.write(`${command}\r`); + terminal.pty.write(`${command}\r`); - // Only auto-rename if terminal has default name - // This preserves user-customized names and prevents renaming on every resume - if (shouldAutoRenameTerminal(terminal.title)) { - terminal.title = 'Claude'; - const win = getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, 'Claude'); + // Only auto-rename if terminal has default name + // This preserves user-customized names and prevents renaming on every resume + if (shouldAutoRenameTerminal(terminal.title)) { + terminal.title = 'Claude'; + const win = getWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, 'Claude'); + } } - } - if (terminal.projectPath) { - SessionHandler.persistSession(terminal); + if (terminal.projectPath) { + SessionHandler.persistSession(terminal); + } + } catch (error) { + // Reset terminal state on error to prevent inconsistent state + terminal.isClaudeMode = wasClaudeMode; + // Note: Don't restore claudeSessionId since --continue doesn't use session IDs + debugError('[ClaudeIntegration:resumeClaudeAsync] Resume failed:', error); + throw error; // Re-throw to allow caller to handle } } diff --git a/apps/frontend/src/shared/platform.ts b/apps/frontend/src/shared/platform.ts new file mode 100644 index 0000000000..7b6eacecf9 --- /dev/null +++ b/apps/frontend/src/shared/platform.ts @@ -0,0 +1,65 @@ +/** + * Platform abstraction for cross-platform operations. + * + * This module provides a centralized way to check the current platform + * that can be easily mocked in tests. Tests can mock the getCurrentPlatform + * function to test platform-specific behavior without relying on the + * actual runtime platform. + */ + +/** + * Supported platform identifiers + */ +export type Platform = 'win32' | 'darwin' | 'linux' | 'unknown'; + +/** + * Get the current platform identifier. + * + * In production, this returns the actual Node.js process.platform. + * In tests, this can be mocked to test platform-specific behavior. + * + * @returns The current platform identifier + */ +export function getCurrentPlatform(): Platform { + const p = process.platform; + if (p === 'win32' || p === 'darwin' || p === 'linux') { + return p; + } + return 'unknown'; +} + +/** + * Check if the current platform is Windows. + * + * @returns true if running on Windows + */ +export function isWindows(): boolean { + return getCurrentPlatform() === 'win32'; +} + +/** + * Check if the current platform is macOS. + * + * @returns true if running on macOS + */ +export function isMacOS(): boolean { + return getCurrentPlatform() === 'darwin'; +} + +/** + * Check if the current platform is Linux. + * + * @returns true if running on Linux + */ +export function isLinux(): boolean { + return getCurrentPlatform() === 'linux'; +} + +/** + * Check if the current platform is Unix-like (macOS or Linux). + * + * @returns true if running on a Unix-like platform + */ +export function isUnix(): boolean { + return isMacOS() || isLinux(); +} diff --git a/apps/frontend/src/shared/utils/shell-escape.ts b/apps/frontend/src/shared/utils/shell-escape.ts index 93f4283033..d826378c88 100644 --- a/apps/frontend/src/shared/utils/shell-escape.ts +++ b/apps/frontend/src/shared/utils/shell-escape.ts @@ -5,6 +5,8 @@ * IMPORTANT: Always use these utilities when interpolating user-controlled values into shell commands. */ +import { isWindows } from '../platform'; + /** * Escape a string for safe use as a shell argument. * @@ -42,6 +44,9 @@ export function escapeShellPath(path: string): string { * Build a safe cd command from a path. * Uses platform-appropriate quoting (double quotes on Windows, single quotes on Unix). * + * On Windows, uses the /d flag to allow changing drives (e.g., from C: to D:) + * and uses escapeForWindowsDoubleQuote for proper escaping inside double quotes. + * * @param path - The directory path * @returns A safe "cd '' && " string, or empty string if path is undefined */ @@ -51,11 +56,12 @@ export function buildCdCommand(path: string | undefined): string { } // Windows cmd.exe uses double quotes, Unix shells use single quotes - if (process.platform === 'win32') { - // On Windows, escape cmd.exe metacharacters (& | < > ^) that could enable command injection, - // then wrap in double quotes. Using escapeShellArgWindows for proper escaping. - const escaped = escapeShellArgWindows(path); - return `cd "${escaped}" && `; + if (isWindows()) { + // On Windows, use cd /d to change drives and directories simultaneously. + // For values inside double quotes, use escapeForWindowsDoubleQuote() because + // caret is literal inside double quotes in cmd.exe (only double quotes need escaping). + const escaped = escapeForWindowsDoubleQuote(path); + return `cd /d "${escaped}" && `; } return `cd ${escapeShellPath(path)} && `; @@ -76,7 +82,10 @@ export function escapeShellArgWindows(arg: string): string { // ^ is the escape character in cmd.exe // " & | < > ^ need to be escaped // % is used for variable expansion + // \n and \r terminate commands and must be removed const escaped = arg + .replace(/\r/g, '') // Remove carriage returns (command terminators) + .replace(/\n/g, '') // Remove newlines (command terminators) .replace(/\^/g, '^^') // Escape carets first (escape char itself) .replace(/"/g, '^"') // Escape double quotes .replace(/&/g, '^&') // Escape ampersand (command separator) @@ -88,6 +97,40 @@ export function escapeShellArgWindows(arg: string): string { return escaped; } +/** + * Escape a string for safe use inside Windows cmd.exe double-quoted strings. + * + * Inside double quotes in cmd.exe, the escaping rules are different: + * - Caret (^) is a LITERAL character, not an escape character + * - Only double quotes need escaping, done by doubling them ("") + * - Percent signs (%) must be escaped as %% to prevent variable expansion + * - Newlines/carriage returns still need removal (command terminators) + * + * Use this for values in set commands like: set "VAR=value" + * + * Examples: + * - "hello" → "hello" + * - "it's" → "it's" + * - 'path with "quotes"' → 'path with ""quotes""' + * - "C:\Company & Co" → "C:\Company & Co" (ampersand protected by quotes) + * - "%PATH%" → "%%PATH%%" (percent escaped) + * + * @param arg - The argument to escape + * @returns The escaped argument (caller should wrap in double quotes) + */ +export function escapeForWindowsDoubleQuote(arg: string): string { + // Inside double quotes, only escape embedded double quotes by doubling them. + // Also escape percent signs to prevent variable expansion. + // Also remove newlines/carriage returns as they terminate commands. + const escaped = arg + .replace(/\r/g, '') // Remove carriage returns (command terminators) + .replace(/\n/g, '') // Remove newlines (command terminators) + .replace(/%/g, '%%') // Escape percent (variable expansion in cmd.exe) + .replace(/"/g, '""'); // Escape double quotes by doubling + + return escaped; +} + /** * Validate that a path doesn't contain obviously malicious patterns. * This is a defense-in-depth measure - escaping should handle all cases,