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
3 changes: 2 additions & 1 deletion electron/ipc/pty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import path from 'path';
import { fileURLToPath } from 'url';
import type { BrowserWindow } from 'electron';
import { RingBuffer } from '../remote/ring-buffer.js';
import { resolveUserShell } from '../user-shell.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
Expand Down Expand Up @@ -102,7 +103,7 @@ export function spawnAgent(
},
): void {
const channelId = args.onOutput.__CHANNEL_ID__;
const command = args.command || process.env.SHELL || '/bin/sh';
const command = args.command || resolveUserShell();
const cwd = args.cwd || process.env.HOME || '/';

// Reject commands with shell metacharacters (node-pty uses execvp, but
Expand Down
3 changes: 2 additions & 1 deletion electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { registerAllHandlers } from './ipc/register.js';
import { killAllAgents } from './ipc/pty.js';
import { stopAllPlanWatchers } from './ipc/plans.js';
import { IPC } from './ipc/channels.js';
import { resolveUserShell } from './user-shell.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
Expand All @@ -26,7 +27,7 @@ const __dirname = path.dirname(__filename);
function fixPath(): void {
if (process.platform === 'win32') return;
try {
const loginShell = process.env.SHELL || '/bin/sh';
const loginShell = resolveUserShell();
const sentinel = '__PCODE_PATH__';
const result = execFileSync(loginShell, ['-ilc', `printf "${sentinel}%s${sentinel}" "$PATH"`], {
encoding: 'utf8',
Expand Down
85 changes: 85 additions & 0 deletions electron/user-shell.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import { resolveUserShell } from './user-shell.js';

const mockUserInfo = {
username: 'test-user',
uid: 501,
gid: 20,
homedir: '/home/test-user',
};

const allowShells =
(...shells: string[]) =>
(shell: string) =>
shells.includes(shell);

describe('resolveUserShell', () => {
it('prefers the OS account shell over the inherited SHELL env var', () => {
const shell = resolveUserShell({
userInfo: () => ({
...mockUserInfo,
shell: '/bin/zsh',
}),
env: { SHELL: '/bin/bash' },
platform: 'darwin',
canUseShell: allowShells('/bin/zsh', '/bin/bash'),
});

expect(shell).toBe('/bin/zsh');
});

it('falls back to SHELL when the OS lookup has no shell', () => {
const shell = resolveUserShell({
userInfo: () => ({
...mockUserInfo,
shell: '',
}),
env: { SHELL: '/opt/homebrew/bin/bash' },
platform: 'darwin',
canUseShell: allowShells('/opt/homebrew/bin/bash'),
});

expect(shell).toBe('/opt/homebrew/bin/bash');
});

it('falls back to SHELL when the OS lookup throws', () => {
const shell = resolveUserShell({
userInfo: () => {
throw new Error('unavailable');
},
env: { SHELL: '/bin/zsh' },
platform: 'linux',
canUseShell: allowShells('/bin/zsh'),
});

expect(shell).toBe('/bin/zsh');
});

it('falls back to SHELL when the OS shell is not executable', () => {
const shell = resolveUserShell({
userInfo: () => ({
...mockUserInfo,
shell: '/missing/zsh',
}),
env: { SHELL: '/bin/bash' },
platform: 'linux',
canUseShell: allowShells('/bin/bash'),
});

expect(shell).toBe('/bin/bash');
});

it('falls back to /bin/sh on POSIX when neither OS nor env provides a usable shell', () => {
const shell = resolveUserShell({
userInfo: () => ({
...mockUserInfo,
shell: '/missing/zsh',
}),
env: { SHELL: '/missing/bash' },
platform: 'linux',
canUseShell: allowShells(),
});

expect(shell).toBe('/bin/sh');
});
});
43 changes: 43 additions & 0 deletions electron/user-shell.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import fs from 'fs';
import * as os from 'os';

interface ResolveUserShellDeps {
userInfo?: () => { shell: string | null | undefined };
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
canUseShell?: (shell: string) => boolean;
}

function normalizeShell(shell: string | null | undefined): string | null {
const value = shell?.trim();
return value ? value : null;
}

function isExecutablePosixShell(shell: string): boolean {
try {
fs.accessSync(shell, fs.constants.X_OK);
return true;
} catch {
return false;
}
}

export function resolveUserShell(deps: ResolveUserShellDeps = {}): string {
const env = deps.env ?? process.env;
const platform = deps.platform ?? process.platform;
const userInfo = deps.userInfo ?? os.userInfo;
const canUseShell =
deps.canUseShell ?? ((shell: string) => platform === 'win32' || isExecutablePosixShell(shell));

try {
const osShell = normalizeShell(userInfo().shell);
if (osShell && canUseShell(osShell)) return osShell;
} catch {
// Fall back to inherited environment if the OS lookup is unavailable.
Comment on lines +32 to +36
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolveUserShell() returns the OS-reported shell path without verifying it exists/is executable. If a user's account shell is misconfigured (or points to a removed binary), this will now cause shell tabs to fail (via validateCommand) even if env.SHELL or /bin/sh would work. Consider checking executability before returning osShell, and fall back to env.SHELL//bin/sh when the OS shell isn't runnable.

Copilot uses AI. Check for mistakes.
}

const envShell = normalizeShell(env.SHELL);
if (envShell && canUseShell(envShell)) return envShell;

return platform === 'win32' ? 'cmd.exe' : '/bin/sh';
}
Loading