diff --git a/apps/frontend/src/main/__tests__/agent-events.test.ts b/apps/frontend/src/main/__tests__/agent-events.test.ts index fb54903c2e..c0eb5512a0 100644 --- a/apps/frontend/src/main/__tests__/agent-events.test.ts +++ b/apps/frontend/src/main/__tests__/agent-events.test.ts @@ -528,5 +528,58 @@ describe('AgentEvents', () => { expect(result.phase).toBe('analyzing'); expect(result.progress).toBe(25); }); + + it('should match mixed-case log messages (case insensitive)', () => { + // Backend logs may use "Project Analysis" instead of "PROJECT ANALYSIS" + const result = agentEvents.parseRoadmapProgress( + 'Project Analysis starting', + 'idle', + 0 + ); + + expect(result.phase).toBe('analyzing'); + expect(result.progress).toBe(20); + }); + + it('should match lowercase log messages (case insensitive)', () => { + const result = agentEvents.parseRoadmapProgress( + 'project discovery in progress', + 'analyzing', + 20 + ); + + expect(result.phase).toBe('discovering'); + expect(result.progress).toBe(40); + }); + }); + + describe('parseIdeationProgress - case insensitivity', () => { + it('should match mixed-case log messages', () => { + const completedTypes = new Set(); + const result = agentEvents.parseIdeationProgress( + 'Project Analysis starting', + 'idle', + 0, + completedTypes, + 5 + ); + + expect(result.phase).toBe('analyzing'); + expect(result.progress).toBe(10); + }); + + it('should match lowercase log messages', () => { + const completedTypes = new Set(); + const result = agentEvents.parseIdeationProgress( + 'context gathering in progress', + 'analyzing', + 10, + completedTypes, + 5 + ); + + expect(result.phase).toBe('discovering'); + expect(result.progress).toBe(20); + }); }); }); diff --git a/apps/frontend/src/main/agent/agent-events.ts b/apps/frontend/src/main/agent/agent-events.ts index 99dd9d6b9f..e3e61fa3eb 100644 --- a/apps/frontend/src/main/agent/agent-events.ts +++ b/apps/frontend/src/main/agent/agent-events.ts @@ -143,19 +143,22 @@ export class AgentEvents { let phase = currentPhase; let progress = currentProgress; - if (log.includes('PROJECT INDEX') || log.includes('PROJECT ANALYSIS')) { + // Use case-insensitive matching since backend logs may use mixed case + const lowerLog = log.toLowerCase(); + + if (lowerLog.includes('project index') || lowerLog.includes('project analysis')) { phase = 'analyzing'; progress = 10; - } else if (log.includes('CONTEXT GATHERING')) { + } else if (lowerLog.includes('context gathering')) { phase = 'discovering'; progress = 20; - } else if (log.includes('GENERATING IDEAS (PARALLEL)') || (log.includes('Starting') && log.includes('ideation agents in parallel'))) { + } else if (lowerLog.includes('generating ideas (parallel)') || (lowerLog.includes('starting') && lowerLog.includes('ideation agents in parallel'))) { phase = 'generating'; progress = 30; - } else if (log.includes('MERGE') || log.includes('FINALIZE')) { + } else if (lowerLog.includes('merge') || lowerLog.includes('finalize')) { phase = 'finalizing'; progress = 90; - } else if (log.includes('IDEATION COMPLETE')) { + } else if (lowerLog.includes('ideation complete')) { phase = 'complete'; progress = 100; } @@ -176,16 +179,19 @@ export class AgentEvents { let phase = currentPhase; let progress = currentProgress; - if (log.includes('PROJECT ANALYSIS')) { + // Use case-insensitive matching since backend logs may use mixed case + const lowerLog = log.toLowerCase(); + + if (lowerLog.includes('project analysis')) { phase = 'analyzing'; progress = 20; - } else if (log.includes('PROJECT DISCOVERY')) { + } else if (lowerLog.includes('project discovery')) { phase = 'discovering'; progress = 40; - } else if (log.includes('FEATURE GENERATION')) { + } else if (lowerLog.includes('feature generation')) { phase = 'generating'; progress = 70; - } else if (log.includes('ROADMAP GENERATED')) { + } else if (lowerLog.includes('roadmap generated')) { phase = 'complete'; progress = 100; } 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 92090e0b18..95e60c4245 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 @@ -40,6 +40,7 @@ const createMockTerminal = (overrides: Partial = {}): TerminalP title: 'Claude', cwd: '/tmp/project', projectPath: '/tmp/project', + shellType: 'bash', // Explicit shell type for consistent command generation ...overrides, }); @@ -100,6 +101,8 @@ 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; + // Default method now includes clear command at the start + expect(written).toContain("clear && "); expect(written).toContain("cd '/tmp/project' && "); expect(written).toContain("PATH='/opt/claude/bin:/usr/bin' "); expect(written).toContain("'/opt/claude bin/claude'\\''s'"); @@ -233,8 +236,8 @@ describe('claude-integration-handler', () => { const tokenPath = vi.mocked(writeFileSync).mock.calls[0]?.[0] as string; const tokenContents = vi.mocked(writeFileSync).mock.calls[0]?.[1] as string; - const tmpDir = escapeForRegex(tmpdir()); - expect(tokenPath).toMatch(new RegExp(`^${tmpDir}/\\.claude-token-1234-[0-9a-f]{16}$`)); + // On Windows, path.join normalizes /tmp to \tmp, so check for the filename pattern + expect(tokenPath).toMatch(/[/\\]\.claude-token-1234-[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 "); @@ -276,8 +279,8 @@ describe('claude-integration-handler', () => { const tokenPath = vi.mocked(writeFileSync).mock.calls[0]?.[0] as string; const tokenContents = vi.mocked(writeFileSync).mock.calls[0]?.[1] as string; - const tmpDir = escapeForRegex(tmpdir()); - expect(tokenPath).toMatch(new RegExp(`^${tmpDir}/\\.claude-token-5678-[0-9a-f]{16}$`)); + // On Windows, path.join normalizes /tmp to \tmp, so check for the filename pattern + expect(tokenPath).toMatch(/[/\\]\.claude-token-5678-[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}'`); @@ -427,25 +430,32 @@ describe('claude-integration-handler', () => { */ describe('claude-integration-handler - Helper Functions', () => { describe('buildClaudeShellCommand', () => { - it('should build default command without cwd or PATH prefix', async () => { + it('should build default command with clear for bash', async () => { const { buildClaudeShellCommand } = await import('../claude-integration-handler'); - const result = buildClaudeShellCommand('', '', "'/opt/bin/claude'", { method: 'default' }); + const result = buildClaudeShellCommand('', '', "'/opt/bin/claude'", { method: 'default', shellType: 'bash' }); - expect(result).toBe("'/opt/bin/claude'\r"); + expect(result).toBe("clear && '/opt/bin/claude'\r"); + }); + + it('should build default command with cls for powershell', async () => { + const { buildClaudeShellCommand } = await import('../claude-integration-handler'); + const result = buildClaudeShellCommand('', '', "& 'C:/bin/claude'", { method: 'default', shellType: 'powershell' }); + + expect(result).toBe("cls; & 'C:/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' }); + const result = buildClaudeShellCommand("cd '/tmp/project' && ", '', "'/opt/bin/claude'", { method: 'default', shellType: 'bash' }); - expect(result).toBe("cd '/tmp/project' && '/opt/bin/claude'\r"); + expect(result).toBe("clear && 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' }); + const result = buildClaudeShellCommand('', "PATH='/custom/path' ", "'/opt/bin/claude'", { method: 'default', shellType: 'bash' }); - expect(result).toBe("PATH='/custom/path' '/opt/bin/claude'\r"); + expect(result).toBe("clear && PATH='/custom/path' '/opt/bin/claude'\r"); }); it('should build temp-file method command with history-safe prefixes', async () => { @@ -612,4 +622,66 @@ describe('claude-integration-handler - Helper Functions', () => { }).not.toThrow(); }); }); + + describe('isBashCompatibleShell', () => { + it('should return true for bash', async () => { + const { isBashCompatibleShell } = await import('../claude-integration-handler'); + expect(isBashCompatibleShell('bash')).toBe(true); + }); + + it('should return true for zsh', async () => { + const { isBashCompatibleShell } = await import('../claude-integration-handler'); + expect(isBashCompatibleShell('zsh')).toBe(true); + }); + + it('should return false for powershell', async () => { + const { isBashCompatibleShell } = await import('../claude-integration-handler'); + expect(isBashCompatibleShell('powershell')).toBe(false); + }); + + it('should return false for pwsh', async () => { + const { isBashCompatibleShell } = await import('../claude-integration-handler'); + expect(isBashCompatibleShell('pwsh')).toBe(false); + }); + + it('should return false for cmd', async () => { + const { isBashCompatibleShell } = await import('../claude-integration-handler'); + expect(isBashCompatibleShell('cmd')).toBe(false); + }); + + it('should return false for unknown', async () => { + const { isBashCompatibleShell } = await import('../claude-integration-handler'); + expect(isBashCompatibleShell('unknown')).toBe(false); + }); + }); + + describe('buildClaudeShellCommand - shell type variations', () => { + it('should use cls for cmd shell', async () => { + const { buildClaudeShellCommand } = await import('../claude-integration-handler'); + const result = buildClaudeShellCommand('', '', '"claude"', { method: 'default', shellType: 'cmd' }); + + expect(result).toBe('cls && "claude"\r'); + }); + + it('should use cls; for pwsh shell', async () => { + const { buildClaudeShellCommand } = await import('../claude-integration-handler'); + const result = buildClaudeShellCommand('', '', "& 'claude'", { method: 'default', shellType: 'pwsh' }); + + expect(result).toBe("cls; & 'claude'\r"); + }); + + it('should use clear && for zsh shell', async () => { + const { buildClaudeShellCommand } = await import('../claude-integration-handler'); + const result = buildClaudeShellCommand('', '', "'claude'", { method: 'default', shellType: 'zsh' }); + + expect(result).toBe("clear && 'claude'\r"); + }); + + it('should use clear && for unknown shell (default)', async () => { + const { buildClaudeShellCommand } = await import('../claude-integration-handler'); + const result = buildClaudeShellCommand('', '', "'claude'", { method: 'default', shellType: 'unknown' }); + + expect(result).toBe("clear && 'claude'\r"); + }); + }); }); diff --git a/apps/frontend/src/main/terminal/__tests__/pty-manager.test.ts b/apps/frontend/src/main/terminal/__tests__/pty-manager.test.ts new file mode 100644 index 0000000000..d465872ef8 --- /dev/null +++ b/apps/frontend/src/main/terminal/__tests__/pty-manager.test.ts @@ -0,0 +1,252 @@ +/** + * PTY Manager Tests + * + * Tests for shell detection and PTY process management. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock node-pty to avoid native module issues in tests +vi.mock('@lydell/node-pty', () => ({ + spawn: vi.fn(), +})); + +// Mock fs for existsSync +vi.mock('fs', () => ({ + existsSync: vi.fn(() => false), +})); + +// Mock settings-utils +vi.mock('../../settings-utils', () => ({ + readSettingsFile: vi.fn(() => null), +})); + +// Mock claude-profile-manager +vi.mock('../../claude-profile-manager', () => ({ + getClaudeProfileManager: vi.fn(() => ({ + getActiveProfileEnv: vi.fn(() => ({})), + })), +})); + +import { detectShellType } from '../pty-manager'; + +describe('pty-manager', () => { + describe('detectShellType', () => { + describe('PowerShell Core (pwsh)', () => { + it('should detect pwsh.exe on Windows', () => { + expect(detectShellType('C:\\Program Files\\PowerShell\\7\\pwsh.exe')).toBe('pwsh'); + }); + + it('should detect pwsh without extension', () => { + expect(detectShellType('/usr/bin/pwsh')).toBe('pwsh'); + }); + + it('should detect pwsh with path separator', () => { + expect(detectShellType('/usr/local/bin/pwsh')).toBe('pwsh'); + }); + + it('should handle uppercase PWSH', () => { + expect(detectShellType('C:\\PWSH.EXE')).toBe('pwsh'); + }); + }); + + describe('Windows PowerShell', () => { + it('should detect powershell.exe', () => { + expect(detectShellType('C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe')).toBe('powershell'); + }); + + it('should handle various PowerShell paths', () => { + expect(detectShellType('/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell')).toBe('powershell'); + }); + }); + + describe('cmd.exe', () => { + it('should detect cmd.exe', () => { + expect(detectShellType('C:\\Windows\\System32\\cmd.exe')).toBe('cmd'); + }); + + it('should detect standalone cmd', () => { + expect(detectShellType('cmd')).toBe('cmd'); + }); + + it('should detect cmd with backslash', () => { + expect(detectShellType('C:\\Windows\\System32\\cmd')).toBe('cmd'); + }); + + it('should NOT match paths containing cmd as substring', () => { + // This was the bug - 'cmdtool' should not match as cmd + expect(detectShellType('C:\\Programs\\mycmdtool\\bash.exe')).toBe('bash'); + }); + + it('should NOT match files like command.exe', () => { + expect(detectShellType('C:\\Programs\\command.exe')).not.toBe('cmd'); + }); + }); + + describe('Bash', () => { + it('should detect bash.exe on Windows', () => { + expect(detectShellType('C:\\Program Files\\Git\\bin\\bash.exe')).toBe('bash'); + }); + + it('should detect bash on Unix', () => { + expect(detectShellType('/bin/bash')).toBe('bash'); + expect(detectShellType('/usr/bin/bash')).toBe('bash'); + }); + + it('should detect bash ending without extension', () => { + expect(detectShellType('/bin/bash')).toBe('bash'); + }); + + it('should detect MSYS2 bash', () => { + expect(detectShellType('C:\\msys64\\usr\\bin\\bash.exe')).toBe('bash'); + }); + + it('should detect Cygwin bash', () => { + expect(detectShellType('C:\\cygwin64\\bin\\bash.exe')).toBe('bash'); + }); + }); + + describe('Zsh', () => { + it('should detect zsh on Unix', () => { + expect(detectShellType('/bin/zsh')).toBe('zsh'); + expect(detectShellType('/usr/bin/zsh')).toBe('zsh'); + }); + + it('should detect zsh with path', () => { + expect(detectShellType('/usr/local/bin/zsh')).toBe('zsh'); + }); + }); + + describe('Edge cases and fallbacks', () => { + it('should handle case-insensitive matching', () => { + expect(detectShellType('C:\\BASH.EXE')).toBe('bash'); + expect(detectShellType('/BIN/BASH')).toBe('bash'); + expect(detectShellType('C:\\ZSH')).toBe('zsh'); + }); + + it('should return unknown for unrecognized Windows shells', () => { + // Save original platform + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + + expect(detectShellType('C:\\some\\random\\shell.exe')).toBe('unknown'); + + // Restore platform + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('should fallback to bash for unrecognized Unix shells', () => { + // Save original platform + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'darwin' }); + + expect(detectShellType('/usr/local/bin/fish')).toBe('bash'); + + // Restore platform + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('should handle empty string', () => { + // This depends on platform + const result = detectShellType(''); + expect(['bash', 'unknown']).toContain(result); + }); + }); + + describe('Complex path scenarios', () => { + it('should handle paths with spaces', () => { + expect(detectShellType('C:\\Program Files\\Git\\bin\\bash.exe')).toBe('bash'); + expect(detectShellType('C:\\Program Files\\PowerShell\\7\\pwsh.exe')).toBe('pwsh'); + }); + + it('should handle mixed forward/back slashes', () => { + expect(detectShellType('C:/Windows/System32/cmd.exe')).toBe('cmd'); + expect(detectShellType('C:\\Program Files/Git\\bin/bash.exe')).toBe('bash'); + }); + + it('should handle WSL paths', () => { + expect(detectShellType('/mnt/c/Windows/System32/bash.exe')).toBe('bash'); + }); + }); + + describe('False positive prevention (precise path boundary matching)', () => { + // These tests verify that shell names in directory paths don't cause false positives + // when using endsWith() instead of includes() + + describe('pwsh false positives', () => { + it('should NOT match pwsh-tools directory as pwsh', () => { + // /usr/local/pwsh-tools/bash should be bash, not pwsh + expect(detectShellType('/usr/local/pwsh-tools/bash')).toBe('bash'); + }); + + it('should NOT match pwsh in middle of path', () => { + expect(detectShellType('/home/user/pwsh/bin/zsh')).toBe('zsh'); + }); + }); + + describe('powershell false positives', () => { + it('should NOT match powershell-scripts directory as powershell', () => { + // /opt/powershell-scripts/zsh should be zsh, not powershell + expect(detectShellType('/opt/powershell-scripts/zsh')).toBe('zsh'); + }); + + it('should NOT match powershell in middle of path', () => { + expect(detectShellType('/home/user/powershell/modules/bash')).toBe('bash'); + }); + }); + + describe('cmd false positives', () => { + it('should NOT match cmd in username', () => { + // C:\Users\cmd-admin\Git\bin\bash.exe should be bash + expect(detectShellType('C:\\Users\\cmd-admin\\Git\\bin\\bash.exe')).toBe('bash'); + }); + + it('should NOT match commander directory as cmd', () => { + // /home/commander/bin/bash should be bash + expect(detectShellType('/home/commander/bin/bash')).toBe('bash'); + }); + + it('should NOT match cmdtools directory as cmd', () => { + expect(detectShellType('/usr/local/cmdtools/zsh')).toBe('zsh'); + }); + }); + + describe('bash false positives', () => { + it('should NOT match bash-tools directory as bash', () => { + // /path/to/bash-tools/zsh should be zsh, not bash + expect(detectShellType('/path/to/bash-tools/zsh')).toBe('zsh'); + }); + + it('should NOT match bash_scripts directory as bash', () => { + expect(detectShellType('/home/user/bash_scripts/pwsh')).toBe('pwsh'); + }); + }); + + describe('zsh false positives', () => { + it('should NOT match zsh-plugin directory as zsh', () => { + // /path/to/zsh-plugin/bash should be bash, not zsh + expect(detectShellType('/path/to/zsh-plugin/bash')).toBe('bash'); + }); + + it('should NOT match zshrc-backup directory as zsh', () => { + expect(detectShellType('/home/user/zshrc-backup/pwsh')).toBe('pwsh'); + }); + }); + + describe('complex mixed scenarios', () => { + it('should correctly identify shell even with multiple shell names in path', () => { + // Path contains pwsh, bash, cmd but ends with zsh + expect(detectShellType('/pwsh-tools/bash-scripts/cmd-utils/zsh')).toBe('zsh'); + + // Path contains all shell names but ends with bash + expect(detectShellType('/zsh-config/powershell/cmd/bash')).toBe('bash'); + }); + + it('should handle Windows paths with shell names in directories', () => { + expect(detectShellType('C:\\Users\\pwsh-user\\bash-scripts\\Git\\bin\\bash.exe')).toBe('bash'); + expect(detectShellType('C:\\cmd-tools\\powershell\\7\\pwsh.exe')).toBe('pwsh'); + }); + }); + }); + }); +}); diff --git a/apps/frontend/src/main/terminal/claude-integration-handler.ts b/apps/frontend/src/main/terminal/claude-integration-handler.ts index ae420b2d97..2100965db9 100644 --- a/apps/frontend/src/main/terminal/claude-integration-handler.ts +++ b/apps/frontend/src/main/terminal/claude-integration-handler.ts @@ -13,19 +13,16 @@ 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, buildCdCommandForShell, buildPathPrefixForShell, escapeCommandForShell } from '../../shared/utils/shell-escape'; import { getClaudeCliInvocation, getClaudeCliInvocationAsync } from '../claude-cli-utils'; import type { TerminalProcess, + ShellType, WindowGetter, RateLimitEvent, OAuthTokenEvent } from './types'; -function normalizePathForBash(envPath: string): string { - return process.platform === 'win32' ? envPath.replace(/;/g, ':') : envPath; -} - // ============================================================================ // SHARED HELPERS - Used by both sync and async invokeClaude // ============================================================================ @@ -35,17 +32,50 @@ function normalizePathForBash(envPath: string): string { * This provides type safety by ensuring the correct options are provided for each method. */ type ClaudeCommandConfig = - | { method: 'default' } + | { method: 'default'; shellType: ShellType } | { method: 'temp-file'; escapedTempFile: string } | { method: 'config-dir'; escapedConfigDir: string }; +/** + * Get the clear screen command for a shell type + */ +function getClearCommand(shellType: ShellType): string { + switch (shellType) { + case 'powershell': + case 'pwsh': + return 'cls; '; + case 'cmd': + return 'cls && '; + case 'bash': + case 'zsh': + default: + return 'clear && '; + } +} + +/** + * Check if shell type supports bash-like features (source, export, bash -c) + * Used for OAuth token injection methods that require bash syntax. + * + * @param shellType - The shell type to check + * @returns true if shell supports bash-like features + */ +export function isBashCompatibleShell(shellType: ShellType): boolean { + return shellType === 'bash' || shellType === 'zsh'; +} + /** * Build the shell command for invoking Claude CLI. * * Generates the appropriate command string based on the invocation method: - * - 'default': Simple command execution - * - 'temp-file': Sources OAuth token from temp file, then removes it - * - 'config-dir': Sets CLAUDE_CONFIG_DIR for custom profile location + * - 'default': Simple command execution with screen clear (all shells) + * - 'temp-file': Sources OAuth token from temp file, then removes it (bash/zsh only) + * - 'config-dir': Sets CLAUDE_CONFIG_DIR for custom profile location (bash/zsh only) + * + * IMPORTANT: The 'temp-file' and 'config-dir' methods use bash-specific syntax + * (source, export, bash -c, exec) and require a bash-compatible shell (bash or zsh). + * On Windows with PowerShell or cmd.exe, use Git Bash or WSL for these methods. + * The caller should check isBashCompatibleShell() before using these methods. * * All non-default methods include history-safe prefixes (HISTFILE=, HISTCONTROL=) * to prevent sensitive data from appearing in shell history. @@ -57,11 +87,11 @@ type ClaudeCommandConfig = * @returns Complete shell command string ready for terminal.pty.write() * * @example - * // Default method - * buildClaudeShellCommand('cd /path && ', 'PATH=/bin ', 'claude', { method: 'default' }); - * // Returns: 'cd /path && PATH=/bin claude\r' + * // Default method (works with all shells) + * buildClaudeShellCommand('cd /path && ', 'PATH=/bin ', 'claude', { method: 'default', shellType: 'bash' }); + * // Returns: 'clear && cd /path && PATH=/bin claude\r' * - * // Temp file method + * // Temp file method (bash/zsh only - check isBashCompatibleShell() first) * buildClaudeShellCommand('', '', 'claude', { method: 'temp-file', escapedTempFile: '/tmp/token' }); * // Returns: 'clear && HISTFILE= HISTCONTROL=ignorespace bash -c "source /tmp/token && rm -f /tmp/token && exec claude"\r' */ @@ -73,13 +103,19 @@ export function buildClaudeShellCommand( ): string { switch (config.method) { case 'temp-file': + // NOTE: This method requires bash-compatible shell (bash or zsh). + // Uses bash-specific features: source, export, bash -c, exec return `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace ${pathPrefix}bash -c "source ${config.escapedTempFile} && rm -f ${config.escapedTempFile} && exec ${escapedClaudeCmd}"\r`; case 'config-dir': + // NOTE: This method requires bash-compatible shell (bash or zsh). + // Uses bash-specific features: CLAUDE_CONFIG_DIR env var, bash -c, exec return `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace CLAUDE_CONFIG_DIR=${config.escapedConfigDir} ${pathPrefix}bash -c "exec ${escapedClaudeCmd}"\r`; - default: - return `${cwdCommand}${pathPrefix}${escapedClaudeCmd}\r`; + default: { + const clearCmd = getClearCommand(config.shellType); + return `${clearCmd}${cwdCommand}${pathPrefix}${escapedClaudeCmd}\r`; + } } } @@ -367,11 +403,23 @@ export function invokeClaude( isDefault: activeProfile?.isDefault }); - const cwdCommand = buildCdCommand(cwd); + // Use shell-aware command generation based on terminal's shell type. + // Platform-aware fallback: 'cmd' on Windows, 'bash' on Unix. + // + // Note: We use 'cmd' as the Windows fallback (not 'powershell') because: + // 1. cmd.exe is the system default (COMSPEC) and universally available + // 2. User's preferred shell is already handled by PTY spawning (getWindowsShell) + // 3. This fallback only applies when shell detection fails (rare edge case) + // 4. Using the more conservative/universal option prevents issues in edge cases + const shellType = terminal.shellType || (process.platform === 'win32' ? 'cmd' : 'bash'); + if (!terminal.shellType) { + debugLog('[ClaudeIntegration:invokeClaude] Shell type not detected, using fallback:', shellType); + } + const cwdCommand = buildCdCommandForShell(cwd, shellType); const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation(); - const escapedClaudeCmd = escapeShellArg(claudeCmd); + const escapedClaudeCmd = escapeCommandForShell(claudeCmd, shellType); const pathPrefix = claudeEnv.PATH - ? `PATH=${escapeShellArg(normalizePathForBash(claudeEnv.PATH))} ` + ? buildPathPrefixForShell(claudeEnv.PATH, shellType) : ''; const needsEnvOverride = profileId && profileId !== previousProfileId; @@ -389,32 +437,46 @@ export function invokeClaude( }); 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 }); - 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; + // Check if shell supports bash-like features required for temp-file method + if (!isBashCompatibleShell(shellType)) { + console.warn('[ClaudeIntegration:invokeClaude] WARNING: temp-file method requires bash-compatible shell. Current shell:', shellType); + debugLog('[ClaudeIntegration:invokeClaude] Falling back to default method for non-bash shell'); + // Fall through to default method + } else { + 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 }); + 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 }); - 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; + // Check if shell supports bash-like features required for config-dir method + if (!isBashCompatibleShell(shellType)) { + console.warn('[ClaudeIntegration:invokeClaude] WARNING: config-dir method requires bash-compatible shell. Current shell:', shellType); + debugLog('[ClaudeIntegration:invokeClaude] Falling back to default method for non-bash shell'); + // Fall through to default method + } else { + const escapedConfigDir = escapeShellArg(activeProfile.configDir); + const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'config-dir', escapedConfigDir }); + 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'); } @@ -424,7 +486,7 @@ export function invokeClaude( debugLog('[ClaudeIntegration:invokeClaude] Using terminal environment for non-default profile:', activeProfile.name); } - const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'default' }); + const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'default', shellType }); debugLog('[ClaudeIntegration:invokeClaude] Executing command (default method):', command); terminal.pty.write(command); @@ -455,10 +517,15 @@ export function resumeClaude( terminal.isClaudeMode = true; SessionHandler.releaseSessionId(terminal.id); + // Use shell-aware command generation (see invokeClaude for fallback rationale) + const shellType = terminal.shellType || (process.platform === 'win32' ? 'cmd' : 'bash'); + if (!terminal.shellType) { + debugLog('[ClaudeIntegration:resumeClaude] Shell type not detected, using fallback:', shellType); + } const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation(); - const escapedClaudeCmd = escapeShellArg(claudeCmd); + const escapedClaudeCmd = escapeCommandForShell(claudeCmd, shellType); const pathPrefix = claudeEnv.PATH - ? `PATH=${escapeShellArg(normalizePathForBash(claudeEnv.PATH))} ` + ? buildPathPrefixForShell(claudeEnv.PATH, shellType) : ''; // Always use --continue which resumes the most recent session in the current directory. @@ -537,12 +604,16 @@ export async function invokeClaudeAsync( isDefault: activeProfile?.isDefault }); - // Async CLI invocation - non-blocking - const cwdCommand = buildCdCommand(cwd); + // Async CLI invocation - non-blocking, shell-aware command generation (see invokeClaude for fallback rationale) + const shellType = terminal.shellType || (process.platform === 'win32' ? 'cmd' : 'bash'); + if (!terminal.shellType) { + debugLog('[ClaudeIntegration:invokeClaudeAsync] Shell type not detected, using fallback:', shellType); + } + const cwdCommand = buildCdCommandForShell(cwd, shellType); const { command: claudeCmd, env: claudeEnv } = await getClaudeCliInvocationAsync(); - const escapedClaudeCmd = escapeShellArg(claudeCmd); + const escapedClaudeCmd = escapeCommandForShell(claudeCmd, shellType); const pathPrefix = claudeEnv.PATH - ? `PATH=${escapeShellArg(normalizePathForBash(claudeEnv.PATH))} ` + ? buildPathPrefixForShell(claudeEnv.PATH, shellType) : ''; const needsEnvOverride = profileId && profileId !== previousProfileId; @@ -560,32 +631,46 @@ export async function invokeClaudeAsync( }); 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 }); - 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; + // Check if shell supports bash-like features required for temp-file method + if (!isBashCompatibleShell(shellType)) { + console.warn('[ClaudeIntegration:invokeClaudeAsync] WARNING: temp-file method requires bash-compatible shell. Current shell:', shellType); + debugLog('[ClaudeIntegration:invokeClaudeAsync] Falling back to default method for non-bash shell'); + // Fall through to default method + } else { + 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 }); + 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 }); - 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; + // Check if shell supports bash-like features required for config-dir method + if (!isBashCompatibleShell(shellType)) { + console.warn('[ClaudeIntegration:invokeClaudeAsync] WARNING: config-dir method requires bash-compatible shell. Current shell:', shellType); + debugLog('[ClaudeIntegration:invokeClaudeAsync] Falling back to default method for non-bash shell'); + // Fall through to default method + } else { + const escapedConfigDir = escapeShellArg(activeProfile.configDir); + const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'config-dir', escapedConfigDir }); + 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'); } @@ -595,7 +680,7 @@ export async function invokeClaudeAsync( debugLog('[ClaudeIntegration:invokeClaudeAsync] Using terminal environment for non-default profile:', activeProfile.name); } - const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'default' }); + const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'default', shellType }); debugLog('[ClaudeIntegration:invokeClaudeAsync] Executing command (default method):', command); terminal.pty.write(command); @@ -621,11 +706,15 @@ export async function resumeClaudeAsync( terminal.isClaudeMode = true; SessionHandler.releaseSessionId(terminal.id); - // Async CLI invocation - non-blocking + // Async CLI invocation - non-blocking, shell-aware command generation (see invokeClaude for fallback rationale) + const shellType = terminal.shellType || (process.platform === 'win32' ? 'cmd' : 'bash'); + if (!terminal.shellType) { + debugLog('[ClaudeIntegration:resumeClaudeAsync] Shell type not detected, using fallback:', shellType); + } const { command: claudeCmd, env: claudeEnv } = await getClaudeCliInvocationAsync(); - const escapedClaudeCmd = escapeShellArg(claudeCmd); + const escapedClaudeCmd = escapeCommandForShell(claudeCmd, shellType); const pathPrefix = claudeEnv.PATH - ? `PATH=${escapeShellArg(normalizePathForBash(claudeEnv.PATH))} ` + ? buildPathPrefixForShell(claudeEnv.PATH, shellType) : ''; // Always use --continue which resumes the most recent session in the current directory. diff --git a/apps/frontend/src/main/terminal/pty-manager.ts b/apps/frontend/src/main/terminal/pty-manager.ts index 2117917b0c..110f3a4afb 100644 --- a/apps/frontend/src/main/terminal/pty-manager.ts +++ b/apps/frontend/src/main/terminal/pty-manager.ts @@ -6,13 +6,83 @@ import * as pty from '@lydell/node-pty'; import * as os from 'os'; import { existsSync } from 'fs'; -import type { TerminalProcess, WindowGetter } from './types'; +import type { TerminalProcess, WindowGetter, ShellType } from './types'; import { IPC_CHANNELS } from '../../shared/constants'; import { getClaudeProfileManager } from '../claude-profile-manager'; import { readSettingsFile } from '../settings-utils'; import { debugLog, debugError } from '../../shared/utils/debug-logger'; import type { SupportedTerminal } from '../../shared/types/settings'; +/** + * Result of spawning a PTY process + */ +export interface SpawnResult { + pty: pty.IPty; + shellType: ShellType; +} + +/** + * Detect shell type from executable path. + * + * Uses precise matching to avoid false positives (e.g., a path containing 'cmd' + * as a substring should not match as cmd.exe). + * + * @param shellPath - The path to the shell executable + * @returns The detected shell type + */ +export function detectShellType(shellPath: string): ShellType { + const normalized = shellPath.toLowerCase(); + + // Check for PowerShell Core (pwsh) first - more specific match + // Matches: pwsh.exe, pwsh, /usr/bin/pwsh + // Using endsWith() for precise path boundary matching to avoid false positives + // (e.g., /usr/local/pwsh-tools/bash should NOT match as pwsh) + if (normalized.endsWith('pwsh.exe') || normalized.endsWith('pwsh') || normalized.endsWith('/pwsh')) { + return 'pwsh'; + } + + // Check for Windows PowerShell + // Matches: powershell.exe, powershell, /powershell + // Using endsWith() for precise path boundary matching to avoid false positives + // (e.g., /opt/powershell-scripts/zsh should NOT match as powershell) + if (normalized.endsWith('powershell.exe') || normalized.endsWith('powershell') || normalized.endsWith('/powershell')) { + return 'powershell'; + } + + // Check for cmd.exe - use precise matching to avoid false positives + // A path like 'C:\Documents\mycmdtool\bash.exe' should NOT match as cmd + // Only match: cmd.exe, \cmd.exe, /cmd.exe, or just 'cmd' + if ( + normalized.endsWith('cmd.exe') || + normalized.endsWith('\\cmd') || + normalized.endsWith('/cmd') || + normalized === 'cmd' + ) { + return 'cmd'; + } + + // Check for bash (includes Git Bash, Cygwin, MSYS2) + // Matches: bash.exe, bash, /bin/bash, /usr/bin/bash + // Using endsWith() for precise path boundary matching to avoid false positives + // (e.g., /path/to/bash-tools/zsh should NOT match as bash) + if (normalized.endsWith('bash.exe') || normalized.endsWith('bash') || normalized.endsWith('/bash')) { + return 'bash'; + } + + // Check for zsh + // Matches: zsh, /bin/zsh, /usr/bin/zsh + // Using endsWith() for precise path boundary matching to avoid false positives + // (e.g., /path/to/zsh-plugin/bash should NOT match as zsh) + if (normalized.endsWith('zsh') || normalized.endsWith('/zsh')) { + return 'zsh'; + } + + // Unix fallback based on platform + if (process.platform !== 'win32') return 'bash'; + + return 'unknown'; +} + /** * Windows shell paths for different terminal preferences */ @@ -68,13 +138,14 @@ function getWindowsShell(preferredTerminal: SupportedTerminal | undefined): stri /** * Spawn a new PTY process with appropriate shell and environment + * Returns both the PTY process and the detected shell type for command generation */ export function spawnPtyProcess( cwd: string, cols: number, rows: number, profileEnv?: Record -): pty.IPty { +): SpawnResult { // Read user's preferred terminal setting const settings = readSettingsFile(); const preferredTerminal = settings?.preferredTerminal as SupportedTerminal | undefined; @@ -83,9 +154,10 @@ export function spawnPtyProcess( ? getWindowsShell(preferredTerminal) : process.env.SHELL || '/bin/zsh'; + const shellType = detectShellType(shell); const shellArgs = process.platform === 'win32' ? [] : ['-l']; - debugLog('[PtyManager] Spawning shell:', shell, shellArgs, '(preferred:', preferredTerminal || 'system', ')'); + debugLog('[PtyManager] Spawning shell:', shell, shellArgs, '(preferred:', preferredTerminal || 'system', ', type:', shellType, ')'); // Create a clean environment without DEBUG to prevent Claude Code from // enabling debug mode when the Electron app is run in development mode. @@ -95,7 +167,7 @@ export function spawnPtyProcess( // show "Claude API" instead of "Claude Max" when ANTHROPIC_API_KEY is set. const { DEBUG: _DEBUG, ANTHROPIC_API_KEY: _ANTHROPIC_API_KEY, ...cleanEnv } = process.env; - return pty.spawn(shell, shellArgs, { + const ptyProcess = pty.spawn(shell, shellArgs, { name: 'xterm-256color', cols, rows, @@ -107,6 +179,8 @@ export function spawnPtyProcess( COLORTERM: 'truecolor', }, }); + + return { pty: ptyProcess, shellType }; } /** diff --git a/apps/frontend/src/main/terminal/terminal-lifecycle.ts b/apps/frontend/src/main/terminal/terminal-lifecycle.ts index 22d7eaecee..5a1a410770 100644 --- a/apps/frontend/src/main/terminal/terminal-lifecycle.ts +++ b/apps/frontend/src/main/terminal/terminal-lifecycle.ts @@ -66,14 +66,15 @@ export async function createTerminal( effectiveCwd = projectPath || os.homedir(); } - const ptyProcess = PtyManager.spawnPtyProcess( + // Spawn PTY and get shell type for command generation + const { pty: ptyProcess, shellType } = PtyManager.spawnPtyProcess( effectiveCwd || os.homedir(), cols, rows, profileEnv ); - debugLog('[TerminalLifecycle] PTY process spawned, pid:', ptyProcess.pid); + debugLog('[TerminalLifecycle] PTY process spawned, pid:', ptyProcess.pid, 'shellType:', shellType); const terminalCwd = effectiveCwd || os.homedir(); const terminal: TerminalProcess = { @@ -83,7 +84,8 @@ export async function createTerminal( projectPath, cwd: terminalCwd, outputBuffer: '', - title: `Terminal ${terminals.size + 1}` + title: `Terminal ${terminals.size + 1}`, + shellType, }; terminals.set(id, terminal); diff --git a/apps/frontend/src/main/terminal/types.ts b/apps/frontend/src/main/terminal/types.ts index b8ef101230..058a2c697e 100644 --- a/apps/frontend/src/main/terminal/types.ts +++ b/apps/frontend/src/main/terminal/types.ts @@ -2,6 +2,12 @@ import type * as pty from '@lydell/node-pty'; import type { BrowserWindow } from 'electron'; import type { TerminalWorktreeConfig } from '../../shared/types'; +/** + * Shell types for command generation + * Used to generate shell-specific command syntax (PowerShell, cmd, bash, etc.) + */ +export type ShellType = 'bash' | 'zsh' | 'powershell' | 'pwsh' | 'cmd' | 'unknown'; + /** * Terminal process tracking */ @@ -19,6 +25,8 @@ export interface TerminalProcess { worktreeConfig?: TerminalWorktreeConfig; /** Whether this terminal has a pending Claude resume that should be triggered on activation */ pendingClaudeResume?: boolean; + /** The shell type for this terminal (used for shell-specific command generation) */ + shellType?: ShellType; } /** diff --git a/apps/frontend/src/shared/utils/__tests__/shell-escape.test.ts b/apps/frontend/src/shared/utils/__tests__/shell-escape.test.ts new file mode 100644 index 0000000000..6f04925c99 --- /dev/null +++ b/apps/frontend/src/shared/utils/__tests__/shell-escape.test.ts @@ -0,0 +1,331 @@ +/** + * Shell Escape Utilities Tests + * + * Comprehensive tests for shell escaping functions to prevent command injection. + * These tests cover POSIX (bash/zsh), PowerShell, and cmd.exe escaping. + */ + +import { describe, it, expect } from 'vitest'; +import { + escapeShellArg, + escapeShellArgPowerShell, + escapeShellArgWindows, + escapeShellArgForShell, + escapeCommandForShell, + escapeShellPath, + buildCdCommand, + buildCdCommandForShell, + buildPathPrefixForShell, + isPathSafe, +} from '../shell-escape'; + +describe('shell-escape', () => { + describe('escapeShellArg (POSIX/bash)', () => { + it('should wrap simple strings in single quotes', () => { + expect(escapeShellArg('hello')).toBe("'hello'"); + }); + + it('should handle strings with spaces', () => { + expect(escapeShellArg('hello world')).toBe("'hello world'"); + }); + + it('should escape single quotes correctly', () => { + // Single quotes are escaped as: '\'' + expect(escapeShellArg("it's")).toBe("'it'\\''s'"); + expect(escapeShellArg("don't")).toBe("'don'\\''t'"); + }); + + it('should handle multiple single quotes', () => { + expect(escapeShellArg("it's a 'test'")).toBe("'it'\\''s a '\\''test'\\'''"); + }); + + it('should safely wrap command injection attempts', () => { + // These should all be safely wrapped in single quotes + expect(escapeShellArg('$(rm -rf /)')).toBe("'$(rm -rf /)'"); + expect(escapeShellArg('`rm -rf /`')).toBe("'`rm -rf /`'"); + expect(escapeShellArg('; rm -rf /')).toBe("'; rm -rf /'"); + expect(escapeShellArg('&& rm -rf /')).toBe("'&& rm -rf /'"); + expect(escapeShellArg('| rm -rf /')).toBe("'| rm -rf /'"); + }); + + it('should handle dollar signs and variables', () => { + expect(escapeShellArg('$HOME')).toBe("'$HOME'"); + expect(escapeShellArg('${PATH}')).toBe("'${PATH}'"); + }); + + it('should handle special characters', () => { + expect(escapeShellArg('path/to/file')).toBe("'path/to/file'"); + expect(escapeShellArg('C:\\Windows\\System32')).toBe("'C:\\Windows\\System32'"); + }); + + it('should handle empty string', () => { + expect(escapeShellArg('')).toBe("''"); + }); + + it('should handle newlines and carriage returns', () => { + expect(escapeShellArg("line1\nline2")).toBe("'line1\nline2'"); + expect(escapeShellArg("line1\r\nline2")).toBe("'line1\r\nline2'"); + }); + }); + + describe('escapeShellArgPowerShell', () => { + it('should wrap simple strings in single quotes', () => { + expect(escapeShellArgPowerShell('hello')).toBe("'hello'"); + }); + + it('should escape single quotes by doubling them', () => { + // PowerShell uses '' to escape a single quote within single quotes + expect(escapeShellArgPowerShell("it's")).toBe("'it''s'"); + expect(escapeShellArgPowerShell("don't")).toBe("'don''t'"); + }); + + it('should handle multiple single quotes', () => { + expect(escapeShellArgPowerShell("it's a 'test'")).toBe("'it''s a ''test'''"); + }); + + it('should handle Windows paths', () => { + expect(escapeShellArgPowerShell('C:\\Program Files\\App')).toBe("'C:\\Program Files\\App'"); + }); + + it('should safely handle command injection attempts', () => { + expect(escapeShellArgPowerShell('$(rm -rf /)')).toBe("'$(rm -rf /)'"); + expect(escapeShellArgPowerShell('; Remove-Item -Recurse')).toBe("'; Remove-Item -Recurse'"); + }); + + it('should handle dollar signs (no expansion in single quotes)', () => { + expect(escapeShellArgPowerShell('$env:PATH')).toBe("'$env:PATH'"); + }); + }); + + describe('escapeShellArgWindows (cmd.exe)', () => { + it('should escape percent signs', () => { + expect(escapeShellArgWindows('%PATH%')).toBe('%%PATH%%'); + expect(escapeShellArgWindows('100%')).toBe('100%%'); + }); + + it('should not modify strings without special characters', () => { + expect(escapeShellArgWindows('hello')).toBe('hello'); + expect(escapeShellArgWindows('C:\\Windows\\System32')).toBe('C:\\Windows\\System32'); + }); + + it('should throw error for strings with double quotes', () => { + expect(() => escapeShellArgWindows('path"with"quotes')).toThrow( + 'Path contains double quote character which cannot be safely escaped for cmd.exe' + ); + }); + + it('should not modify special chars that are literals inside double quotes', () => { + // Inside double quotes, &|<>^ are treated as literals + expect(escapeShellArgWindows('path&with&ersands')).toBe('path&with&ersands'); + expect(escapeShellArgWindows('path|with|pipes')).toBe('path|with|pipes'); + expect(escapeShellArgWindows('pathangles')).toBe('pathangles'); + }); + }); + + describe('escapeShellArgForShell', () => { + const testPath = "it's a test"; + + it('should use POSIX escaping for bash', () => { + expect(escapeShellArgForShell(testPath, 'bash')).toBe("'it'\\''s a test'"); + }); + + it('should use POSIX escaping for zsh', () => { + expect(escapeShellArgForShell(testPath, 'zsh')).toBe("'it'\\''s a test'"); + }); + + it('should use PowerShell escaping for powershell', () => { + expect(escapeShellArgForShell(testPath, 'powershell')).toBe("'it''s a test'"); + }); + + it('should use PowerShell escaping for pwsh', () => { + expect(escapeShellArgForShell(testPath, 'pwsh')).toBe("'it''s a test'"); + }); + + it('should use Windows escaping wrapped in double quotes for cmd', () => { + expect(escapeShellArgForShell('test%path%', 'cmd')).toBe('"test%%path%%"'); + }); + + it('should default to POSIX escaping for unknown shell', () => { + expect(escapeShellArgForShell(testPath, 'unknown')).toBe("'it'\\''s a test'"); + }); + }); + + describe('escapeCommandForShell', () => { + const claudePath = '/usr/bin/claude'; + const windowsClaudePath = 'C:\\Program Files\\Claude\\claude.exe'; + + it('should use POSIX escaping for bash', () => { + expect(escapeCommandForShell(claudePath, 'bash')).toBe("'/usr/bin/claude'"); + }); + + it('should use PowerShell call operator (&) for powershell', () => { + // PowerShell needs & to execute a quoted command + expect(escapeCommandForShell(windowsClaudePath, 'powershell')).toBe( + "& 'C:\\Program Files\\Claude\\claude.exe'" + ); + }); + + it('should use PowerShell call operator (&) for pwsh', () => { + expect(escapeCommandForShell(windowsClaudePath, 'pwsh')).toBe( + "& 'C:\\Program Files\\Claude\\claude.exe'" + ); + }); + + it('should wrap in double quotes for cmd', () => { + expect(escapeCommandForShell(windowsClaudePath, 'cmd')).toBe( + '"C:\\Program Files\\Claude\\claude.exe"' + ); + }); + }); + + describe('escapeShellPath', () => { + it('should escape paths using POSIX escaping', () => { + expect(escapeShellPath('/path/to/file')).toBe("'/path/to/file'"); + expect(escapeShellPath("path with spaces")).toBe("'path with spaces'"); + }); + }); + + describe('buildCdCommand (deprecated)', () => { + it('should build cd command for Unix paths', () => { + // This test may vary based on process.platform + const result = buildCdCommand('/path/to/dir'); + expect(result).toContain('cd'); + expect(result).toContain('/path/to/dir'); + }); + + it('should return empty string for undefined path', () => { + expect(buildCdCommand(undefined)).toBe(''); + }); + }); + + describe('buildCdCommandForShell', () => { + it('should return empty string for undefined path', () => { + expect(buildCdCommandForShell(undefined, 'bash')).toBe(''); + }); + + it('should use cd with single quotes for bash', () => { + expect(buildCdCommandForShell('/path/to/dir', 'bash')).toBe("cd '/path/to/dir' && "); + }); + + it('should use cd with single quotes for zsh', () => { + expect(buildCdCommandForShell('/path/to/dir', 'zsh')).toBe("cd '/path/to/dir' && "); + }); + + it('should use Set-Location with semicolon for powershell', () => { + expect(buildCdCommandForShell('C:\\Users\\Test', 'powershell')).toBe( + "Set-Location 'C:\\Users\\Test'; " + ); + }); + + it('should use Set-Location with semicolon for pwsh', () => { + expect(buildCdCommandForShell('C:\\Users\\Test', 'pwsh')).toBe( + "Set-Location 'C:\\Users\\Test'; " + ); + }); + + it('should use cd /d with double quotes for cmd', () => { + expect(buildCdCommandForShell('C:\\Users\\Test', 'cmd')).toBe( + 'cd /d "C:\\Users\\Test" && ' + ); + }); + + it('should escape percent signs for cmd', () => { + expect(buildCdCommandForShell('C:\\100%Complete', 'cmd')).toBe( + 'cd /d "C:\\100%%Complete" && ' + ); + }); + + it('should escape single quotes for bash', () => { + expect(buildCdCommandForShell("/path/with'quote", 'bash')).toBe( + "cd '/path/with'\\''quote' && " + ); + }); + }); + + describe('buildPathPrefixForShell', () => { + it('should return empty string for undefined path', () => { + expect(buildPathPrefixForShell(undefined, 'bash')).toBe(''); + }); + + it('should use PATH= with single quotes for bash', () => { + expect(buildPathPrefixForShell('/usr/bin:/bin', 'bash')).toBe("PATH='/usr/bin:/bin' "); + }); + + it('should convert Windows semicolons to Unix colons for bash', () => { + expect(buildPathPrefixForShell('C:\\bin;D:\\tools', 'bash')).toBe("PATH='C:\\bin:D:\\tools' "); + }); + + it('should use $env:PATH= for powershell', () => { + expect(buildPathPrefixForShell('C:\\bin;D:\\tools', 'powershell')).toBe( + "$env:PATH='C:\\bin;D:\\tools'; " + ); + }); + + it('should preserve Windows semicolons for powershell', () => { + // PowerShell uses semicolons as PATH separator on Windows + expect(buildPathPrefixForShell('C:\\bin;D:\\tools', 'pwsh')).toBe( + "$env:PATH='C:\\bin;D:\\tools'; " + ); + }); + + it('should use set "PATH=" for cmd', () => { + expect(buildPathPrefixForShell('C:\\bin;D:\\tools', 'cmd')).toBe( + 'set "PATH=C:\\bin;D:\\tools" && ' + ); + }); + + it('should escape percent signs for cmd', () => { + expect(buildPathPrefixForShell('C:\\100%', 'cmd')).toBe('set "PATH=C:\\100%%" && '); + }); + }); + + describe('isPathSafe', () => { + it('should return true for safe paths', () => { + expect(isPathSafe('/usr/bin/claude')).toBe(true); + expect(isPathSafe('C:\\Program Files\\App')).toBe(true); + expect(isPathSafe('/path/to/file.txt')).toBe(true); + }); + + it('should return false for command substitution attempts', () => { + expect(isPathSafe('$(rm -rf /)')).toBe(false); + expect(isPathSafe('`rm -rf /`')).toBe(false); + }); + + it('should return false for pipe operators', () => { + expect(isPathSafe('/path | rm')).toBe(false); + }); + + it('should return false for command separators', () => { + expect(isPathSafe('/path; rm -rf /')).toBe(false); + expect(isPathSafe('/path && rm -rf /')).toBe(false); + expect(isPathSafe('/path || rm -rf /')).toBe(false); + }); + + it('should return false for redirection operators', () => { + expect(isPathSafe('/path > /etc/passwd')).toBe(false); + expect(isPathSafe('/path < /etc/passwd')).toBe(false); + }); + + it('should return false for newlines', () => { + expect(isPathSafe('/path\nrm -rf /')).toBe(false); + expect(isPathSafe('/path\r\nrm -rf /')).toBe(false); + }); + }); + + describe('Edge cases', () => { + it('should handle Unicode characters', () => { + expect(escapeShellArg('/path/日本語/file')).toBe("'/path/日本語/file'"); + expect(escapeShellArgPowerShell('/path/日本語/file')).toBe("'/path/日本語/file'"); + }); + + it('should handle very long paths', () => { + const longPath = '/path/' + 'a'.repeat(1000); + expect(escapeShellArg(longPath)).toBe(`'${longPath}'`); + }); + + it('should handle paths with all kinds of special characters', () => { + const weirdPath = '/path/with spaces/and-dashes/and_underscores/and.dots'; + expect(escapeShellArg(weirdPath)).toBe(`'${weirdPath}'`); + }); + }); +}); diff --git a/apps/frontend/src/shared/utils/shell-escape.ts b/apps/frontend/src/shared/utils/shell-escape.ts index 8bdd3b0dc6..381e9d1297 100644 --- a/apps/frontend/src/shared/utils/shell-escape.ts +++ b/apps/frontend/src/shared/utils/shell-escape.ts @@ -5,8 +5,10 @@ * IMPORTANT: Always use these utilities when interpolating user-controlled values into shell commands. */ +import type { ShellType } from '../../main/terminal/types'; + /** - * Escape a string for safe use as a shell argument. + * Escape a string for safe use as a shell argument (POSIX/bash). * * Uses single quotes which prevent all shell expansion (variables, command substitution, etc.) * except for single quotes themselves, which are escaped as '\'' @@ -28,6 +30,99 @@ export function escapeShellArg(arg: string): string { return `'${escaped}'`; } +/** + * Escape a string for safe use in PowerShell. + * + * PowerShell uses single quotes for literal strings (no variable expansion). + * Single quotes inside are escaped by doubling them. + * + * Examples: + * - "hello" → 'hello' + * - "it's" → 'it''s' + * - "C:\Program Files" → 'C:\Program Files' + * + * @param arg - The argument to escape + * @returns The escaped argument wrapped in single quotes + */ +export function escapeShellArgPowerShell(arg: string): string { + // In PowerShell, single quotes are literal strings + // Single quotes inside are escaped by doubling them + const escaped = arg.replace(/'/g, "''"); + return `'${escaped}'`; +} + +/** + * Escape a string for use inside a double-quoted cmd.exe argument. + * + * Inside double quotes in cmd.exe: + * - Special chars &|<>^ are treated as literals (no escaping needed) + * - % must be escaped as %% to prevent variable expansion + * - " cannot be reliably escaped inside double quotes in cmd.exe + * + * @param arg - The argument to escape + * @returns The escaped argument safe for use inside double quotes in cmd.exe + * @throws Error if the argument contains double quotes (cannot be safely escaped in cmd.exe) + */ +export function escapeShellArgWindows(arg: string): string { + // Double quotes cannot be reliably escaped inside double-quoted strings in cmd.exe. + // Reject arguments containing double quotes to prevent command injection. + // This is extremely rare in practice (paths almost never contain double quotes), + // but we must be defensive. + if (arg.includes('"')) { + throw new Error('Path contains double quote character which cannot be safely escaped for cmd.exe'); + } + + // Only % needs escaping inside double quotes - it prevents variable expansion + // Characters like &|<>^ are treated as literals inside double quotes + return arg.replace(/%/g, '%%'); +} + +/** + * Escape a shell argument based on shell type. + * + * @param arg - The argument to escape + * @param shellType - The target shell type + * @returns The escaped argument appropriate for the shell + */ +export function escapeShellArgForShell(arg: string, shellType: ShellType): string { + switch (shellType) { + case 'powershell': + case 'pwsh': + return escapeShellArgPowerShell(arg); + case 'cmd': + return `"${escapeShellArgWindows(arg)}"`; + case 'bash': + case 'zsh': + default: + return escapeShellArg(arg); + } +} + +/** + * Escape a command for execution based on shell type. + * + * This differs from escapeShellArgForShell in that PowerShell requires + * the & (call) operator to execute a quoted command path. + * + * @param cmd - The command/executable path to escape + * @param shellType - The target shell type + * @returns The escaped command ready for execution + */ +export function escapeCommandForShell(cmd: string, shellType: ShellType): string { + switch (shellType) { + case 'powershell': + case 'pwsh': + // PowerShell requires & (call) operator to execute a quoted string + return `& ${escapeShellArgPowerShell(cmd)}`; + case 'cmd': + return `"${escapeShellArgWindows(cmd)}"`; + case 'bash': + case 'zsh': + default: + return escapeShellArg(cmd); + } +} + /** * Escape a path for use in a cd command. * @@ -44,6 +139,7 @@ export function escapeShellPath(path: string): string { * * @param path - The directory path * @returns A safe "cd '' && " string, or empty string if path is undefined + * @deprecated Use buildCdCommandForShell for shell-aware command building */ export function buildCdCommand(path: string | undefined): string { if (!path) { @@ -62,30 +158,66 @@ export function buildCdCommand(path: string | undefined): string { } /** - * Escape a string for safe use as a Windows cmd.exe argument. + * Build a shell-aware cd command. * - * Windows cmd.exe uses different escaping rules than POSIX shells. - * This function escapes special characters that could break out of strings - * or execute additional commands. + * @param path - The directory path + * @param shellType - The target shell type + * @returns A shell-appropriate cd command string, or empty string if path is undefined + */ +export function buildCdCommandForShell(path: string | undefined, shellType: ShellType): string { + if (!path) { + return ''; + } + + switch (shellType) { + case 'powershell': + case 'pwsh': + // PowerShell: Set-Location with single-quoted path, semicolon separator + return `Set-Location ${escapeShellArgPowerShell(path)}; `; + + case 'cmd': + // cmd.exe: cd /d with double-quoted path, && separator + return `cd /d "${escapeShellArgWindows(path)}" && `; + + case 'bash': + case 'zsh': + default: + // Bash/Zsh: cd with single-quoted path, && separator + return `cd ${escapeShellArg(path)} && `; + } +} + +/** + * Build a shell-aware PATH prefix for command execution. * - * @param arg - The argument to escape - * @returns The escaped argument safe for use in cmd.exe + * @param envPath - The PATH value to set + * @param shellType - The target shell type + * @returns A shell-appropriate PATH assignment string, or empty string if envPath is undefined */ -export function escapeShellArgWindows(arg: string): string { - // Escape characters that have special meaning in cmd.exe: - // ^ is the escape character in cmd.exe - // " & | < > ^ need to be escaped - // % is used for variable expansion - const escaped = arg - .replace(/\^/g, '^^') // Escape carets first (escape char itself) - .replace(/"/g, '^"') // Escape double quotes - .replace(/&/g, '^&') // Escape ampersand (command separator) - .replace(/\|/g, '^|') // Escape pipe - .replace(//g, '^>') // Escape greater than - .replace(/%/g, '%%'); // Escape percent (variable expansion) - - return escaped; +export function buildPathPrefixForShell(envPath: string | undefined, shellType: ShellType): string { + if (!envPath) { + return ''; + } + + switch (shellType) { + case 'powershell': + case 'pwsh': + // PowerShell: $env:PATH = 'value'; (keep Windows semicolons in path) + return `$env:PATH=${escapeShellArgPowerShell(envPath)}; `; + + case 'cmd': + // cmd.exe: set "PATH=value" && (keep Windows semicolons in path) + // Escape % to prevent variable expansion + return `set "PATH=${escapeShellArgWindows(envPath)}" && `; + + case 'bash': + case 'zsh': + default: { + // Bash/Zsh: PATH='value' (convert Windows semicolons to Unix colons) + const unixPath = envPath.replace(/;/g, ':'); + return `PATH=${escapeShellArg(unixPath)} `; + } + } } /** diff --git a/package-lock.json b/package-lock.json index 842c9c6a46..d188e60eb4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -360,7 +360,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -745,7 +744,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -789,7 +787,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -829,7 +826,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -1695,6 +1691,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1716,6 +1713,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1732,6 +1730,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1746,6 +1745,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -2807,7 +2807,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2829,7 +2828,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.2.0.tgz", "integrity": "sha512-qRkLWiUEZNAmYapZ7KGS5C4OmBLcP/H2foXeOEaowYCR0wi89fHejrfYfbuLVCMLp/dWZXKvQusdbUEZjERfwQ==", "license": "Apache-2.0", - "peer": true, "engines": { "node": "^18.19.0 || >=20.6.0" }, @@ -2842,7 +2840,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2858,7 +2855,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.208.0.tgz", "integrity": "sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.208.0", "import-in-the-middle": "^2.0.0", @@ -3246,7 +3242,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -3263,7 +3258,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", @@ -3281,7 +3275,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.38.0.tgz", "integrity": "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=14" } @@ -5519,7 +5512,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -5750,7 +5744,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -5761,7 +5754,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5869,7 +5861,6 @@ "integrity": "sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.51.0", "@typescript-eslint/types": "8.51.0", @@ -6284,7 +6275,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6354,7 +6344,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -6975,7 +6964,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -7789,7 +7777,8 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-env": { "version": "10.1.0", @@ -8175,7 +8164,6 @@ "integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.0.12", "builder-util": "26.0.11", @@ -8271,7 +8259,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dotenv": { "version": "16.6.1", @@ -8346,7 +8335,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^22.7.7", @@ -8595,6 +8583,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -8615,6 +8604,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -8989,7 +8979,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -10215,7 +10204,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -11018,7 +11006,6 @@ "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.28", "@asamuzakjp/dom-selector": "^6.7.6", @@ -11755,6 +11742,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -13824,7 +13812,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -13900,6 +13887,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -13917,6 +13905,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -13937,6 +13926,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -13952,6 +13942,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -14079,7 +14070,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -14089,7 +14079,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -14129,7 +14118,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-markdown": { "version": "10.1.0", @@ -14564,6 +14554,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -15454,8 +15445,7 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -15565,6 +15555,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -15628,6 +15619,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -15719,7 +15711,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -15982,7 +15973,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16332,7 +16322,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -16925,7 +16914,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17466,7 +17454,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.2.tgz", "integrity": "sha512-b8L8yn4rIVfiXyHAmnr52/ZEpDumlT0bmxiq3Ws1ybrinhflGpt12Hvv54kYnEsGPRs6o/Ka3/ppA2OWY21IVg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" }