Files
Codeman/src/utils/shell-resolver.ts
T
Codeman maintainer 2b89f35599 fix(shell,remote-ssh): allowlist the login flags, and keep only CRASHED remote panes
Follow-up to #209 and #210. Both land a real fix (a pane that is a login shell
picks up /etc/profile and the per-user PATH entries an ssh remote command never
sees, which is what was failing agent CLIs with exit 127). Three corrections:

1. `-i -l` is no longer hardcoded onto the resolved shell. That path ultimately
   comes from the passwd entry, which is user data and can name anything, and a
   shell that rejects an unknown flag exits on the spot: nushell, elvish and xonsh
   take neither flag, so a user with one of those in passwd would have gotten a
   dead pane on arrival, which is exactly the #208 failure #209 builds on top of.
   loginShellArgs() applies them only to the POSIX-family shells verified to
   accept both, and a test really launches every allowlisted shell present on the
   machine rather than trusting the set. csh/tcsh are excluded deliberately: tcsh
   honors -l only when it is the ONLY flag.

2. `remain-on-exit on` -> `failed`, moved LAST in the tmux command chain. `on`
   keeps the pane after a CLEAN exit too, so typing `exit` in a remote shell
   stranded a dead pane, the session outlived it, and the next launch's `-A`
   reattached to that corpse: "Pane is dead (status 0)" instead of a shell,
   permanently, on the DEFAULT path. Verified against a real tmux, as was the
   fix: `failed` tears the session down on status 0 and keeps the pane on 127
   with the "command not found" still on screen, which is the case #210 wanted.
   It is last because tmux aborts the remaining commands of a `\;` sequence once
   one errors (also verified) and `failed` needs tmux >= 3.2 on the REMOTE host;
   leading, a rejection there would have silently dropped status/mouse/prefix/
   escape-time/window-size along with it.

3. `$SHELL` -> `"${SHELL:-/bin/sh}"`, via one shared remoteLoginShellCommand()
   helper instead of the string being rebuilt in tmux-manager as well.

Also corrects the rationale both PRs carried: a tmux pane already hands the shell
a tty, so it was interactive all along ($- contains i for a bare /bin/bash in a
pane) and ~/.bashrc was always being sourced. `-l` is the flag doing the work.

End-to-end verified, not just unit-tested: the emitted remote pane command was
run through all three quoting layers under a minimal sshd-style PATH with the
CLI installed only on a login-shell PATH entry, and it resolved and launched the
CLI with its arguments intact and a space-containing remote path preserved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 01:40:37 +02:00

112 lines
4.6 KiB
TypeScript

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