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

+3 -1
View File
@@ -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\'; }'
);
});
});
+3 -1
View File
@@ -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\'; }'
);
});
});
+1 -3
View File
@@ -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<string, string> = {
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'),
+3 -1
View File
@@ -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\'; }'
);
});
});
+3 -1
View File
@@ -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)', () => {
+7 -3
View File
@@ -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(
+74 -1
View File
@@ -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([]);
});
});
+2 -1
View File
@@ -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`,
+2 -2
View File
@@ -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');
});