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
46 changes: 46 additions & 0 deletions src/apps/shell/src/runtime/shell-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { pickUnixShell, withDefaultPath } from './shell-config.js';

describe('pickUnixShell', () => {
it('returns $SHELL when it exists', () => {
const exists = (p: string) => p === '/opt/homebrew/bin/fish';
expect(pickUnixShell(exists, '/opt/homebrew/bin/fish')).toBe('/opt/homebrew/bin/fish');
});

it('ignores $SHELL when the path does not exist', () => {
const exists = (p: string) => p === '/bin/zsh';
expect(pickUnixShell(exists, '/nonexistent/shell')).toBe('/bin/zsh');
});

it('falls back to an ABSOLUTE path (never a bare name) when $SHELL is unset', () => {
// Regression: bare "bash" makes posix_spawnp fail when the child env has no PATH (macOS daemon/GUI launch).
const exists = (p: string) => p === '/bin/zsh';
const result = pickUnixShell(exists, undefined);
expect(result.startsWith('/')).toBe(true);
expect(result).toBe('/bin/zsh');
});

it('prefers zsh, then bash, then sh', () => {
expect(pickUnixShell((p) => ['/bin/zsh', '/bin/bash', '/bin/sh'].includes(p), undefined)).toBe('/bin/zsh');
expect(pickUnixShell((p) => ['/bin/bash', '/bin/sh'].includes(p), undefined)).toBe('/bin/bash');
expect(pickUnixShell((p) => p === '/bin/sh', undefined)).toBe('/bin/sh');
});

it('never returns a bare command even when nothing is found', () => {
const result = pickUnixShell(() => false, undefined);
expect(result.startsWith('/')).toBe(true);
});
});

describe('withDefaultPath', () => {
it('injects a sane PATH when the env has none', () => {
const env = withDefaultPath({ TERM: 'xterm-256color' });
expect(env.PATH).toBeTruthy();
expect(env.PATH).toContain('/bin');
});

it('preserves an existing PATH', () => {
const env = withDefaultPath({ PATH: '/custom/bin' });
expect(env.PATH).toBe('/custom/bin');
});
});
37 changes: 33 additions & 4 deletions src/apps/shell/src/runtime/shell-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,35 @@ export type { ShellConfig };

const ENV_ALLOWLIST_UNIX = ['HOME', 'PATH', 'USER', 'SHELL', 'LANG', 'LC_ALL', 'SSH_AUTH_SOCK'];

/** Absolute shells tried, in order, when $SHELL is unset/missing. zsh is the macOS default since Catalina. */
const UNIX_SHELL_CANDIDATES = ['/bin/zsh', '/bin/bash', '/bin/sh', '/usr/bin/zsh', '/usr/bin/bash', '/usr/bin/sh'];

/** Conservative default PATH for spawn envs launched without one (daemon/GUI launch). */
const DEFAULT_UNIX_PATH = '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin';

/**
* Ensure a spawn env has a PATH. A macOS daemon or GUI-launched process can have a sparse
* environment with no PATH; without it node-pty's posix_spawnp cannot locate a bare command
* and fails. Pure/testable — does not read process.env.
*/
export function withDefaultPath(env: Record<string, string>): Record<string, string> {
if (env.PATH) return env;
return { ...env, PATH: DEFAULT_UNIX_PATH };
}

/**
* Resolve a macOS/Linux shell to an ABSOLUTE path. Returning a bare name ("bash") makes
* posix_spawnp depend on PATH being present in the child env, which fails under daemon/GUI
* launch. Pure/testable — caller injects the existence check and $SHELL value.
*/
export function pickUnixShell(exists: (p: string) => boolean, shellEnv: string | undefined): string {
if (shellEnv && exists(shellEnv)) return shellEnv;
for (const sh of UNIX_SHELL_CANDIDATES) {
if (exists(sh)) return sh;
}
return '/bin/sh';
}

export function safeEnv(extra: Record<string, string> = {}): Record<string, string> {
if (process.platform === 'win32') {
const env: Record<string, string> = {};
Expand All @@ -18,18 +47,18 @@ export function safeEnv(extra: Record<string, string> = {}): Record<string, stri
for (const key of ENV_ALLOWLIST_UNIX) {
if (process.env[key] !== undefined) env[key] = process.env[key]!;
}
return { ...env, ...extra };
return withDefaultPath({ ...env, ...extra });
}

function resolveDefaultShell(): string {
const userShell = process.env.SHELL;
if (userShell && fs.existsSync(userShell)) return userShell;
if (process.platform === 'win32') {
const userShell = process.env.SHELL;
if (userShell && fs.existsSync(userShell)) return userShell;
const gitBash = 'C:\\Program Files\\Git\\bin\\bash.exe';
if (fs.existsSync(gitBash)) return gitBash;
return 'cmd.exe';
}
return 'bash';
return pickUnixShell(fs.existsSync, process.env.SHELL);
}

export const SHELL_CONFIGS: Record<string, ShellConfig> = {
Expand Down
Loading