fix(remote): start csh and tcsh login shells without -i -l

A remote host whose login shell is tcsh came up on 1.48.0, when the local
sh rewrote ${SHELL:-/bin/sh} to the Codeman host's shell. Since #770 the
launch reaches the far end verbatim and runs tcsh itself, and tcsh refuses
-i -l -c, -l -c and -i -l, so every stock launch left a dead pane.

The login flags are now picked on the far end, in the shape of
loginShellArgs(): set -- -i -l, cleared for csh and tcsh, then
exec "${SHELL:-/bin/sh}" "$@". The text is one brace group, so
cd <path> && { ...; } still starts nothing when the cd fails. bsd-csh takes
every flag combination, csh is listed because it is tcsh on macOS and the BSDs.

The version probe keeps its one statement: the login shell parses it, not
/bin/sh, so fish and tcsh refuse it either way, as they did on 1.48.0.

Measured with the real builder on a real sshd in a throwaway Debian container
(tcsh 6.24.07, bsd-csh, fish 3.6, bash) and through a fake sshd with
tcsh 6.24.10, fish, zsh, dash and bash, a real Codeman server on tcsh included.
The tcsh disclosure comes out of the docs and the changeset. They keep the two
limits 1.48.0 had too: tcsh has no export, so the PATH bootstrap only prints an
error, and the tmux probe needs a command executable, which Debian lacks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 06:05:53 +01:00
1 parent cd0bbc3e3a
commit 5559aa8294
18 files changed
+161 -56

No files matched your search

+1 -2
View File
@@ -1437,8 +1437,7 @@ const GROK: CliEntry = {
// docs/, bin/ — per-file seeding like pi's credStore, not a whole-dir seedWhole copy.
credStore: { rel: '.grok', seedFiles: ['auth.json', 'config.toml', 'pager.toml'] },
// No remote/docker overlay needed: the defaults (exec grok / login-shell `grok`) are
// already correct — verified against upstream's own pinned test/grok-mode.test.ts
// expectation `exec "${SHELL:-/bin/sh}" -i -l -c 'grok'`.
// already correct, verified against upstream's own pinned test/grok-mode.test.ts.
},
};
+36 -17
View File
@@ -74,6 +74,20 @@ export async function writeRemoteCases(configDir: string, cases: RemoteCase[]):
*/
const REMOTE_LOGIN_SHELL = '"${SHELL:-/bin/sh}"';
/**
* The remote login shell with the flags that make it one, as ONE compound command, so
* `cd <path> && <this>` keeps its short-circuit (a failed `cd` must not start a shell in
* the wrong folder). `args` follows the flags verbatim.
*
* The flags are chosen on the far end: the shell is whatever the REMOTE passwd says, so
* this is `loginShellArgs()` run where the answer is known. tcsh honors `-l` only as its
* ONLY flag and exits on `-i -l -c`, `-l -c` and `-i -l` (measured on tcsh 6.24), and
* `-c` needs no login flag to read `.tcshrc`, so csh and tcsh get neither flag.
*/
function remoteLoginShell(args: string): string {
return `{ set -- -i -l; case \${SHELL##*/} in csh|tcsh) set --;; esac; exec ${REMOTE_LOGIN_SHELL} "$@"${args}; }`;
}
/**
* Run `command` through the remote user's interactive login shell, so per-user
* PATH entries (~/.local/bin, ~/.opencode/bin, …) are resolved before the CLI name
@@ -81,15 +95,15 @@ const REMOTE_LOGIN_SHELL = '"${SHELL:-/bin/sh}"';
* so a bare `exec claude` sees only sshd's minimal default PATH and dies with
* "command not found" (exit 127).
*
* Shells that take neither flag cannot be detected from here the way
* `loginShellArgs()` detects them locally, since the shell is whatever the REMOTE
* passwd says. A host like that is what the per-host `commands.*` override is for
* (tcsh, which rejects `-l` combined with `-i`). It only goes so far: the PATH
* bootstrap below runs in the login shell first, so a shell that cannot parse
* `export` at all (nushell, elvish) cannot be rescued by an override.
* ⚠️ POSIX text, like `REMOTE_LOGIN_SHELL`: only `/bin/sh -c` may parse it.
*
* Other shells that take neither flag are what the per-host `commands.*` override is
* for. It only goes so far: the PATH bootstrap below runs in the login shell first, so
* a shell that cannot parse `export` at all (nushell, elvish) cannot be rescued by an
* override.
*/
export function remoteLoginShellCommand(command: string): string {
return `exec ${REMOTE_LOGIN_SHELL} -i -l -c ${shellescape(command)}`;
return remoteLoginShell(` -c ${shellescape(command)}`);
}
/**
@@ -130,8 +144,9 @@ function overlayCliCommand(mode: SessionMode, location: 'remote' | 'docker'): st
* user's interactive-login shell startup files (~/.zshrc etc.). ssh's remote-command
* execution is neither interactive nor login, so a bare `exec claude` sees only sshd's
* minimal default PATH and fails with "command not found" (exit 127) — confirmed via
* `tmux capture-pane` on the remain-on-exit-preserved dead pane. Route through
* `$SHELL -i -l -c`, the same fix shell mode uses, so PATH is fully resolved first.
* `tmux capture-pane` on the remain-on-exit-preserved dead pane. Route through the
* login shell (`remoteLoginShellCommand`), the same fix shell mode uses, so PATH is fully
* resolved first.
*
* ⚠️ The per-CLI half is now READ FROM THE REGISTRY (`overlays.remote`), not from a
* hardcoded `Record<RemoteCommandMode, string>`. The table it replaces duplicated the
@@ -148,10 +163,11 @@ function overlayCliCommand(mode: SessionMode, location: 'remote' | 'docker'): st
export function defaultRemoteCommandForMode(mode: SessionMode): string {
// $SHELL, not a hardcoded bash: sshd sets it from the remote user's /etc/passwd entry, and
// tmux exports its default-shell as SHELL into the pane, so this launches their actual
// login shell, or the default-shell their tmux.conf picks (zsh, fish, …). `-i -l` so it sources rc files,
// matching the local shell-mode launch. Not templatable as overlay data: the shell is
// whatever the REMOTE passwd says, which is why `shell` is the one arm still written here.
const shellCommand = `exec ${REMOTE_LOGIN_SHELL} -i -l`;
// login shell, or the default-shell their tmux.conf picks (zsh, fish, …). `-i -l` where it
// takes them, so it sources rc files, matching the local shell-mode launch. Not templatable
// as overlay data: the shell is whatever the REMOTE passwd says, which is why `shell` is the
// one arm still written here.
const shellCommand = remoteLoginShell('');
const cli = overlayCliCommand(mode, 'remote');
return cli === null ? shellCommand : remoteLoginShellCommand(cli);
}
@@ -325,13 +341,16 @@ function remoteCliBin(mode: SessionMode): string | undefined {
/**
* Build the SSH command that reads the remote CLI's version (`claude --version`
* on the remote host). The version query is routed through
* `remoteLoginShellCommand` (the SAME `$SHELL -i -l -c` wrapper the real
* launch uses), because agent CLIs live on PATH only after the remote user's
* on the remote host). The version query runs under `$SHELL -i -l -c` like the real
* launch, because agent CLIs live on PATH only after the remote user's
* interactive-login startup files run (see defaultRemoteCommandForMode); a bare
* `claude --version` over ssh exits 127. Connection options come from the
* shared `buildSshConnectionArgs`, so the probe reaches exactly the hosts the
* launch can reach. Returns null for modes with no CLI (shell).
*
* ⚠️ One statement, never `remoteLoginShellCommand`: nothing hands this text to `/bin/sh`,
* the login shell parses it itself. fish and tcsh refuse the `${…}` either way, so such a
* host reports no version, the same as a failed probe, and its launch is not affected.
*/
export function buildRemoteCliVersionProbeCommand(
host: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions,
@@ -342,7 +361,7 @@ export function buildRemoteCliVersionProbeCommand(
return [
...buildSshConnectionArgs(host),
remoteSshTarget(host),
shellescape(remoteLoginShellCommand(`${bin} --version`)),
shellescape(`exec ${REMOTE_LOGIN_SHELL} -i -l -c ${shellescape(`${bin} --version`)}`),
].join(' ');
}
+3 -3
View File
@@ -1339,9 +1339,9 @@ export function buildRemoteLaunchCommand(options: {
// hardcoding --dangerously-skip-permissions, so a non-granted multi-user user's
// downgraded 'auto' actually reaches the remote agent (the default command otherwise
// ignored claudeMode). A per-host `commands.claude` override stays authoritative
// (admin's explicit choice). Wrapped in `$SHELL -i -l -c` for the same reason as
// `defaultRemoteCommandForMode`: `claude` lives under a per-user PATH entry that
// only an interactive login shell resolves (see that function's comment).
// (admin's explicit choice). Wrapped in the login shell (`remoteLoginShellCommand`) for
// the same reason as `defaultRemoteCommandForMode`: `claude` lives under a per-user PATH
// entry that only an interactive login shell resolves (see that function's comment).
const override = remote.commands?.[mode];
let modeCommand: string;
if (override) {