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
2 changes: 1 addition & 1 deletion src/config-field-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const CONFIG_FIELD_DEFINITIONS = {
},
defaultShell: {
label: 'Default Shell',
description: 'This is the shell used for new command sessions (for example /bin/bash or /bin/zsh). Only change this if you know your environment requires a specific shell.',
description: 'This is the shell used for new command sessions (for example /bin/bash or /bin/zsh). You can include arguments, for example "pwsh.exe -NoProfile -NoLogo". Only change this if you know your environment requires a specific shell.',
valueType: 'string',
},
telemetryEnabled: {
Expand Down
84 changes: 54 additions & 30 deletions src/terminal-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,65 +82,89 @@ interface ShellSpawnConfig {
windowsVerbatim?: boolean;
}

/**
* Split a shell configuration value into its executable and any extra arguments.
*
* defaultShell (and the per-call shell parameter) may carry flags, e.g.
* "pwsh.exe -NoProfile -NoLogo" or "/bin/bash --norc". Without this split the
* whole string is treated as one executable path, spawn fails with ENOENT and
* the call hangs until the client's timeout. Quotes are honoured so executable
* paths containing spaces survive, e.g. '"C:\\Program Files\\PowerShell\\7\\pwsh.exe" -NoProfile'.
* See issue #448.
*/
function splitShellConfig(shell: string): { executable: string; args: string[] } {
const tokens: string[] = [];
const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(shell)) !== null) {
tokens.push(match[1] ?? match[2] ?? match[3]);
}
const [executable = shell, ...args] = tokens;
return { executable, args };
}

/**
* Get the appropriate spawn configuration for a given shell
* This handles login shell flags for different shell types
*/
function getShellSpawnArgs(shellPath: string, command: string): ShellSpawnConfig {
const shellName = path.basename(shellPath).toLowerCase();

export function getShellSpawnArgs(shellPath: string, command: string): ShellSpawnConfig {
// The shell value may include arguments (e.g. "pwsh.exe -NoProfile"); match on
// the executable name only and keep the caller's extra args ahead of our flags.
const { executable: shellExecutable, args: shellArgs } = splitShellConfig(shellPath);
const shellName = path.basename(shellExecutable).toLowerCase();

// Unix shells with login flag support
if (shellName.includes('bash') || shellName.includes('zsh')) {
return {
executable: shellPath,
args: ['-l', '-c', command],
useShellOption: false
return {
executable: shellExecutable,
args: [...shellArgs, '-l', '-c', command],
useShellOption: false
};
}

// PowerShell Core (cross-platform, supports -Login)
if (shellName === 'pwsh' || shellName === 'pwsh.exe') {
return {
executable: shellPath,
args: ['-Login', '-Command', command],
useShellOption: false
return {
executable: shellExecutable,
args: [...shellArgs, '-Login', '-Command', command],
useShellOption: false
};
}

// Windows PowerShell 5.1 (no login flag support)
if (shellName === 'powershell' || shellName === 'powershell.exe') {
return {
executable: shellPath,
args: ['-Command', command],
useShellOption: false
return {
executable: shellExecutable,
args: [...shellArgs, '-Command', command],
useShellOption: false
};
}

// CMD
if (shellName === 'cmd' || shellName === 'cmd.exe') {
return {
executable: shellPath,
args: ['/c', command],
return {
executable: shellExecutable,
args: [...shellArgs, '/c', command],
windowsVerbatim: true,
useShellOption: false
useShellOption: false
};
}

// Fish shell (uses -l for login, -c for command)
if (shellName.includes('fish')) {
return {
executable: shellPath,
args: ['-l', '-c', command],
useShellOption: false
return {
executable: shellExecutable,
args: [...shellArgs, '-l', '-c', command],
useShellOption: false
};
}

// Unknown/other shells - use shell option for safety
// This provides a fallback for shells we don't explicitly handle
return {
return {
executable: command,
args: [],
useShellOption: shellPath
useShellOption: shellExecutable
};
}

Expand Down
69 changes: 69 additions & 0 deletions test/test-default-shell-args.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* Tests for defaultShell / shell values that carry arguments (issue #448).
*
* A defaultShell like "pwsh.exe -NoProfile -NoLogo" or "/bin/bash --norc" used to
* be treated as a single executable path, so spawn failed with ENOENT and the
* call hung until the client timed out. getShellSpawnArgs now splits the
* executable from its arguments, matches the shell on the executable name and
* keeps the caller's extra args ahead of the standard flags. Single-token values
* must stay byte-for-byte identical to the old behaviour.
*
* These assertions are platform independent (shell matching for cmd/pwsh keys and
* the POSIX paths used here resolve the same on any OS), so they run on macOS CI.
*/
import { getShellSpawnArgs } from '../dist/terminal-manager.js';
import assert from 'assert';

function runDefaultShellArgsTests() {
// 1. Single-token values are unchanged (no regression)
let cfg = getShellSpawnArgs('/bin/bash', 'echo hi');
assert.strictEqual(cfg.executable, '/bin/bash');
assert.deepStrictEqual(cfg.args, ['-l', '-c', 'echo hi']);
console.log('✓ single-token /bin/bash unchanged');

cfg = getShellSpawnArgs('pwsh.exe', 'x');
assert.strictEqual(cfg.executable, 'pwsh.exe');
assert.deepStrictEqual(cfg.args, ['-Login', '-Command', 'x']);
console.log('✓ single-token pwsh.exe unchanged');

// 2. The reported case: pwsh with flags no longer collapses into the executable
cfg = getShellSpawnArgs('pwsh.exe -NoProfile -NoLogo', 'Write-Host hi');
assert.strictEqual(cfg.executable, 'pwsh.exe');
assert.deepStrictEqual(cfg.args, ['-NoProfile', '-NoLogo', '-Login', '-Command', 'Write-Host hi']);
console.log('✓ pwsh.exe with flags splits correctly');

// 3. Unix shell with a flag
cfg = getShellSpawnArgs('/bin/bash --norc', 'echo hi');
assert.strictEqual(cfg.executable, '/bin/bash');
assert.deepStrictEqual(cfg.args, ['--norc', '-l', '-c', 'echo hi']);
console.log('✓ /bin/bash --norc splits correctly');

// 4. Quoted executable path with spaces survives
cfg = getShellSpawnArgs('"/opt/my shell/bin/bash" --norc', 'echo hi');
assert.strictEqual(cfg.executable, '/opt/my shell/bin/bash');
assert.deepStrictEqual(cfg.args, ['--norc', '-l', '-c', 'echo hi']);
console.log('✓ quoted executable path with spaces preserved');

// 5. cmd with a flag keeps windowsVerbatim
cfg = getShellSpawnArgs('cmd.exe /q', 'dir');
assert.strictEqual(cfg.executable, 'cmd.exe');
assert.deepStrictEqual(cfg.args, ['/q', '/c', 'dir']);
assert.strictEqual(cfg.windowsVerbatim, true);
console.log('✓ cmd.exe /q splits correctly and stays verbatim');

console.log('\n✅ All defaultShell argument-parsing tests passed');
}

export default async function runTests() {
try {
runDefaultShellArgsTests();
return true;
} catch (error) {
console.error('❌ Test failed:', error.message);
return false;
}
}

if (import.meta.url === `file://${process.argv[1]}`) {
runDefaultShellArgsTests();
}