mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39: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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user