2026-08-04 23:09:00 +02:00
|
|
|
/**
|
|
|
|
|
* @fileoverview Resolve a real, launchable login shell for `mode: 'shell'` sessions.
|
|
|
|
|
*
|
|
|
|
|
* The tmux pane command for a local shell session used to be the literal string
|
|
|
|
|
* `$SHELL`. That string is embedded in the `bash -c "…"` argument of the
|
|
|
|
|
* `respawn-pane` line, which `execSync` hands to `/bin/sh -c` — so `$SHELL` was
|
|
|
|
|
* expanded by the SERVER process's shell (not the pane's), against the SERVER
|
|
|
|
|
* process's env. Containers and system-level systemd units do not set `SHELL`,
|
|
|
|
|
* so the expansion produced an empty string and the pane command ended in a
|
|
|
|
|
* dangling `&&`:
|
|
|
|
|
*
|
|
|
|
|
* bash -c "cd \"/case\" && ulimit … && export … && "
|
|
|
|
|
* -> bash: -c: line 1: syntax error: unexpected end of file
|
|
|
|
|
*
|
|
|
|
|
* The pane then died instantly (status 2) while tmux creation itself reported
|
|
|
|
|
* success, which is exactly what issue #208 saw. Resolving the shell HERE, in
|
|
|
|
|
* Node, removes the shell-expansion layer entirely and guarantees a non-empty
|
|
|
|
|
* absolute path.
|
|
|
|
|
*
|
|
|
|
|
* @module utils/shell-resolver
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { accessSync, constants } from 'node:fs';
|
|
|
|
|
import { userInfo } from 'node:os';
|
|
|
|
|
|
|
|
|
|
/** Last-resort shells, in preference order. `/bin/sh` exists on every POSIX host. */
|
|
|
|
|
const FALLBACK_SHELLS = ['/bin/bash', '/bin/zsh', '/bin/sh'];
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Shells that exist and are executable but immediately exit — a service account's
|
|
|
|
|
* passwd entry commonly points at one, which would look identical to the crash
|
|
|
|
|
* this module exists to prevent.
|
|
|
|
|
*/
|
|
|
|
|
const NON_INTERACTIVE_SHELLS = new Set(['nologin', 'false', 'true', 'sync']);
|
|
|
|
|
|
2026-08-05 01:40:37 +02:00
|
|
|
/**
|
|
|
|
|
* Shells verified to accept BOTH `-i` and `-l`. Deliberately an allowlist, not a
|
|
|
|
|
* blocklist: a shell that rejects an unknown flag exits immediately, which is the
|
|
|
|
|
* dead-pane-on-arrival failure this module exists to prevent (#208). The passwd
|
|
|
|
|
* entry is user data and can name anything — nushell, elvish, and xonsh all take
|
|
|
|
|
* neither flag in this form, so they get a bare launch instead of a dead tab.
|
|
|
|
|
*
|
|
|
|
|
* csh/tcsh are excluded on purpose: tcsh honors `-l` only when it is the ONLY
|
|
|
|
|
* flag, so `-i -l` would silently not be a login shell there anyway.
|
|
|
|
|
*/
|
|
|
|
|
const LOGIN_FLAG_SHELLS = new Set(['sh', 'bash', 'dash', 'ash', 'zsh', 'ksh', 'ksh93', 'mksh', 'pdksh', 'fish']);
|
|
|
|
|
|
2026-08-04 23:09:00 +02:00
|
|
|
function isUsableShell(candidate: string): boolean {
|
|
|
|
|
if (!candidate.startsWith('/')) return false;
|
|
|
|
|
const base = candidate.slice(candidate.lastIndexOf('/') + 1);
|
|
|
|
|
if (NON_INTERACTIVE_SHELLS.has(base)) return false;
|
|
|
|
|
try {
|
|
|
|
|
accessSync(candidate, constants.X_OK);
|
|
|
|
|
return true;
|
|
|
|
|
} catch {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Resolve an absolute path to an interactive shell, preferring the user's own.
|
|
|
|
|
*
|
|
|
|
|
* Order: `$SHELL` -> the passwd entry -> `/bin/bash` -> `/bin/zsh` -> `/bin/sh`.
|
|
|
|
|
* Every candidate must be an absolute path to an executable that is not a
|
|
|
|
|
* nologin-style stub. Always returns a non-empty string.
|
|
|
|
|
*/
|
|
|
|
|
export function resolveLocalShell(): string {
|
|
|
|
|
const candidates: string[] = [];
|
|
|
|
|
|
|
|
|
|
const envShell = process.env.SHELL?.trim();
|
|
|
|
|
if (envShell) candidates.push(envShell);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Throws when the uid has no /etc/passwd entry (common for `--user` containers).
|
|
|
|
|
const passwdShell = userInfo().shell?.trim();
|
|
|
|
|
if (passwdShell) candidates.push(passwdShell);
|
|
|
|
|
} catch {
|
|
|
|
|
/* no passwd entry — fall through to the static fallbacks */
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
candidates.push(...FALLBACK_SHELLS);
|
|
|
|
|
|
|
|
|
|
for (const candidate of candidates) {
|
|
|
|
|
if (isUsableShell(candidate)) return candidate;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Nothing was verifiable (exotic/read-restricted image). /bin/sh is still the
|
|
|
|
|
// best guess and is far better than emitting an empty command.
|
|
|
|
|
return '/bin/sh';
|
|
|
|
|
}
|
2026-08-05 01:40:37 +02:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Flags that make `shellPath` a login shell, or `''` when it takes none we trust.
|
|
|
|
|
*
|
|
|
|
|
* A tmux pane already hands the shell a tty, so it is interactive with or without
|
|
|
|
|
* `-i` (verified: `$-` contains `i` for a bare `/bin/bash` in a pane, which is why
|
|
|
|
|
* `~/.bashrc` has always been sourced). The flag that actually changes anything is
|
|
|
|
|
* `-l`: it makes the pane a LOGIN shell, matching what tmux itself does when it
|
|
|
|
|
* spawns a pane with no `default-command`, and picking up the `/etc/profile` and
|
|
|
|
|
* `/etc/profile.d/*` PATH entries that a systemd-spawned server never sourced.
|
|
|
|
|
*
|
|
|
|
|
* `-i` is kept alongside it because for bash the two select different files —
|
|
|
|
|
* login reads `~/.bash_profile`, interactive-non-login reads `~/.bashrc` — and
|
|
|
|
|
* asking for both is the closest thing to "the shell the user actually gets".
|
|
|
|
|
*
|
|
|
|
|
* Returns a string ready to append to an already-escaped shell path.
|
|
|
|
|
*/
|
|
|
|
|
export function loginShellArgs(shellPath: string): string {
|
|
|
|
|
const base = shellPath.slice(shellPath.lastIndexOf('/') + 1);
|
|
|
|
|
return LOGIN_FLAG_SHELLS.has(base) ? ' -i -l' : '';
|
|
|
|
|
}
|