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 new file mode 100644 index 00000000..a1d1acde --- /dev/null +++ b/src/remote-hosts.ts @@ -0,0 +1,223 @@ +import { existsSync, mkdirSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import { join } from 'node:path'; +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 execAsync = promisify(exec); + +const REMOTE_HOSTS_FILE = 'remote-hosts.json'; +const REMOTE_CASES_FILE = 'remote-cases.json'; + +export function remoteHostsPath(configDir: string): string { + return join(configDir, REMOTE_HOSTS_FILE); +} + +export function remoteCasesPath(configDir: string): string { + return join(configDir, REMOTE_CASES_FILE); +} + +async function readJsonArray(path: string): Promise { + try { + const raw = await fs.readFile(path, 'utf-8'); + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as T[]) : []; + } catch { + return []; + } +} + +async function writeJsonArray(configDir: string, path: string, value: T[]): Promise { + if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true }); + await fs.writeFile(path, JSON.stringify(value, null, 2)); +} + +export async function readRemoteHosts(configDir: string): Promise { + return readJsonArray(remoteHostsPath(configDir)); +} + +export async function writeRemoteHosts(configDir: string, hosts: RemoteHost[]): Promise { + await writeJsonArray(configDir, remoteHostsPath(configDir), hosts); +} + +export async function readRemoteCases(configDir: string): Promise { + return readJsonArray(remoteCasesPath(configDir)); +} + +export async function writeRemoteCases(configDir: string, cases: RemoteCase[]): Promise { + await writeJsonArray(configDir, remoteCasesPath(configDir), cases); +} + +export function defaultRemoteCommandForMode(mode: SessionMode): string { + const commands: Record = { + shell: 'exec bash -l', + claude: 'exec claude', + opencode: 'exec opencode', + codex: 'exec codex', + gemini: 'exec gemini', + }; + return commands[mode as RemoteCommandMode] || commands.shell; +} + +export function remoteSshTarget(host: Pick): string { + 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 ] (shellescaped, single token) + * [-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 ${shellescape(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 { + const path = 'remotePath' in remote ? remote.remotePath : remote.path; + return `${remote.username}@${remote.host}:${path}`; +} + +export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): SessionRemote { + return { + hostId: host.id, + label: host.label, + host: host.host, + username: host.username, + 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/session.ts b/src/session.ts index daeb1667..839053b1 100644 --- a/src/session.ts +++ b/src/session.ts @@ -49,6 +49,7 @@ import { type CodexConfig, type EffortLevel, type GeminiConfig, + type SessionRemote, } from './types.js'; import type { TerminalMultiplexer, MuxSession } from './mux-interface.js'; import { TaskTracker, type BackgroundTask } from './task-tracker.js'; @@ -209,6 +210,10 @@ export function queryTmuxWindowSize(muxName: string, socket: string): { cols: nu return { cols: DEFAULT_PTY_COLS, rows: DEFAULT_PTY_ROWS }; } +export function resolveMuxAttachCwd(workingDir: string, remote?: SessionRemote): string { + return remote ? '/tmp' : workingDir; +} + /** * Represents a JSON message from Claude CLI's stream-json output format. * Messages are newline-delimited JSON objects parsed from PTY output. @@ -385,6 +390,9 @@ export class Session extends EventEmitter { // tmux history-limit (scrollback lines) applied to this session's pane. private readonly _tmuxHistoryLimit: number; + // Remote execution metadata, present when this session runs over SSH through local tmux. + private readonly _remote?: SessionRemote; + // Session color for visual differentiation private _color: import('./types.js').SessionColor = 'default'; @@ -456,6 +464,8 @@ export class Session extends EventEmitter { tmuxHistoryLimit?: number; /** Restored per-session attachment history. May include server-private external paths. */ attachmentHistory?: SessionAttachmentHistoryItem[]; + /** Remote execution metadata for sessions launched through SSH inside local tmux. */ + remote?: SessionRemote; } ) { super(); @@ -528,6 +538,7 @@ export class Session extends EventEmitter { this._effort = config.effort; } this._tmuxHistoryLimit = config.tmuxHistoryLimit ?? DEFAULT_TMUX_HISTORY_LIMIT; + this._remote = config.remote; if (config.attachmentHistory && config.attachmentHistory.length > 0) { this.restoreAttachmentHistory(config.attachmentHistory); } @@ -987,6 +998,7 @@ export class Session extends EventEmitter { pid: this.pid, status: this._status, workingDir: this.workingDir, + remote: this._remote, currentTaskId: this._currentTaskId, createdAt: this.createdAt, lastActivityAt: this._lastActivityAt, @@ -1171,7 +1183,7 @@ export class Session extends EventEmitter { name: 'xterm-256color', cols: ptyCols, rows: ptyRows, - cwd: this.workingDir, + cwd: resolveMuxAttachCwd(this.workingDir, this._remote), env: buildMuxAttachEnv(), }); } catch (spawnErr) { @@ -1282,6 +1294,7 @@ export class Session extends EventEmitter { envOverrides: this._envOverrides, effort: this._effort, historyLimit: this._tmuxHistoryLimit, + remote: this._remote, }, createSessionOptions: { sessionId: this.id, @@ -1299,6 +1312,7 @@ export class Session extends EventEmitter { envOverrides: this._envOverrides, effort: this._effort, historyLimit: this._tmuxHistoryLimit, + remote: this._remote, }, spawnErrLabel: 'mux attachment', }); @@ -1637,6 +1651,7 @@ export class Session extends EventEmitter { niceConfig: this._niceConfig, envOverrides: this._envOverrides, historyLimit: this._tmuxHistoryLimit, + remote: this._remote, }, createSessionOptions: { sessionId: this.id, @@ -1646,6 +1661,7 @@ export class Session extends EventEmitter { niceConfig: this._niceConfig, envOverrides: this._envOverrides, historyLimit: this._tmuxHistoryLimit, + remote: this._remote, }, spawnErrLabel: 'shell mux attachment', }); diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 0af5caa8..0024f35f 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -42,8 +42,10 @@ import { type CodexConfig, type EffortLevel, type GeminiConfig, + type SessionRemote, } from './types.js'; import { buildEffortCliArgs } from './session-cli-builder.js'; +import { buildSshConnectionArgs, defaultRemoteCommandForMode, remoteSshTarget } from './remote-hosts.js'; import { wrapWithNice, SAFE_PATH_PATTERN, @@ -669,6 +671,78 @@ function buildSpawnCommand(options: { return '$SHELL'; } +/** + * 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 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(' '); +} + /** * Set sensitive environment variables on a tmux session via setenv. * These are inherited by panes but not visible in ps output or tmux history. @@ -1059,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)}`; @@ -1077,6 +1152,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { pid: 99999, createdAt: Date.now(), workingDir, + remote, mode, attached: false, name, @@ -1121,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: @@ -1172,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)}`, { @@ -1245,6 +1322,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { pid, createdAt: Date.now(), workingDir, + remote, mode, attached: false, name, @@ -1329,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; @@ -1365,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 @@ -1382,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/api.ts b/src/types/api.ts index 23a97594..b7201022 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -123,6 +123,17 @@ export interface CaseInfo { path: string; /** Whether CLAUDE.md exists */ hasClaudeMd?: boolean; + /** Case storage/execution location */ + location?: 'local' | 'linked-local' | 'remote'; + /** Whether this is a linked local folder */ + linked?: boolean; + /** Remote case metadata for display and session creation */ + remote?: { + hostId: string; + host: string; + username: string; + path: string; + }; } // ========== Error Handling Utilities ========== diff --git a/src/types/session.ts b/src/types/session.ts index 460f6e67..9b01ec99 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -43,6 +43,61 @@ export type ClaudeMode = 'dangerously-skip-permissions' | 'normal' | 'allowedToo /** Session mode: which CLI backend a session runs */ export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini'; +export type RemoteCommandMode = Extract; + +/** + * 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; + username: string; + port?: number; + commands?: Partial>; +} + +export interface RemoteCase { + name: string; + type: 'remote'; + hostId: string; + remotePath: string; +} + +export interface SessionRemote extends RemoteSshOptions { + hostId: string; + label: string; + host: string; + username: string; + port?: number; + remotePath: string; + commands?: Partial>; +} + /** * Valid Claude CLI effort levels (claude >= 2.1.154). * `ultracode` = xhigh effort + standing dynamic-workflow orchestration; it is a @@ -160,6 +215,8 @@ export interface SessionState { status: SessionStatus; /** Working directory path */ workingDir: string; + /** Remote execution metadata, present when this session runs over SSH through local tmux */ + remote?: SessionRemote; /** ID of currently assigned task, null if none */ currentTaskId: string | null; /** Timestamp when session was created */ diff --git a/src/web/public/index.html b/src/web/public/index.html index 3f6edd2d..7a3974c6 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1649,6 +1649,66 @@

Add Case

Absolute path to an existing project folder, e.g. /home/you/my-project + +