diff --git a/src/apps/shell/src/runtime/shell-config.test.ts b/src/apps/shell/src/runtime/shell-config.test.ts new file mode 100644 index 0000000..68005f2 --- /dev/null +++ b/src/apps/shell/src/runtime/shell-config.test.ts @@ -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'); + }); +}); diff --git a/src/apps/shell/src/runtime/shell-config.ts b/src/apps/shell/src/runtime/shell-config.ts index b9464cd..7f68083 100644 --- a/src/apps/shell/src/runtime/shell-config.ts +++ b/src/apps/shell/src/runtime/shell-config.ts @@ -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): Record { + 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 = {}): Record { if (process.platform === 'win32') { const env: Record = {}; @@ -18,18 +47,18 @@ export function safeEnv(extra: Record = {}): Record = {