diff --git a/src/mux-interface.ts b/src/mux-interface.ts index 8c053c84..3537e24f 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -17,6 +17,7 @@ import type { CodexConfig, EffortLevel, GeminiConfig, + SessionRemote, } from './types.js'; /** @@ -33,6 +34,8 @@ export interface MuxSession { createdAt: number; /** Working directory */ workingDir: string; + /** Remote execution metadata for local tmux sessions wrapping SSH */ + remote?: SessionRemote; /** Session mode */ mode: SessionMode; /** Whether webserver is attached to this session */ @@ -74,6 +77,8 @@ export interface CreateSessionOptions { effort?: EffortLevel; /** tmux history-limit (scrollback lines) to set for this session. */ historyLimit?: number; + /** Remote execution metadata for local tmux sessions wrapping SSH */ + remote?: SessionRemote; } /** Options for respawning a dead pane. */ @@ -96,6 +101,8 @@ export interface RespawnPaneOptions { effort?: EffortLevel; /** tmux history-limit (scrollback lines) to set for this session after respawn. */ historyLimit?: number; + /** Remote execution metadata for local tmux sessions wrapping SSH */ + remote?: SessionRemote; } /** diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index 1272beea..eb1b1c3d 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -1,7 +1,17 @@ import { existsSync, mkdirSync } from 'node:fs'; import fs from 'node:fs/promises'; import { join } from 'node:path'; -import type { RemoteCase, RemoteCommandMode, RemoteHost, SessionMode, SessionRemote } from './types.js'; +import { homedir } from 'node:os'; +import { exec } from 'node:child_process'; +import { promisify } from 'node:util'; +import type { + RemoteCase, + RemoteCommandMode, + RemoteHost, + RemoteSshOptions, + SessionMode, + SessionRemote, +} from './types.js'; const REMOTE_HOSTS_FILE = 'remote-hosts.json'; const REMOTE_CASES_FILE = 'remote-cases.json'; @@ -60,6 +70,131 @@ export function remoteSshTarget(host: Pick): st return `${host.username}@${host.host}`; } +/** + * POSIX single-quote shell-escaping (end-quote, escaped-quote, restart-quote). + * Mirrors the helper in tmux-manager.ts so a value with spaces/metachars stays a + * single shell token. Used here for identity paths and `-o KEY=VALUE` options. + */ +function shellescape(str: string): string { + return "'" + str.replace(/'/g, "'\\''") + "'"; +} + +/** + * Expand a leading `~` or `$HOME` in an identity path to an absolute path. + * + * ssh does NOT expand `~` inside `-i` (the shell would, but we shellescape the + * value into a single quoted token so the shell never sees it). So we expand at + * build time, before escaping. Non-`~`/`$HOME` paths are returned unchanged. + */ +function expandIdentityPath(identityFile: string): string { + if (identityFile === '~') return homedir(); + if (identityFile.startsWith('~/')) return join(homedir(), identityFile.slice(2)); + if (identityFile === '$HOME') return homedir(); + if (identityFile.startsWith('$HOME/')) return join(homedir(), identityFile.slice('$HOME/'.length)); + return identityFile; +} + +/** + * COD-107 — build the ordered, shell-safe ssh CONNECTION tokens shared by both + * the durable-launch command (`buildRemoteLaunchCommand`) and the tmux + * prerequisite probe (`buildRemoteTmuxCheckCommand`), so the prereq check and + * the real launch connect with IDENTICAL options (they can't drift). + * + * Returns the leading tokens of an ssh command line (NOT including `-t`, the + * target, or any remote command). Order: + * ssh -o BatchMode=yes + * [-p ] + * [-i ] (~/$HOME expanded, then shellescaped) + * [-J ] + * [-o ProxyCommand=nc -X 5 -x %h %p] (ONE shellescaped -o token) + * [-o ] … (each extra option, shellescaped) + * + * Escaping notes (the risky part): + * - The ProxyCommand is emitted as a single shellescaped `-o KEY=VALUE`, so the + * whole value (spaces + `%h`/`%p`) reaches ssh as one argument and `%h %p` + * survive verbatim — ssh expands them to the real host/port, not the shell. + * - Empty options ⇒ `['ssh', '-o BatchMode=yes']` (+ `-p` only when set), i.e. + * byte-identical to the historical behavior. + */ +export function buildSshConnectionArgs(remote: RemoteSshOptions & Pick): string[] { + const parts: string[] = ['ssh', '-o BatchMode=yes']; + if (remote.port) parts.push(`-p ${remote.port}`); + if (remote.identityFile) parts.push(`-i ${shellescape(expandIdentityPath(remote.identityFile))}`); + if (remote.jumpHost) parts.push(`-J ${remote.jumpHost}`); + if (remote.socksProxy) { + parts.push(`-o ${shellescape(`ProxyCommand=nc -X 5 -x ${remote.socksProxy} %h %p`)}`); + } + for (const opt of remote.extraSshOptions ?? []) { + parts.push(`-o ${shellescape(opt)}`); + } + return parts; +} + +/** + * COD-104 — build the SSH command that checks the remote host has tmux. + * + * Durable remote sessions run the agent inside a tmux server ON the remote host + * (`tmux -L codeman new-session -A …`), so tmux is now a hard prerequisite there. + * `command -v tmux` exits 0 (and prints the path) when tmux is installed. + * + * COD-107 — connects with the SAME options as the real launch + * (`buildSshConnectionArgs`) so a proxied/custom-port/identity host that the + * launch can reach also passes the prereq probe (and vice-versa). + */ +export function buildRemoteTmuxCheckCommand( + host: Pick & RemoteSshOptions +): string { + const [ssh, ...connectionArgs] = buildSshConnectionArgs(host); + const parts = [ssh, connectionArgs[0], '-o ConnectTimeout=10', ...connectionArgs.slice(1)]; + parts.push(remoteSshTarget(host), "'command -v tmux'"); + return parts.join(' '); +} + +export interface RemoteTmuxCheckResult { + ok: boolean; + /** Resolved tmux path on the remote (when ok). */ + tmuxPath?: string; + /** Human-readable failure reason (when !ok). */ + error?: string; +} + +/** + * COD-104 — verify the remote host has tmux installed (required for durable + * remote sessions). Returns a structured result with a clear, user-facing error + * when tmux is missing or the host is unreachable. Never throws. + */ +export async function checkRemoteTmuxAvailable( + host: Pick & RemoteSshOptions +): Promise { + const command = buildRemoteTmuxCheckCommand(host); + try { + const { stdout } = await execAsync(command, { timeout: 15_000 }); + const tmuxPath = stdout.trim(); + if (!tmuxPath) { + return { + ok: false, + error: `remote host ${host.host} needs tmux installed for durable remote sessions`, + }; + } + return { ok: true, tmuxPath }; + } catch (err) { + const stderr = + err && typeof err === 'object' && 'stderr' in err ? String((err as { stderr?: unknown }).stderr ?? '') : ''; + // `command -v tmux` exits non-zero when tmux is absent (no stderr); a real + // connection failure surfaces ssh diagnostics on stderr. + if (stderr.trim()) { + return { + ok: false, + error: `could not verify tmux on remote host ${host.host}: ${stderr.trim()}`, + }; + } + return { + ok: false, + error: `remote host ${host.host} needs tmux installed for durable remote sessions`, + }; + } +} + export function remoteDisplayPath( remote: Pick | { username: string; host: string; path: string } ): string { @@ -76,5 +211,11 @@ export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): Sessi port: host.port, remotePath: remoteCase.remotePath, commands: host.commands, + // COD-107 — carry the advanced SSH options from host config into the session + // so the launch/prereq commands connect the same way the operator configured. + identityFile: host.identityFile, + socksProxy: host.socksProxy, + jumpHost: host.jumpHost, + extraSshOptions: host.extraSshOptions, }; } diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 8f5c52ed..0024f35f 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -45,7 +45,7 @@ import { type SessionRemote, } from './types.js'; import { buildEffortCliArgs } from './session-cli-builder.js'; -import { defaultRemoteCommandForMode, remoteSshTarget } from './remote-hosts.js'; +import { buildSshConnectionArgs, defaultRemoteCommandForMode, remoteSshTarget } from './remote-hosts.js'; import { wrapWithNice, SAFE_PATH_PATTERN, @@ -671,14 +671,76 @@ function buildSpawnCommand(options: { return '$SHELL'; } -export function buildRemoteLaunchCommand(options: { mode: SessionMode; remote: SessionRemote }): string { - const { mode, remote } = options; +/** + * Deterministic, reattach-stable remote tmux session name for a Codeman session. + * + * Derived from the same stable field the LOCAL muxName uses + * (`codeman-${sessionId.slice(0, 8)}`), so reconnecting (which re-issues the + * exact same `ssh … new-session -A`) lands back in the SAME remote session. + * Must NOT be random/time-based — it has to be stable across reconnects. + */ +export function remoteTmuxSessionName(sessionId: string): string { + return `codeman-${sessionId.slice(0, 8)}`; +} + +/** + * COD-104 — build the SSH command that launches (or reattaches) a remote + * session INSIDE a tmux server on the remote host, so the remote agent survives + * an SSH drop. + * + * Emits: + * ssh -o BatchMode=yes -t [] user@host \ + * 'tmux -L codeman new-session -A -s codeman- -c "cd && exec " \ + * \; set -g status off \; set -g mouse off \; set -sg escape-time 0 \; set -g prefix C-q' + * + * 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. + * + * - `new-session -A -s codeman-` = attach-if-exists-else-create (idempotent), + * so reconnect re-runs the same command and reattaches the still-running agent. + * - `-L codeman` = canonical remote socket (for Phase 2/3 discovery). + * - 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. + */ +export function buildRemoteLaunchCommand(options: { + mode: SessionMode; + remote: SessionRemote; + sessionId: string; +}): string { + const { mode, remote, sessionId } = options; const modeCommand = remote.commands?.[mode] || defaultRemoteCommandForMode(mode); - const args = ['ssh', '-o', 'BatchMode=yes', '-t']; - if (remote.port) args.push('-p', String(remote.port)); - const remoteCommand = `cd ${shellescape(remote.remotePath)} && ${modeCommand}`; - args.push(remoteSshTarget(remote), `bash -lc ${shellescape(remoteCommand)}`); - return args.map((arg) => shellescape(arg)).join(' '); + 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. + const paneCommand = `cd ${shellescape(remote.remotePath)} && ${modeCommand}`; + + // The tmux command line, with `\;` separating commands so the config `set`s + // apply on the SAME connection (and are idempotent on reattach). + const tmuxInvocation = [ + `tmux -L codeman new-session -A -s ${remoteName} -c ${shellescape(remote.remotePath)} ${shellescape(paneCommand)}`, + 'set -g status off', + 'set -g mouse off', + 'set -sg escape-time 0', + 'set -g prefix C-q', + ].join(' \\; '); + + // ssh runs its trailing args through the remote login shell, so the entire + // tmux invocation is passed as one shell-quoted argument. + // + // COD-107 — connection options (port, identity, SOCKS ProxyCommand, jump host, + // arbitrary -o) come from the shared `buildSshConnectionArgs` so the launch and + // the tmux-prereq probe connect IDENTICALLY. `-t` is inserted right after + // `ssh -o BatchMode=yes` (preserving the historical token order), then the rest + // of the connection args, then the target and the quoted tmux invocation. + const [ssh, batchMode, ...connectionArgs] = buildSshConnectionArgs(remote); + const sshParts = [ssh, batchMode, '-t', ...connectionArgs, remoteSshTarget(remote), shellescape(tmuxInvocation)]; + return sshParts.join(' '); } /** @@ -1071,6 +1133,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { envOverrides, effort, historyLimit = DEFAULT_TMUX_HISTORY_LIMIT, + remote, } = options; const muxName = `codeman-${sessionId.slice(0, 8)}`; @@ -1089,6 +1152,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { pid: 99999, createdAt: Date.now(), workingDir, + remote, mode, attached: false, name, @@ -1133,7 +1197,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { try { // Build the full command to run inside tmux - const fullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`; + const localFullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`; + const fullCmd = remote ? buildRemoteLaunchCommand({ mode, remote, sessionId }) : localFullCmd; // Create tmux session in three steps to handle cold-start (no server running) // and avoid the race where the command exits before remain-on-exit is set: @@ -1184,7 +1249,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // Replace the shell with the actual command (no echo in terminal). Keep // pane launch in /tmp, then cd inside bash against the current mount table. - const launchCmd = `cd ${JSON.stringify(workingDir)} && ${fullCmd}`; + const launchCmd = remote ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`; execSync( `${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`, { @@ -1257,6 +1322,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { pid, createdAt: Date.now(), workingDir, + remote, mode, attached: false, name, @@ -1341,6 +1407,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { envOverrides, effort, historyLimit = DEFAULT_TMUX_HISTORY_LIMIT, + remote, } = options; const session = this.sessions.get(sessionId); if (!session) return null; @@ -1377,7 +1444,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { }); const config = niceConfig || DEFAULT_NICE_CONFIG; const cmd = wrapWithNice(baseCmd, config); - const fullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`; + const localFullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`; + const fullCmd = remote ? buildRemoteLaunchCommand({ mode, remote, sessionId }) : localFullCmd; try { // For OpenCode: set sensitive env vars via tmux setenv before respawn @@ -1394,7 +1462,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // Re-apply user env overrides before respawn so the new shell inherits them. this.applyEnvOverrides(muxName, envOverrides); - const launchCmd = `cd ${JSON.stringify(workingDir)} && ${fullCmd}`; + // -c /tmp + cd bounce — see createSession() for rationale (stale FUSE state). + const launchCmd = remote ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`; await execAsync( `${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`, { diff --git a/src/types/session.ts b/src/types/session.ts index c10f7b27..9b01ec99 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -45,7 +45,34 @@ export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini'; export type RemoteCommandMode = Extract; -export interface RemoteHost { +/** + * Advanced SSH connection options shared by RemoteHost and SessionRemote. + * + * COD-107 — all fields are optional; every field absent reproduces today's + * behavior (port-22, default-identity, directly-SSH-able hosts). These describe + * HOW Codeman reaches the host (identity, proxy, jump host, arbitrary `-o`), + * letting it connect to e.g. a host fronted by a cloudflared SOCKS5 proxy on a + * custom port — the same connection `ssh-aa-desktop` makes — without a wrapper. + */ +export interface RemoteSshOptions { + /** + * Path to an SSH identity (private key) file — path ONLY, never key bytes. + * A leading `~`/`$HOME` is expanded to an absolute path at command-build time + * (ssh does not expand `~` in `-i`). + */ + identityFile?: string; + /** + * SOCKS5 proxy as `host:port` (e.g. `127.0.0.1:1080`). Expands to + * `-o ProxyCommand=nc -X 5 -x %h %p` (the cloudflared/SOCKS5 case). + */ + socksProxy?: string; + /** SSH jump host (`[user@]host[:port]`) emitted as `-J `. */ + jumpHost?: string; + /** Arbitrary additional `-o KEY=VALUE` options (escape hatch). Each `KEY=VALUE`. */ + extraSshOptions?: string[]; +} + +export interface RemoteHost extends RemoteSshOptions { id: string; label: string; host: string; @@ -61,7 +88,7 @@ export interface RemoteCase { remotePath: string; } -export interface SessionRemote { +export interface SessionRemote extends RemoteSshOptions { hostId: string; label: string; host: string; diff --git a/src/web/public/index.html b/src/web/public/index.html index bf699c72..7a3974c6 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1673,11 +1673,41 @@ +
+ + + Optional. Leave blank for the default port 22. +
Optional. Leave blank to use exec codex on the remote host.
+
+ Advanced SSH +
+
+ + + Optional. Path to a private key on this machine (passed to ssh -i). Never the key contents. +
+
+ + + Optional. host:port of a SOCKS5 proxy (e.g. cloudflared). Routes ssh through it via a ProxyCommand. +
+
+ + + Optional. [user@]host[:port] for ssh -J (jump/bastion host). +
+
+ + + Optional. One KEY=VALUE per line; each becomes an ssh -o option. +
+
+