Files
Codeman/src/utils/shell-resolver.ts
T

111 lines
4.6 KiB
TypeScript
Raw Normal View History

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']);
/**
* 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';
}
/**
* 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' : '';
}