Skip to content
Merged
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
11 changes: 7 additions & 4 deletions electron/gateway/clawhub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import { app, shell } from 'electron';
import { getOpenClawConfigDir, ensureDir, getClawHubCliBinPath, getClawHubCliEntryPath } from '../utils/paths';
import { getOpenClawConfigDir, ensureDir, getClawHubCliBinPath, getClawHubCliEntryPath, quoteForCmd } from '../utils/paths';

export interface ClawHubSearchParams {
query: string;
Expand Down Expand Up @@ -87,17 +87,20 @@ export class ClawHubService {
console.log(`Running ClawHub command: ${displayCommand}`);

const isWin = process.platform === 'win32';
const useShell = isWin && !this.useNodeRunner;
const env = {
...process.env,
CI: 'true',
FORCE_COLOR: '0', // Disable colors for easier parsing
FORCE_COLOR: '0',
};
if (this.useNodeRunner) {
env.ELECTRON_RUN_AS_NODE = '1';
}
const child = spawn(this.cliPath, commandArgs, {
const spawnCmd = useShell ? quoteForCmd(this.cliPath) : this.cliPath;
const spawnArgs = useShell ? commandArgs.map(a => quoteForCmd(a)) : commandArgs;
const child = spawn(spawnCmd, spawnArgs, {
cwd: this.workDir,
shell: isWin && !this.useNodeRunner,
shell: useShell,
env: {
...env,
CLAWHUB_WORKDIR: this.workDir,
Expand Down
11 changes: 8 additions & 3 deletions electron/gateway/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import {
getOpenClawDir,
getOpenClawEntryPath,
isOpenClawBuilt,
isOpenClawPresent
isOpenClawPresent,
quoteForCmd,
} from '../utils/paths';
import { getSetting } from '../utils/store';
import { getApiKey, getDefaultProvider, getProvider } from '../utils/secure-storage';
Expand Down Expand Up @@ -755,11 +756,15 @@ export class GatewayManager extends EventEmitter {
}
}

this.process = spawn(command, args, {
const useShell = !app.isPackaged && process.platform === 'win32';
const spawnCmd = useShell ? quoteForCmd(command) : command;
const spawnArgs = useShell ? args.map(a => quoteForCmd(a)) : args;

this.process = spawn(spawnCmd, spawnArgs, {
cwd: openclawDir,
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
shell: !app.isPackaged && process.platform === 'win32', // shell only in dev on Windows
shell: useShell,
env: spawnEnv,
});
const child = this.process;
Expand Down
2 changes: 2 additions & 0 deletions electron/utils/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { homedir } from 'os';
import { existsSync, mkdirSync, readFileSync, realpathSync } from 'fs';
import { logger } from './logger';

export { quoteForCmd, needsWinShell, prepareWinSpawn } from './win-shell';

/**
* Expand ~ to home directory
*/
Expand Down
16 changes: 10 additions & 6 deletions electron/utils/uv-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { existsSync } from 'fs';
import { join } from 'path';
import { getUvMirrorEnv } from './uv-env';
import { logger } from './logger';
import { quoteForCmd, needsWinShell } from './paths';

/**
* Get the path to the bundled uv binary
Expand Down Expand Up @@ -88,11 +89,12 @@ export async function installUv(): Promise<void> {
*/
export async function isPythonReady(): Promise<boolean> {
const { bin: uvBin } = resolveUvBin();
const useShell = needsWinShell(uvBin);

return new Promise<boolean>((resolve) => {
try {
const child = spawn(uvBin, ['python', 'find', '3.12'], {
shell: process.platform === 'win32',
const child = spawn(useShell ? quoteForCmd(uvBin) : uvBin, ['python', 'find', '3.12'], {
shell: useShell,
});
child.on('close', (code) => resolve(code === 0));
child.on('error', () => resolve(false));
Expand All @@ -111,12 +113,13 @@ async function runPythonInstall(
env: Record<string, string | undefined>,
label: string,
): Promise<void> {
const useShell = needsWinShell(uvBin);
return new Promise<void>((resolve, reject) => {
const stderrChunks: string[] = [];
const stdoutChunks: string[] = [];

const child = spawn(uvBin, ['python', 'install', '3.12'], {
shell: process.platform === 'win32',
const child = spawn(useShell ? quoteForCmd(uvBin) : uvBin, ['python', 'install', '3.12'], {
shell: useShell,
env,
});

Expand Down Expand Up @@ -201,10 +204,11 @@ export async function setupManagedPython(): Promise<void> {
}

// After installation, verify and log the Python path
const verifyShell = needsWinShell(uvBin);
try {
const findPath = await new Promise<string>((resolve) => {
const child = spawn(uvBin, ['python', 'find', '3.12'], {
shell: process.platform === 'win32',
const child = spawn(verifyShell ? quoteForCmd(uvBin) : uvBin, ['python', 'find', '3.12'], {
shell: verifyShell,
env: { ...process.env, ...uvEnv },
});
let output = '';
Expand Down
65 changes: 65 additions & 0 deletions electron/utils/win-shell.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Windows shell quoting utilities for child_process.spawn().
*
* When spawn() is called with `shell: true` on Windows, the command and
* arguments are concatenated and passed to cmd.exe. Paths containing spaces
* must be wrapped in double-quotes to prevent cmd.exe from splitting them
* into separate tokens.
*
* This module is intentionally dependency-free so it can be unit-tested
* without mocking Electron.
*/
import path from 'path';

/**
* Quote a path/value for safe use with Windows cmd.exe (shell: true in spawn).
*
* When Node.js spawn is called with `shell: true` on Windows, cmd.exe
* interprets spaces as argument separators. Wrapping the value in double
* quotes prevents this. On non-Windows platforms the value is returned
* unchanged so this function can be called unconditionally.
*/
export function quoteForCmd(value: string): string {
if (process.platform !== 'win32') return value;
if (!value.includes(' ')) return value;
if (value.startsWith('"') && value.endsWith('"')) return value;
return `"${value}"`;
}

/**
* Determine whether a spawn call needs `shell: true` on Windows.
*
* Full (absolute) paths can be executed directly by the OS via
* CreateProcessW, which handles spaces correctly without a shell.
* Simple command names (e.g. 'uv', 'node') need shell for PATH/PATHEXT
* resolution on Windows.
*/
export function needsWinShell(bin: string): boolean {
if (process.platform !== 'win32') return false;
return !path.win32.isAbsolute(bin);
}

/**
* Prepare command and args for spawn(), handling Windows paths with spaces.
*
* Returns the shell option, the (possibly quoted) command, and the
* (possibly quoted) args array ready for child_process.spawn().
*/
export function prepareWinSpawn(
command: string,
args: string[],
forceShell?: boolean,
): { shell: boolean; command: string; args: string[] } {
const isWin = process.platform === 'win32';
const useShell = forceShell ?? (isWin && !path.win32.isAbsolute(command));

if (!useShell || !isWin) {
return { shell: useShell, command, args };
}

return {
shell: true,
command: quoteForCmd(command),
args: args.map(a => quoteForCmd(a)),
};
}
5 changes: 2 additions & 3 deletions scripts/download-bundled-uv.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,9 @@ async function setupTarget(id) {
echo`📂 Extracting...`;
if (target.filename.endsWith('.zip')) {
if (os.platform() === 'win32') {
// Use .NET Framework for ZIP extraction (more reliable than Expand-Archive)
const { execSync } = await import('child_process');
const { execFileSync } = await import('child_process');
const psCommand = `Add-Type -AssemblyName System.IO.Compression.FileSystem; [System.IO.Compression.ZipFile]::ExtractToDirectory('${archivePath.replace(/'/g, "''")}', '${tempDir.replace(/'/g, "''")}')`;
execSync(`powershell.exe -NoProfile -Command "${psCommand}"`, { stdio: 'inherit' });
execFileSync('powershell.exe', ['-NoProfile', '-Command', psCommand], { stdio: 'inherit' });
} else {
await $`unzip -q -o ${archivePath} -d ${tempDir}`;
}
Expand Down
162 changes: 162 additions & 0 deletions tests/unit/win-shell.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/**
* Windows shell quoting utilities tests
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';

// We test the pure functions directly by dynamically importing after
// patching process.platform, since the functions check it at call time.
const originalPlatform = process.platform;

function setPlatform(platform: string) {
Object.defineProperty(process, 'platform', { value: platform, writable: true });
}

afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform, writable: true });
});

describe('quoteForCmd', () => {
let quoteForCmd: (value: string) => string;

beforeEach(async () => {
const mod = await import('@electron/utils/win-shell');
quoteForCmd = mod.quoteForCmd;
});

it('returns value unchanged on non-Windows', () => {
setPlatform('linux');
expect(quoteForCmd('C:\\Program Files\\uv.exe')).toBe('C:\\Program Files\\uv.exe');
});

it('returns value unchanged on macOS', () => {
setPlatform('darwin');
expect(quoteForCmd('/Applications/My App/bin')).toBe('/Applications/My App/bin');
});

it('returns value unchanged on Windows when no spaces', () => {
setPlatform('win32');
expect(quoteForCmd('C:\\tools\\uv.exe')).toBe('C:\\tools\\uv.exe');
});

it('wraps in double quotes on Windows when path has spaces', () => {
setPlatform('win32');
expect(quoteForCmd('C:\\Program Files\\uv.exe')).toBe('"C:\\Program Files\\uv.exe"');
});

it('wraps user home paths with spaces', () => {
setPlatform('win32');
expect(quoteForCmd('C:\\Users\\John Doe\\AppData\\Local\\uv.exe'))
.toBe('"C:\\Users\\John Doe\\AppData\\Local\\uv.exe"');
});

it('does not double-quote already quoted values', () => {
setPlatform('win32');
expect(quoteForCmd('"C:\\Program Files\\uv.exe"')).toBe('"C:\\Program Files\\uv.exe"');
});

it('handles simple command names without spaces', () => {
setPlatform('win32');
expect(quoteForCmd('uv')).toBe('uv');
expect(quoteForCmd('node')).toBe('node');
expect(quoteForCmd('pnpm')).toBe('pnpm');
});

it('handles empty string', () => {
setPlatform('win32');
expect(quoteForCmd('')).toBe('');
});
});

describe('needsWinShell', () => {
let needsWinShell: (bin: string) => boolean;

beforeEach(async () => {
const mod = await import('@electron/utils/win-shell');
needsWinShell = mod.needsWinShell;
});

it('returns false on non-Windows', () => {
setPlatform('linux');
expect(needsWinShell('uv')).toBe(false);
expect(needsWinShell('/usr/bin/uv')).toBe(false);
});

it('returns true on Windows for simple command names', () => {
setPlatform('win32');
expect(needsWinShell('uv')).toBe(true);
expect(needsWinShell('node')).toBe(true);
expect(needsWinShell('pnpm')).toBe(true);
});

it('returns false on Windows for absolute paths', () => {
setPlatform('win32');
expect(needsWinShell('C:\\Program Files\\uv.exe')).toBe(false);
expect(needsWinShell('D:\\tools\\bin\\uv.exe')).toBe(false);
});

it('returns true on Windows for relative paths', () => {
setPlatform('win32');
expect(needsWinShell('bin\\uv.exe')).toBe(true);
expect(needsWinShell('.\\uv.exe')).toBe(true);
});
});

describe('prepareWinSpawn', () => {
let prepareWinSpawn: (
command: string,
args: string[],
forceShell?: boolean,
) => { shell: boolean; command: string; args: string[] };

beforeEach(async () => {
const mod = await import('@electron/utils/win-shell');
prepareWinSpawn = mod.prepareWinSpawn;
});

it('does not quote on non-Windows', () => {
setPlatform('linux');
const result = prepareWinSpawn('/usr/bin/uv', ['python', 'install', '3.12']);
expect(result.shell).toBe(false);
expect(result.command).toBe('/usr/bin/uv');
expect(result.args).toEqual(['python', 'install', '3.12']);
});

it('quotes command and args with spaces on Windows with shell', () => {
setPlatform('win32');
const result = prepareWinSpawn(
'C:\\Program Files\\uv.exe',
['python', 'install', '3.12'],
true,
);
expect(result.shell).toBe(true);
expect(result.command).toBe('"C:\\Program Files\\uv.exe"');
expect(result.args).toEqual(['python', 'install', '3.12']);
});

it('quotes args that contain spaces on Windows with shell', () => {
setPlatform('win32');
const result = prepareWinSpawn(
'node',
['C:\\Users\\John Doe\\script.js', '--port', '18789'],
true,
);
expect(result.shell).toBe(true);
expect(result.command).toBe('node');
expect(result.args).toEqual(['"C:\\Users\\John Doe\\script.js"', '--port', '18789']);
});

it('auto-detects shell need based on absolute path on Windows', () => {
setPlatform('win32');
const absResult = prepareWinSpawn(
'C:\\tools\\uv.exe',
['python', 'find', '3.12'],
);
expect(absResult.shell).toBe(false);

const relResult = prepareWinSpawn(
'uv',
['python', 'find', '3.12'],
);
expect(relResult.shell).toBe(true);
});
});