Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/mux-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
CodexConfig,
EffortLevel,
GeminiConfig,
SessionRemote,
} from './types.js';

/**
Expand All @@ -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 */
Expand Down Expand Up @@ -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. */
Expand All @@ -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;
}

/**
Expand Down
223 changes: 223 additions & 0 deletions src/remote-hosts.ts
Original file line number Diff line number Diff line change
@@ -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<T>(path: string): Promise<T[]> {
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<T>(configDir: string, path: string, value: T[]): Promise<void> {
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true });
await fs.writeFile(path, JSON.stringify(value, null, 2));
}

export async function readRemoteHosts(configDir: string): Promise<RemoteHost[]> {
return readJsonArray<RemoteHost>(remoteHostsPath(configDir));
}

export async function writeRemoteHosts(configDir: string, hosts: RemoteHost[]): Promise<void> {
await writeJsonArray(configDir, remoteHostsPath(configDir), hosts);
}

export async function readRemoteCases(configDir: string): Promise<RemoteCase[]> {
return readJsonArray<RemoteCase>(remoteCasesPath(configDir));
}

export async function writeRemoteCases(configDir: string, cases: RemoteCase[]): Promise<void> {
await writeJsonArray(configDir, remoteCasesPath(configDir), cases);
}

export function defaultRemoteCommandForMode(mode: SessionMode): string {
const commands: Record<RemoteCommandMode, string> = {
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<RemoteHost, 'username' | 'host'>): 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 <port>]
* [-i <abs-identity>] (~/$HOME expanded, then shellescaped)
* [-J <jumpHost>] (shellescaped, single token)
* [-o ProxyCommand=nc -X 5 -x <socks> %h %p] (ONE shellescaped -o token)
* [-o <KEY=VALUE>] … (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<RemoteHost, 'port'>): 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<RemoteHost, 'username' | 'host' | 'port'> & 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<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions
): Promise<RemoteTmuxCheckResult> {
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<SessionRemote, 'username' | 'host' | 'remotePath'> | { 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,
};
}
18 changes: 17 additions & 1 deletion src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand All @@ -1299,6 +1312,7 @@ export class Session extends EventEmitter {
envOverrides: this._envOverrides,
effort: this._effort,
historyLimit: this._tmuxHistoryLimit,
remote: this._remote,
},
spawnErrLabel: 'mux attachment',
});
Expand Down Expand Up @@ -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,
Expand All @@ -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',
});
Expand Down
Loading