From 5559aa82945a7cb9e92822e6c222feff875701b9 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 6 Nov 2026 06:05:53 +0100 Subject: [PATCH] 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 && { ...; } 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 --- .changeset/remote-launch-expansion.md | 2 +- docs/grok-integration.md | 2 +- docs/omp-integration.md | 2 +- docs/pi-integration.md | 2 +- docs/remote-sessions.md | 25 +++++---- docs/wiki/Remote-SSH-Sessions.md | 10 ++-- src/config/cli-registry/stock.ts | 3 +- src/remote-hosts.ts | 53 ++++++++++++------ src/tmux-manager.ts | 6 +-- test/antigravity-mode.test.ts | 4 +- test/grok-mode.test.ts | 4 +- test/location-overlay-commands.test.ts | 4 +- test/omp-mode.test.ts | 4 +- test/pi-mode.test.ts | 4 +- test/remote-hosts.test.ts | 10 ++-- test/remote-pane-command-shell.test.ts | 75 +++++++++++++++++++++++++- test/remote-ssh-options.test.ts | 3 +- test/tmux-manager.test.ts | 4 +- 18 files changed, 161 insertions(+), 56 deletions(-) diff --git a/.changeset/remote-launch-expansion.md b/.changeset/remote-launch-expansion.md index 392a0a48..7ab377d7 100644 --- a/.changeset/remote-launch-expansion.md +++ b/.changeset/remote-launch-expansion.md @@ -2,4 +2,4 @@ "aicodeman": patch --- -Remote sessions: new sessions no longer die with `tmux: command not found` (exit 127) when the remote user's home differs from the Codeman host's. The launch line reached the local shell in double quotes, so `$HOME` and `$PATH` in the remote PATH bootstrap expanded on the Codeman host before ssh ran. Launches now reach the far end verbatim, which also means a remote session starts in the remote user's own shell rather than the Codeman host's, and a Codeman host whose `$SHELL` path does not exist on the remote no longer kills the pane. Codeman's own pane command now runs through `/bin/sh` so fish hosts keep working, while a per-host `commands.*` override still runs in the remote user's shell. `commands.omp` and `commands.copilot` are now accepted, like every other mode. Behaviour changes: in a `commands.*` override, `$` now expands on the far end (remote host or container) instead of on the Codeman host; a bare `$VAR` in a Docker `extraCreateArgs`/`extraExecArgs` value is passed to `docker` literally, so write the value out, and the same goes for a remote host's `extraSshOptions` value at launch, which now matches what the tmux probes always sent. A remote host whose login shell is tcsh no longer starts a stock launch: tcsh refuses `-i -l`, which 1.48.0 avoided by running the Codeman host's shell there, so such a host now needs a `commands.*` override per mode (`exec claude`), and a `commands.claude` override gives up the conversation pinning, so a respawn starts a new conversation. Thanks to @Randalix for the report and the fix. +Remote sessions: new sessions no longer die with `tmux: command not found` (exit 127) when the remote user's home differs from the Codeman host's. The launch line reached the local shell in double quotes, so `$HOME` and `$PATH` in the remote PATH bootstrap expanded on the Codeman host before ssh ran. Launches now reach the far end verbatim, which also means a remote session starts in the remote user's own shell rather than the Codeman host's, and a Codeman host whose `$SHELL` path does not exist on the remote no longer kills the pane. Codeman's own pane command now runs through `/bin/sh` so fish hosts keep working, while a per-host `commands.*` override still runs in the remote user's shell. `commands.omp` and `commands.copilot` are now accepted, like every other mode. Behaviour changes: in a `commands.*` override, `$` now expands on the far end (remote host or container) instead of on the Codeman host; a bare `$VAR` in a Docker `extraCreateArgs`/`extraExecArgs` value is passed to `docker` literally, so write the value out, and the same goes for a remote host's `extraSshOptions` value at launch, which now matches what the tmux probes always sent. A csh or tcsh login shell starts without `-i -l`, which tcsh refuses, so it reads `.tcshrc` and not `.login`. Thanks to @Randalix for the report and the fix. diff --git a/docs/grok-integration.md b/docs/grok-integration.md index a78d1d1c..2b0d4e7a 100644 --- a/docs/grok-integration.md +++ b/docs/grok-integration.md @@ -114,7 +114,7 @@ history. ## Remote SSH cases `grok` mode is routed through an interactive login shell -(`exec "$SHELL" -i -l -c 'grok'`), because sshd's remote-command PATH does not include +(`remoteLoginShellCommand('grok')`), because sshd's remote-command PATH does not include `~/.grok/bin`. Per-session config and `envOverrides` do not cross ssh and are rejected rather than silently ignored; use the per-host command override instead. For auth on the remote host, `grok login --device-auth` exists for exactly this. diff --git a/docs/omp-integration.md b/docs/omp-integration.md index 5729dfd9..bb5dea7b 100644 --- a/docs/omp-integration.md +++ b/docs/omp-integration.md @@ -149,7 +149,7 @@ shared nor seeded. ## Remote SSH cases `omp` mode is routed through an interactive login shell -(`exec "$SHELL" -i -l -c 'omp'`), because sshd's remote-command PATH does not +(`remoteLoginShellCommand('omp')`), because sshd's remote-command PATH does not include `~/.local/bin`. Per-session config and `envOverrides` do not cross ssh and are rejected rather than silently ignored; use the per-host command override instead. diff --git a/docs/pi-integration.md b/docs/pi-integration.md index 8dca655e..00625f76 100644 --- a/docs/pi-integration.md +++ b/docs/pi-integration.md @@ -221,7 +221,7 @@ a Docker case only sees that container's own history. ## Remote SSH cases `pi` mode is routed through an interactive login shell -(`exec "$SHELL" -i -l -c 'pi'`), because sshd's remote-command PATH does not +(`remoteLoginShellCommand('pi')`), because sshd's remote-command PATH does not include npm's global bin on most hosts. Per-session config and `envOverrides` do not cross ssh and are rejected rather than silently ignored; use the per-host command override instead. diff --git a/docs/remote-sessions.md b/docs/remote-sessions.md index f2c4c05d..dc46bb45 100644 --- a/docs/remote-sessions.md +++ b/docs/remote-sessions.md @@ -152,17 +152,20 @@ Key points: fish refuses `${SHELL:-/bin/sh}` with status 127, which is a dead pane. A `commands.*` override stays one argument on purpose, since it is written for the user's own shell (`source` and `[[` work in bash and zsh and die under a dash `/bin/sh`). -- **Login shells**: POSIX shells and fish work. nushell and elvish cannot run the - PATH bootstrap at all, so no override helps. A `remotePath` holding an apostrophe - does not start under fish. -- ⚠️ **A tcsh login shell stopped working in 1.49.0.** tcsh refuses `-l` beside any - other flag (``Unknown option: `-l'``), so every stock launch leaves a dead pane. On - 1.48.0 the local `sh` rewrote `${SHELL:-/bin/sh}` to this host's shell, so such a - host ran that shell instead (when its path also exists on the remote) and came up. - It now needs a `commands.*` override per mode (`exec claude`). The Create Case - form's Remote tab offers codex only, the other modes go through - `PUT /api/remote-hosts/:id`, and a `commands.claude` override gives up the - `--session-id`/`--resume` pair, so a respawn starts a new conversation. +- **Login shells**: POSIX shells and fish work, and csh and tcsh do within the limits + below. nushell and elvish cannot run the PATH bootstrap at all, so no override + helps. A `remotePath` holding an apostrophe does not start under fish. +- **The login flags are picked on the far end**, the only place the shell is known: + the pane command passes `-i -l` unless `${SHELL##*/}` is `csh` or `tcsh`, which get + neither. tcsh honors `-l` only as its one flag and exits on `-i -l -c`, `-l -c` and + `-i -l` (measured on 6.24), so a tcsh session reads `.tcshrc` and not `.login`. The + text is one brace group, so `cd && { …; }` starts nothing when the `cd` fails. +- **The login shell still parses the ssh line itself**, as it did in 1.48.0, so tcsh + has two more limits. It has no `export`, so the PATH bootstrap only prints an error + there, and `tmux` and the CLI have to be on a PATH that `.tcshrc` sets. And the tmux + prerequisite probe's `command -v tmux` needs a `command` executable: macOS and the + BSDs ship one, Debian and Ubuntu do not, so linking a tcsh host there is refused + with `could not verify tmux`. - Connection options come from the **same `buildSshConnectionArgs(remote)`** as the prereq probe; `-t` is inserted right after `ssh -o BatchMode=yes`, preserving historical token order. diff --git a/docs/wiki/Remote-SSH-Sessions.md b/docs/wiki/Remote-SSH-Sessions.md index c3e5ca79..3b750909 100644 --- a/docs/wiki/Remote-SSH-Sessions.md +++ b/docs/wiki/Remote-SSH-Sessions.md @@ -35,11 +35,11 @@ in it expands there, not on the Codeman machine. The remote host needs **tmux**. Codeman probes for it when you link the host rather than failing later at launch. -The remote login shell can be bash, zsh, dash or fish. Since 1.49.0 **tcsh needs an override** -for each run mode you use (`exec claude`), because tcsh refuses the flags of a stock launch and -the pane dies. The Remote tab offers the codex override, the others go through -`PUT /api/remote-hosts/:id`, and a claude override gives up resuming the same conversation on a -respawn. nushell and elvish cannot run a remote session at all. +The remote login shell can be bash, zsh, dash, fish, csh or tcsh. A tcsh session reads `.tcshrc` +and not `.login`, so set the PATH that finds `tmux` and your agent CLI in `.tcshrc`. The tmux check +also needs a `command` executable under tcsh: macOS and the BSDs ship one, Debian and Ubuntu do +not, so linking a tcsh host there stops at `could not verify tmux`. nushell and elvish cannot run a +remote session at all. ## What actually runs diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index c92d1d2d..c1634144 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -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. }, }; diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index 7a8afa7e..7f139070 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -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 && ` 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`. 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 & 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(' '); } diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index ad6dfcc5..97adf92b 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -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) { diff --git a/test/antigravity-mode.test.ts b/test/antigravity-mode.test.ts index dca7e669..bb209954 100644 --- a/test/antigravity-mode.test.ts +++ b/test/antigravity-mode.test.ts @@ -120,6 +120,8 @@ describe('Antigravity mode gates', () => { expect(defaultDockerCommandForMode('antigravity')).toBe('exec agy'); // Routed through an interactive login shell so per-user PATH entries resolve — // same fix as the other remote agent CLIs (see defaultRemoteCommandForMode). - expect(defaultRemoteCommandForMode('antigravity')).toBe('exec "${SHELL:-/bin/sh}" -i -l -c \'agy\''); + expect(defaultRemoteCommandForMode('antigravity')).toBe( + '{ set -- -i -l; case ${SHELL##*/} in csh|tcsh) set --;; esac; exec "${SHELL:-/bin/sh}" "$@" -c \'agy\'; }' + ); }); }); diff --git a/test/grok-mode.test.ts b/test/grok-mode.test.ts index 191f17f3..484d2aed 100644 --- a/test/grok-mode.test.ts +++ b/test/grok-mode.test.ts @@ -154,6 +154,8 @@ describe('Grok mode gates', () => { expect(defaultDockerCommandForMode('grok')).toBe('exec grok'); // Routed through an interactive login shell so ~/.grok/bin resolves, // the same fix as the other remote agent CLIs (see defaultRemoteCommandForMode). - expect(defaultRemoteCommandForMode('grok')).toBe('exec "${SHELL:-/bin/sh}" -i -l -c \'grok\''); + expect(defaultRemoteCommandForMode('grok')).toBe( + '{ set -- -i -l; case ${SHELL##*/} in csh|tcsh) set --;; esac; exec "${SHELL:-/bin/sh}" "$@" -c \'grok\'; }' + ); }); }); diff --git a/test/location-overlay-commands.test.ts b/test/location-overlay-commands.test.ts index f4b77961..91fed857 100644 --- a/test/location-overlay-commands.test.ts +++ b/test/location-overlay-commands.test.ts @@ -22,11 +22,9 @@ import { defaultRemoteCommandForMode, remoteLoginShellCommand } from '../src/rem import { defaultDockerCommandForMode } from '../src/docker-hosts.js'; import type { SessionMode } from '../src/types/session.js'; -const REMOTE_LOGIN_SHELL = '"${SHELL:-/bin/sh}"'; - it('pins every remote pane command', () => { const expected: Record = { - shell: `exec ${REMOTE_LOGIN_SHELL} -i -l`, + shell: '{ set -- -i -l; case ${SHELL##*/} in csh|tcsh) set --;; esac; exec "${SHELL:-/bin/sh}" "$@"; }', claude: remoteLoginShellCommand('claude --dangerously-skip-permissions'), opencode: remoteLoginShellCommand('opencode'), codex: remoteLoginShellCommand('codex'), diff --git a/test/omp-mode.test.ts b/test/omp-mode.test.ts index dd7aa5a3..f32b2156 100644 --- a/test/omp-mode.test.ts +++ b/test/omp-mode.test.ts @@ -130,7 +130,9 @@ describe('OMP mode gates', () => { expect(defaultDockerCommandForMode('omp')).toBe('exec omp'); // Routed through an interactive login shell so per-user PATH entries resolve — // same fix as the other remote agent CLIs (see defaultRemoteCommandForMode). - expect(defaultRemoteCommandForMode('omp')).toBe('exec "${SHELL:-/bin/sh}" -i -l -c \'omp\''); + expect(defaultRemoteCommandForMode('omp')).toBe( + '{ set -- -i -l; case ${SHELL##*/} in csh|tcsh) set --;; esac; exec "${SHELL:-/bin/sh}" "$@" -c \'omp\'; }' + ); }); }); diff --git a/test/pi-mode.test.ts b/test/pi-mode.test.ts index c1d63938..ad772e4f 100644 --- a/test/pi-mode.test.ts +++ b/test/pi-mode.test.ts @@ -186,7 +186,9 @@ describe('Pi mode gates', () => { expect(defaultDockerCommandForMode('pi')).toBe('exec pi'); // Routed through an interactive login shell so npm's global bin resolves — // same fix as the other remote agent CLIs (see defaultRemoteCommandForMode). - expect(defaultRemoteCommandForMode('pi')).toBe('exec "${SHELL:-/bin/sh}" -i -l -c \'pi\''); + expect(defaultRemoteCommandForMode('pi')).toBe( + '{ set -- -i -l; case ${SHELL##*/} in csh|tcsh) set --;; esac; exec "${SHELL:-/bin/sh}" "$@" -c \'pi\'; }' + ); }); it('probes the CLI version on a remote host (REMOTE_CLI_BIN carries pi)', () => { diff --git a/test/remote-hosts.test.ts b/test/remote-hosts.test.ts index f07740cf..3933b912 100644 --- a/test/remote-hosts.test.ts +++ b/test/remote-hosts.test.ts @@ -59,14 +59,18 @@ describe('remote-hosts domain', () => { }); it('returns safe mode defaults and remote display values', () => { - expect(defaultRemoteCommandForMode('shell')).toBe('exec "${SHELL:-/bin/sh}" -i -l'); + expect(defaultRemoteCommandForMode('shell')).toBe( + '{ set -- -i -l; case ${SHELL##*/} in csh|tcsh) set --;; esac; exec "${SHELL:-/bin/sh}" "$@"; }' + ); // Routed through an interactive login shell so per-user PATH entries (e.g. // ~/.local/bin, ~/.opencode/bin) resolve — a bare `exec codex` sees only // sshd's minimal default PATH and fails with "command not found". - expect(defaultRemoteCommandForMode('codex')).toBe('exec "${SHELL:-/bin/sh}" -i -l -c \'codex\''); + expect(defaultRemoteCommandForMode('codex')).toBe( + '{ set -- -i -l; case ${SHELL##*/} in csh|tcsh) set --;; esac; exec "${SHELL:-/bin/sh}" "$@" -c \'codex\'; }' + ); // Mirrors the local claude default so the remote agent runs non-interactively. expect(defaultRemoteCommandForMode('claude')).toBe( - 'exec "${SHELL:-/bin/sh}" -i -l -c \'claude --dangerously-skip-permissions\'' + '{ set -- -i -l; case ${SHELL##*/} in csh|tcsh) set --;; esac; exec "${SHELL:-/bin/sh}" "$@" -c \'claude --dangerously-skip-permissions\'; }' ); expect(remoteSshTarget({ id: 'h1', label: 'H1', host: 'box.local', username: 'aamer' })).toBe('aamer@box.local'); expect(remoteDisplayPath({ username: 'aamer', host: 'box.local', path: '/opt/work' })).toBe( diff --git a/test/remote-pane-command-shell.test.ts b/test/remote-pane-command-shell.test.ts index a44f2223..3053bbf2 100644 --- a/test/remote-pane-command-shell.test.ts +++ b/test/remote-pane-command-shell.test.ts @@ -11,7 +11,10 @@ * through a real sh with `ssh` and `tmux` defined as functions that report their arguments. */ import { execFileSync } from 'node:child_process'; -import { describe, expect, it } from 'vitest'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { listClis } from '../src/config/cli-registry/registry.js'; import { buildRemoteLaunchCommand } from '../src/tmux-manager.js'; import type { SessionMode, SessionRemote } from '../src/types.js'; @@ -79,3 +82,73 @@ describe('the shell that parses a remote pane command', () => { expect(sent).toMatch(/set -t codeman-ssh-abcd1234 remain-on-exit failed$/); }); }); + +describe('the login flags a remote launch picks on the far end', () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'remote-login-flags-')); + }); + afterEach(() => rmSync(root, { recursive: true, force: true })); + + /** A shell that reports what it was started with, under the name the launch reads. */ + function fakeShell(name: string): string { + const path = join(root, 'bin', name); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `#!/bin/sh\nfor a; do printf '%s\\0' "$a"; done > "${root}/argv"\n`, { mode: 0o755 }); + return path; + } + + /** Runs the pane argv tmux would receive, in a real sh, with `$SHELL` as given. */ + function runPane(mode: SessionMode, env: NodeJS.ProcessEnv, remotePath = root): void { + const [sh, flag, text] = paneArgv( + buildRemoteLaunchCommand({ mode, remote: { ...remote, remotePath }, sessionId: SESSION_ID }) + ); + execFileSync(sh, [flag, text], { env, stdio: 'ignore' }); + } + + function shellArgv(): string[] { + return readFileSync(join(root, 'argv'), 'utf8').split('\0').slice(0, -1); + } + + it.each(['bash', 'zsh', 'dash', 'sh', 'ksh', 'fish'])('%s starts as an interactive login shell', (name) => { + runPane('codex', { ...process.env, SHELL: fakeShell(name) }); + expect(shellArgv()).toEqual(['-i', '-l', '-c', 'codex']); + + runPane('shell', { ...process.env, SHELL: fakeShell(name) }); + expect(shellArgv()).toEqual(['-i', '-l']); + }); + + // tcsh exits on `-i -l -c`, `-l -c` and `-i -l`: every stock launch was a dead pane on such a host. + it.each(['tcsh', 'csh'])('%s starts with neither flag', (name) => { + runPane('codex', { ...process.env, SHELL: fakeShell(name) }); + expect(shellArgv()).toEqual(['-c', 'codex']); + + runPane('shell', { ...process.env, SHELL: fakeShell(name) }); + expect(shellArgv()).toEqual([]); + }); + + it('goes by the name of the shell, not by a folder on its path', () => { + runPane('codex', { ...process.env, SHELL: fakeShell('tcsh/bash') }); + expect(shellArgv()).toEqual(['-i', '-l', '-c', 'codex']); + }); + + it.each(launches)('%s: a failed cd starts no shell', (_name, options) => { + const [sh, flag, text] = paneArgv( + buildRemoteLaunchCommand({ ...options, remote: { ...remote, remotePath: join(root, 'missing') } }) + ); + + expect(() => + execFileSync(sh, [flag, text], { env: { ...process.env, SHELL: fakeShell('tcsh') }, stdio: 'ignore' }) + ).toThrow(); + expect(existsSync(join(root, 'argv'))).toBe(false); + }); + + it('starts /bin/sh when SHELL is unset', () => { + const { SHELL: _unset, ...env } = process.env; + const bin = dirname(fakeShell('codex')); + + runPane('codex', { ...env, PATH: `${bin}:${env.PATH ?? ''}`, HOME: root }); + expect(shellArgv()).toEqual([]); + }); +}); diff --git a/test/remote-ssh-options.test.ts b/test/remote-ssh-options.test.ts index 5acdbaa8..cc10dc37 100644 --- a/test/remote-ssh-options.test.ts +++ b/test/remote-ssh-options.test.ts @@ -157,7 +157,8 @@ describe('COD-107 buildRemoteLaunchCommand — threads connection args', () => { const sh = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'"; const remoteName = `codeman-ssh-${SESSION_ID.slice(0, 8)}`; const path = sh('/home/ubuntu/work'); - const paneCommand = `cd ${path} && exec "\${SHELL:-/bin/sh}" -i -l`; + const flags = 'set -- -i -l; case ${SHELL##*/} in csh|tcsh) set --;; esac'; + const paneCommand = `cd ${path} && { ${flags}; exec "\${SHELL:-/bin/sh}" "$@"; }`; const tmuxInvocation = [ `tmux -L codeman-remote new-session -A -s ${remoteName} -c ${path} /bin/sh -c ${sh(paneCommand)}`, `set -t ${remoteName} status off`, diff --git a/test/tmux-manager.test.ts b/test/tmux-manager.test.ts index 146236c2..91167301 100644 --- a/test/tmux-manager.test.ts +++ b/test/tmux-manager.test.ts @@ -205,7 +205,7 @@ describe('TmuxManager (unit)', () => { sessionId: 'abc123def456', }); - expect(command).toContain('exec "${SHELL:-/bin/sh}" -i -l'); + expect(command).toContain('exec "${SHELL:-/bin/sh}" "$@"; }'); // `failed`, not `on`: `on` also keeps the pane after a CLEAN exit, so typing // `exit` in a remote shell strands a dead pane that the next launch's `-A` // reattaches to instead of starting a shell. @@ -228,7 +228,7 @@ describe('TmuxManager (unit)', () => { // interactive nor login, so a bare `exec claude` fails with "command not found". // The inner quoting is escaped twice over (once per shellescape() layer), so // assert on the unescaped substrings rather than the literal quoted form. - expect(command).toContain('exec "${SHELL:-/bin/sh}" -i -l -c'); + expect(command).toContain('exec "${SHELL:-/bin/sh}" "$@" -c'); expect(command).toContain('claude --dangerously-skip-permissions'); });