Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ describe('claude-integration-handler', () => {
expect(profileManager.markProfileUsed).toHaveBeenCalledWith('default');
});

it('converts Windows PATH separators to colons for bash invocations', async () => {
it('skips PATH prefix on Windows (PTY has correct PATH)', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', { value: 'win32' });

Expand All @@ -134,8 +134,10 @@ 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');
// On Windows, PATH prefix is skipped - the PTY already has the correct PATH
expect(written).not.toContain('PATH=');
// Should use Windows cmd.exe syntax with double quotes
expect(written).toContain('"C:\\Tools\\claude\\claude.exe"');
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform);
Expand Down Expand Up @@ -474,7 +476,7 @@ describe('claude-integration-handler - Helper Functions', () => {
"cd '/tmp/project' && ",
"PATH='/opt/bin' ",
"'/opt/bin/claude'",
{ method: 'config-dir', escapedConfigDir: "'/home/user/.claude-work'" }
{ method: 'config-dir', escapedConfigDir: "'/home/user/.claude-work'", rawConfigDir: '/home/user/.claude-work' }
);

expect(result).toContain('clear && ');
Expand Down
104 changes: 77 additions & 27 deletions apps/frontend/src/main/terminal/claude-integration-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ 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, escapeShellArgWindows, escapeForWindowsSet, buildCdCommand } from '../../shared/utils/shell-escape';
import { getClaudeCliInvocation, getClaudeCliInvocationAsync } from '../claude-cli-utils';
import type {
TerminalProcess,
Expand All @@ -32,6 +32,23 @@ function normalizePathForBash(envPath: string): string {
*/
const YOLO_MODE_FLAG = ' --dangerously-skip-permissions';

/**
* Check if we're running on Windows
*/
const isWindows = process.platform === 'win32';

/**
* Escape a shell argument for the current platform.
* Uses single quotes on Unix/Mac, double quotes on Windows cmd.exe.
*/
function escapeForPlatform(arg: string): string {
if (isWindows) {
// On Windows cmd.exe, wrap in double quotes after escaping special chars
return `"${escapeShellArgWindows(arg)}"`;
}
return escapeShellArg(arg);
}

// ============================================================================
// SHARED HELPERS - Used by both sync and async invokeClaude
// ============================================================================
Expand All @@ -43,7 +60,7 @@ const YOLO_MODE_FLAG = ' --dangerously-skip-permissions';
type ClaudeCommandConfig =
| { method: 'default' }
| { method: 'temp-file'; escapedTempFile: string }
| { method: 'config-dir'; escapedConfigDir: string };
| { method: 'config-dir'; escapedConfigDir: string; rawConfigDir: string };

/**
* Build the shell command for invoking Claude CLI.
Expand All @@ -56,8 +73,10 @@ type ClaudeCommandConfig =
* All non-default methods include history-safe prefixes (HISTFILE=, HISTCONTROL=)
* to prevent sensitive data from appearing in shell history.
*
* On Windows, uses cmd.exe-compatible syntax. On Unix/Mac, uses bash syntax.
*
* @param cwdCommand - Command to change directory (empty string if no change needed)
* @param pathPrefix - PATH prefix for Claude CLI (empty string if not needed)
* @param pathPrefix - PATH prefix for Claude CLI (empty string if not needed, ignored on Windows)
* @param escapedClaudeCmd - Shell-escaped Claude CLI command
* @param config - Configuration object with method and required options (discriminated union)
* @param extraFlags - Optional extra flags to append to the command (e.g., '--dangerously-skip-permissions')
Expand All @@ -80,6 +99,27 @@ export function buildClaudeShellCommand(
extraFlags?: string
): string {
const fullCmd = extraFlags ? `${escapedClaudeCmd}${extraFlags}` : escapedClaudeCmd;

// Windows uses cmd.exe which has different syntax than bash
if (isWindows) {
switch (config.method) {
case 'temp-file':
// On Windows, use 'call' to execute the batch file that sets env var, then run claude
// Use del /Q for quiet (no confirmation prompt) deletion
return `cls && ${cwdCommand}call ${config.escapedTempFile} && del /Q ${config.escapedTempFile} && ${fullCmd}\r`;

case 'config-dir':
// On Windows, use 'set "VAR=value"' syntax to handle paths with spaces correctly
// The quotes protect special characters, so we use escapeForWindowsSet (not escapeShellArgWindows)
return `cls && ${cwdCommand}set "CLAUDE_CONFIG_DIR=${escapeForWindowsSet(config.rawConfigDir)}" && ${fullCmd}\r`;

default:
// On Windows, don't use PATH= prefix syntax - the PTY already has correct PATH
return `${cwdCommand}${fullCmd}\r`;
}
}

// Unix/Mac bash syntax
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`;
Expand Down Expand Up @@ -419,7 +459,7 @@ export function invokeClaude(

const cwdCommand = buildCdCommand(cwd);
const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation();
const escapedClaudeCmd = escapeShellArg(claudeCmd);
const escapedClaudeCmd = escapeForPlatform(claudeCmd);
const pathPrefix = claudeEnv.PATH
? `PATH=${escapeShellArg(normalizePathForBash(claudeEnv.PATH))} `
: '';
Expand All @@ -440,14 +480,18 @@ 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);
// Use .bat extension on Windows, no extension on Unix (sourced as shell script)
const tempFileExt = isWindows ? '.bat' : '';
const tempFile = path.join(os.tmpdir(), `.claude-token-${Date.now()}-${nonce}${tempFileExt}`);
const escapedTempFile = escapeForPlatform(tempFile);
debugLog('[ClaudeIntegration:invokeClaude] Writing token to temp file:', tempFile);
fs.writeFileSync(
tempFile,
`export CLAUDE_CODE_OAUTH_TOKEN=${escapeShellArg(token)}\n`,
{ mode: 0o600 }
);

// Write platform-appropriate content
const tempFileContent = isWindows
? `@echo off\r\nset "CLAUDE_CODE_OAUTH_TOKEN=${escapeForWindowsSet(token)}"\r\n` // Windows batch file
: `export CLAUDE_CODE_OAUTH_TOKEN=${escapeShellArg(token)}\n`; // Unix shell script

fs.writeFileSync(tempFile, tempFileContent, { mode: 0o600 });

const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'temp-file', escapedTempFile }, extraFlags);
debugLog('[ClaudeIntegration:invokeClaude] Executing command (temp file method, history-safe)');
Expand All @@ -457,8 +501,8 @@ export function invokeClaude(
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);
const escapedConfigDir = escapeForPlatform(activeProfile.configDir);
const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'config-dir', escapedConfigDir, rawConfigDir: activeProfile.configDir }, extraFlags);
debugLog('[ClaudeIntegration:invokeClaude] Executing command (configDir method, history-safe)');
terminal.pty.write(command);
profileManager.markProfileUsed(activeProfile.id);
Expand Down Expand Up @@ -506,8 +550,9 @@ export function resumeClaude(
SessionHandler.releaseSessionId(terminal.id);

const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation();
const escapedClaudeCmd = escapeShellArg(claudeCmd);
const pathPrefix = claudeEnv.PATH
const escapedClaudeCmd = escapeForPlatform(claudeCmd);
// On Windows, the PTY already has the correct PATH - don't use bash-style PATH= prefix
const pathPrefix = !isWindows && claudeEnv.PATH
? `PATH=${escapeShellArg(normalizePathForBash(claudeEnv.PATH))} `
: '';

Expand Down Expand Up @@ -597,7 +642,7 @@ export async function invokeClaudeAsync(
// Async CLI invocation - non-blocking
const cwdCommand = buildCdCommand(cwd);
const { command: claudeCmd, env: claudeEnv } = await getClaudeCliInvocationAsync();
const escapedClaudeCmd = escapeShellArg(claudeCmd);
const escapedClaudeCmd = escapeForPlatform(claudeCmd);
const pathPrefix = claudeEnv.PATH
? `PATH=${escapeShellArg(normalizePathForBash(claudeEnv.PATH))} `
: '';
Expand All @@ -618,14 +663,18 @@ 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);
// Use .bat extension on Windows, no extension on Unix (sourced as shell script)
const tempFileExt = isWindows ? '.bat' : '';
const tempFile = path.join(os.tmpdir(), `.claude-token-${Date.now()}-${nonce}${tempFileExt}`);
const escapedTempFile = escapeForPlatform(tempFile);
debugLog('[ClaudeIntegration:invokeClaudeAsync] Writing token to temp file:', tempFile);
await fsPromises.writeFile(
tempFile,
`export CLAUDE_CODE_OAUTH_TOKEN=${escapeShellArg(token)}\n`,
{ mode: 0o600 }
);

// Write platform-appropriate content
const tempFileContent = isWindows
? `@echo off\r\nset "CLAUDE_CODE_OAUTH_TOKEN=${escapeForWindowsSet(token)}"\r\n` // Windows batch file
: `export CLAUDE_CODE_OAUTH_TOKEN=${escapeShellArg(token)}\n`; // Unix shell script

await fsPromises.writeFile(tempFile, tempFileContent, { mode: 0o600 });

const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'temp-file', escapedTempFile }, extraFlags);
debugLog('[ClaudeIntegration:invokeClaudeAsync] Executing command (temp file method, history-safe)');
Expand All @@ -635,8 +684,8 @@ export async function invokeClaudeAsync(
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);
const escapedConfigDir = escapeForPlatform(activeProfile.configDir);
const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'config-dir', escapedConfigDir, rawConfigDir: activeProfile.configDir }, extraFlags);
debugLog('[ClaudeIntegration:invokeClaudeAsync] Executing command (configDir method, history-safe)');
terminal.pty.write(command);
profileManager.markProfileUsed(activeProfile.id);
Expand Down Expand Up @@ -680,8 +729,9 @@ export async function resumeClaudeAsync(

// Async CLI invocation - non-blocking
const { command: claudeCmd, env: claudeEnv } = await getClaudeCliInvocationAsync();
const escapedClaudeCmd = escapeShellArg(claudeCmd);
const pathPrefix = claudeEnv.PATH
const escapedClaudeCmd = escapeForPlatform(claudeCmd);
// On Windows, the PTY already has the correct PATH - don't use bash-style PATH= prefix
const pathPrefix = !isWindows && claudeEnv.PATH
? `PATH=${escapeShellArg(normalizePathForBash(claudeEnv.PATH))} `
: '';

Expand Down
19 changes: 19 additions & 0 deletions apps/frontend/src/shared/utils/shell-escape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,25 @@ export function escapeShellArgWindows(arg: string): string {
return escaped;
}

/**
* Escape a value for use inside Windows `set "VAR=VALUE"` command.
*
* IMPORTANT: This is different from escapeShellArgWindows!
* Inside `set "VAR=VALUE"`, the double quotes already protect special characters
* like &, |, <, >, so we DON'T add caret escapes (they would become literal).
*
* Only % needs escaping (as %%) because variable expansion still occurs inside quotes.
* Double quotes cannot appear in the value (would break the set syntax).
*
* @param value - The value to escape
* @returns The escaped value safe for use in set "VAR=VALUE"
*/
export function escapeForWindowsSet(value: string): string {
return value
.replace(/%/g, '%%') // Escape percent (variable expansion still works in quotes)
.replace(/"/g, ''); // Remove double quotes (would break set "..." syntax)
}

/**
* Validate that a path doesn't contain obviously malicious patterns.
* This is a defense-in-depth measure - escaping should handle all cases,
Expand Down
Loading