Merge #770 fix(remote): stop the local shell expanding the remote launch (exit 127 since 1.47.0)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-06 05:00:18 +01:00
commit 9e9031214e
14 files changed
+332 -47

No files matched your search

+1 -1
View File
@@ -392,7 +392,7 @@ export function isAdoptedContainer(docker: Pick<SessionDocker, 'owned'>): boolea
/**
* POSIX single-quote shell-escaping (end-quote, escaped-quote, restart-quote).
* Mirror of the helper in remote-hosts.ts / tmux-manager.ts. Every dynamic value
* interpolated into the outer `bash -c "..."` launch layer is escaped through
* interpolated into the `bash -c` launch layer is escaped through
* this so a path with spaces stays a single shell token. Operator-entered fields
* are ALSO schema-rejected for `$`/backtick (NO_SHELL_META) as defense in depth.
*/
+14 -10
View File
@@ -66,10 +66,11 @@ export async function writeRemoteCases(configDir: string, cases: RemoteCase[]):
* arrival, the #208 failure all over again (verified: `sh -c 'exec $SHELL -i -l'`
* with SHELL unset prints `exec: -i: not found`). In practice tmux always exports
* SHELL into a pane from its own `default-shell` option, so the command as USED
* here is safe either way (also verified). The default matters because these
* strings are the seed values a per-host `commands.*` override is edited from, and
* nothing constrains where an edited one ends up running. Quoted for a shell path
* containing spaces. `/bin/sh` exists on every POSIX host.
* here is safe either way (also verified). Quoted for a shell path containing
* spaces. `/bin/sh` exists on every POSIX host.
*
* ⚠️ This is POSIX text. fish refuses `${…}` (status 127), so a launch hands it to
* `/bin/sh -c`, never to the remote default-shell (`buildRemoteLaunchCommand`).
*/
const REMOTE_LOGIN_SHELL = '"${SHELL:-/bin/sh}"';
@@ -80,10 +81,12 @@ 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 (nushell, elvish, …) 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.
* 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.
*/
export function remoteLoginShellCommand(command: string): string {
return `exec ${REMOTE_LOGIN_SHELL} -i -l -c ${shellescape(command)}`;
@@ -143,8 +146,9 @@ function overlayCliCommand(mode: SessionMode, location: 'remote' | 'docker'): st
* The per-host `commands.*` override remains the escape hatch for every mode.
*/
export function defaultRemoteCommandForMode(mode: SessionMode): string {
// $SHELL, not a hardcoded bash: sshd sets it from the remote user's /etc/passwd entry, so
// this launches their actual login shell (zsh, fish, …). `-i -l` so it sources rc files,
// $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`;
+49 -19
View File
@@ -1284,12 +1284,15 @@ export function remoteTmuxLocation(remote: SessionRemote, sessionId: string): {
*
* Emits:
* ssh -o BatchMode=yes -t [<COD-107 connection opts>] user@host \
* 'tmux -L codeman-remote new-session -A -s codeman-ssh-<id> -c <path> "cd <path> && exec <cli>" \
* 'tmux -L codeman-remote new-session -A -s codeman-ssh-<id> -c <path> /bin/sh -c <pane-command> \
* \; set -t codeman-ssh-<id> status off \; set -t codeman-ssh-<id> mouse off \
* \; set -t codeman-ssh-<id> prefix C-q \; set -s escape-time 0 \; \
* bind-key -T prefix C-q send-prefix \; unbind-key -a -T prefix \; \
* bind-key -T prefix C-q send-prefix \; set -t codeman-ssh-<id> remain-on-exit failed'
*
* `<pane-command>` is `cd <path> && exec <cli>`, quoted as one token. A per-host `commands.*`
* override is that one token alone, with no `/bin/sh -c` in front of it.
*
* COD-107 — the connection options (`-p`, `-i`, `-J`, SOCKS `-o ProxyCommand`,
* arbitrary `-o`) come from the shared `buildSshConnectionArgs(remote)`, so the
* prereq tmux probe and this launch connect with identical options.
@@ -1303,10 +1306,12 @@ export function remoteTmuxLocation(remote: SessionRemote, sessionId: string): {
* - The `set` options are scoped per-session (`set -t <name>` / server-level
* `set -s`), never `-g`, so they never mutate other sessions' prefix/mouse.
* - The whole tmux invocation is a SINGLE ssh argument (the remote login shell
* runs it), so it is shell-quoted as one unit; the `cd && exec` command is in
* turn a single tmux argument (tmux runs it via `/bin/sh -c`), so the path is
* shell-quoted inside it too. This keeps escaping correct through every layer
* even when the remote path contains spaces.
* runs it), so it is shell-quoted as one unit. The `cd && exec` command is the
* pane command: Codeman's own text goes to `/bin/sh -c` as separate tmux
* arguments, and a per-host `commands.*` override stays one argument, which
* tmux hands to the remote default-shell (see the comment at `paneCommand`).
* Each is shell-quoted, so escaping stays correct through every layer even
* when the remote path contains spaces.
*/
export function buildRemoteLaunchCommand(options: {
mode: SessionMode;
@@ -1389,17 +1394,25 @@ export function buildRemoteLaunchCommand(options: {
}
const remoteName = remoteTmuxSessionName(sessionId);
// Innermost: the command tmux runs in the new pane. Run via `/bin/sh -c` by
// tmux, so the path needs shell-quoting here. `exec` replaces the shell with
// the CLI so the pane PID is the agent itself.
// Innermost: the command tmux runs in the new pane. `exec` replaces the shell
// with the CLI so the pane PID is the agent itself.
//
// tmux runs a ONE-argument command through the remote default-shell, which is
// the user's login shell, and bypasses it for two or more arguments (both
// measured on tmux 3.4). Codeman's own text is POSIX (`"${SHELL:-/bin/sh}"` is
// a parse error in fish, status 127, a dead pane on every new session), so it
// goes to `/bin/sh -c` as separate arguments and no login shell has to parse it.
// An override is the user's own text for THEIR shell: it stays one argument,
// because `source` and `[[` work in bash and zsh and die in a dash `/bin/sh`.
const paneCommand = `cd ${shellescape(remote.remotePath)} && ${modeCommand}`;
const paneArgv = `${override ? '' : '/bin/sh -c '}${shellescape(paneCommand)}`;
// The tmux command line, with `\;` separating commands so the config `set`s
// apply on the SAME connection (and are idempotent on reattach). Options are
// scoped per-session (`set -t <name>` / server `set -s`), NEVER `-g`, so a
// shared remote tmux server's other sessions keep their own prefix/mouse.
const tmuxInvocation = [
`tmux -L ${REMOTE_TMUX_SOCKET} new-session -A -s ${remoteName} -c ${shellescape(remote.remotePath)} ${shellescape(paneCommand)}`,
`tmux -L ${REMOTE_TMUX_SOCKET} new-session -A -s ${remoteName} -c ${shellescape(remote.remotePath)} ${paneArgv}`,
`set -t ${remoteName} status off`,
`set -t ${remoteName} mouse off`,
`set -t ${remoteName} prefix ${NESTED_TMUX_PREFIX}`,
@@ -1574,7 +1587,7 @@ export interface DockerLaunchOptions {
* Build the ONE `bash -c` launch string for a docker session: image-check ->
* ensure (inspect-or-create) -> start -> `exec docker exec -it` into the durable
* in-container tmux (resume-aware). PURE and unit-testable. The escaping survives
* four layers: outer `bash -c "…"` (JSON.stringify at respawn-pane) -> the joined
* four layers: outer `bash -c '…'` (`bashCArg()` at respawn-pane) -> the joined
* command -> `docker exec … sh -lc '<tmux>'` -> tmux `'<paneCommand>'`.
*/
export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
@@ -1602,8 +1615,8 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
} else if (resumeSessionId) {
modeCommand = appendResumeFlag(modeCommand, mode, resumeSessionId);
}
// Run by tmux via /bin/sh -c, so the path is shell-quoted here. `exec` makes the
// pane PID the agent itself.
// Run by tmux through the container's default-shell, so the path is shell-quoted
// here. `exec` makes the pane PID the agent itself.
const paneCommand = `cd ${workdir} && ${modeCommand}`;
// `setenv -g` primes the session id so reattaches / newly-created panes inherit
@@ -1665,11 +1678,10 @@ export function buildDockerLaunchCommand(opts: DockerLaunchOptions): string {
const ensure = adopted
? `${base} inspect ${name} >/dev/null 2>&1 || { echo ${notFoundMsg}; exit 1; }`
: `${base} inspect ${name} >/dev/null 2>&1 || ${createCommand}`;
// ⚠️ No double quotes and no `$(…)` in the ADOPTED arms. This whole chain is
// embedded in an outer `bash -c "…"`, so an unescaped `"` closes that string early,
// the rest is re-tokenized, and tmux fails to exec with a bare `execvp(3) failed`.
// A `grep -qx` pipeline reads the same answer using only the single-quoted form
// every other line in this builder already uses.
// A `grep -qx` pipeline, in the single-quoted form every other line in this builder
// uses. This chain reaches `bash -c` verbatim now (`bashCArg()`), but it went through
// a double-quoted layer for a long time, where a `"` closed that string early and tmux
// failed with a bare `execvp(3) failed`, so keep it free of double quotes and `$(…)`.
const start = adopted
? `${base} inspect -f ${shellescape('{{.State.Running}}')} ${name} 2>/dev/null | grep -qx true || { echo ${notRunningMsg}; exit 1; }`
: `${base} start ${name} >/dev/null 2>&1 || { echo ${startFailMsg}; exit 1; }`;
@@ -2661,7 +2673,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// pane launch in /tmp, then cd inside bash against the current mount table.
const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
execSync(
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${bashCArg(launchCmd, !!(remote || docker))}`,
{
timeout: EXEC_TIMEOUT_MS,
stdio: 'ignore',
@@ -2914,7 +2926,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// -c /tmp + cd bounce — see createSession() for rationale (stale FUSE state).
const launchCmd = remote || docker ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
await execAsync(
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${bashCArg(launchCmd, !!(remote || docker))}`,
{
timeout: EXEC_TIMEOUT_MS,
}
@@ -4663,6 +4675,24 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
}
}
/**
* The `bash -c` argument of a pane launch (`respawn-pane … bash -c <arg>`, run through the
* local sh). A remote or docker command is already quoted for the shell at the far end and
* must reach bash verbatim, so it goes in single quotes. Double quotes (JSON.stringify) let
* the local sh expand every `$` first: the PATH bootstrap of `withRemoteTmuxPath`
* (`$HOME/.local/bin:…:$PATH`) arrived on the remote host as this host's home and PATH, so
* a remote user with another home found no tmux and every new remote pane exited 127.
* `${SHELL:-/bin/sh}` in the remote launch was the local shell for the same reason.
* Local commands keep their double-quoted form.
*
* ⚠️ So nothing in a verbatim launch is expanded on this host any more, the docker client's
* own arguments included: a bare `$VAR` in `extraCreateArgs`/`extraExecArgs` reaches docker
* as those literal characters.
*/
function bashCArg(launchCmd: string, verbatim: boolean): string {
return verbatim ? shellescape(launchCmd) : JSON.stringify(launchCmd);
}
/**
* Shell-escape a string for use as a single argument.
* Wraps in single quotes, escaping any embedded single quotes.
+11 -9
View File
@@ -867,6 +867,8 @@ const RemoteCommandOverridesSchema = z
pi: z.string().min(1).max(300).optional(),
grok: z.string().min(1).max(300).optional(),
deepseek: z.string().min(1).max(300).optional(),
omp: z.string().min(1).max(300).optional(),
copilot: z.string().min(1).max(300).optional(),
})
.strict()
.optional();
@@ -878,11 +880,11 @@ const RemoteCommandOverridesSchema = z
const NO_SHELL_INJECTION = /^[^\n\r\0`]*$/;
const noCommandSubstitution = (s: string) => !s.includes('$(');
// `remotePath`/`identityFile` are shell-escaped, then the whole launch command is
// embedded via `JSON.stringify(...)` inside `bash -c "..."` (tmux-manager). That
// outer DOUBLE-quote layer re-exposes `$(...)`, backticks, and `$VAR` even though
// the inner value is single-quoted — so a `$(cmd)` in the path would run LOCALLY at
// launch. Reject `$` and backtick (and newline/CR/NUL) entirely at the boundary.
// `remotePath`/`identityFile` are shell-escaped, and a remote launch reaches `bash -c`
// single-quoted (`bashCArg()` in tmux-manager). They are also pasted into strings the
// far end's shells parse, and hosts stored before a launch-quoting change are read with
// no schema, so `$` and backtick (and newline/CR/NUL) stay rejected at the boundary as
// defense in depth, whatever the current quoting does with them.
const NO_SHELL_META = /^[^\n\r\0`$]*$/;
export const RemoteHostSchema = z.object({
@@ -982,10 +984,10 @@ export const RemoteCaseLinkSchema = z.object({
//
// Docker mode is a location overlay on cases (see docs/docker-cases-plan.md),
// the analog of the remote-SSH schemas above. `image`, `hostWorkspacePath`,
// `containerWorkdir`, and `container` all reach the outer `bash -c "..."` launch
// layer, so they carry NO_SHELL_META (rejects `$`/backtick that survive the
// double-quote layer) exactly like remotePath/identityFile. `--privileged` and
// any docker-socket mount are structurally unrepresentable (never accepted).
// `containerWorkdir`, and `container` all reach the `bash -c` launch layer, so they
// carry NO_SHELL_META (rejects `$`/backtick) exactly like remotePath/identityFile.
// `--privileged` and any docker-socket mount are structurally unrepresentable
// (never accepted).
const DockerResourceLimitsSchema = z
.object({