mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
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:
1 parent
cd0bbc3e3a
commit
5559aa8294
18 files changed
+161
-56
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+14
-11
@@ -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 <path> && { …; }` 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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\'; }'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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\'; }'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
|
||||
@@ -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\'; }'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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`,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user