mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
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:
commit
9e9031214e
14 files changed
+332
-47
No files matched your search
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user