mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 21:49:42 +02:00
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>
112 lines
4.6 KiB
TypeScript
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' : '';
|
|
}
|