mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge PR #145 from aakhter/cod-94-remote-host-ssh: remote host SSH cases
Includes review fixes: reachable Remote tab UI, remote metadata restore on recovery, quick-start routing for remote run flows, ssh-arg injection guards, dedicated remote socket/name (no cross-instance adoption), remote tmux kill on delete, wired tmux probe + ConnectTimeout, --dangerously-skip-permissions default.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
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',
|
||||
// Mirror the LOCAL claude default so the remote agent runs non-interactively
|
||||
// (no trust-folder/permission prompt that nothing on the remote answers). The
|
||||
// per-host `commands.claude` override stays the escape hatch.
|
||||
claude: 'exec claude --dangerously-skip-permissions',
|
||||
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
|
||||
* [-o ConnectTimeout=10] (default; suppressed if extraSshOptions sets it)
|
||||
* [-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.
|
||||
* - A default `-o ConnectTimeout=10` bounds the wait on an unreachable/blackholed
|
||||
* host (else the pane hangs on the OS TCP timeout). It is omitted when the
|
||||
* operator already set ConnectTimeout via extraSshOptions, so their value wins.
|
||||
*/
|
||||
export function buildSshConnectionArgs(remote: RemoteSshOptions & Pick<RemoteHost, 'port'>): string[] {
|
||||
const parts: string[] = ['ssh', '-o BatchMode=yes'];
|
||||
const hasConnectTimeout = (remote.extraSshOptions ?? []).some((opt) => /^ConnectTimeout=/i.test(opt));
|
||||
if (!hasConnectTimeout) parts.push('-o ConnectTimeout=10');
|
||||
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 {
|
||||
// ConnectTimeout is now a default of buildSshConnectionArgs (shared with the launch).
|
||||
return [...buildSshConnectionArgs(host), remoteSshTarget(host), "'command -v tmux'"].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,
|
||||
};
|
||||
}
|
||||
+17
-1
@@ -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',
|
||||
});
|
||||
|
||||
+139
-4
@@ -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,118 @@ function buildSpawnCommand(options: {
|
||||
return '$SHELL';
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated socket for Codeman-launched REMOTE tmux servers, distinct from the
|
||||
* canonical local `-L codeman` socket. A remote host that runs its OWN Codeman
|
||||
* would otherwise share the `-L codeman` socket AND the `codeman-<hex>` discovery
|
||||
* name, so its `reconcileSessions()` would ADOPT our session (attach a PTY,
|
||||
* resize, respawn-pane it locally) — the cross-machine form of the "2nd instance
|
||||
* attaches live sessions" hazard. A private socket keeps our remote sessions off
|
||||
* that instance's radar entirely.
|
||||
*/
|
||||
const REMOTE_TMUX_SOCKET = 'codeman-remote';
|
||||
|
||||
/**
|
||||
* Deterministic, reattach-stable remote tmux session name for a Codeman session.
|
||||
*
|
||||
* Derived from the same stable field the LOCAL muxName uses (the first 8 chars of
|
||||
* the sessionId), 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.
|
||||
*
|
||||
* The `codeman-ssh-` prefix is deliberately chosen to FAIL a remote Codeman's
|
||||
* `SAFE_MUX_NAME_PATTERN` (`^codeman-[a-f0-9-]+$`) — the `s`/`h` letters mean a
|
||||
* remote instance's discovery never treats this as one of its own sessions (belt
|
||||
* to the dedicated-socket suspenders above).
|
||||
*/
|
||||
export function remoteTmuxSessionName(sessionId: string): string {
|
||||
return `codeman-ssh-${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 [<COD-107 connection opts>] user@host \
|
||||
* 'tmux -L codeman-remote new-session -A -s codeman-ssh-<id> -c <path> "cd <path> && exec <cli>" \
|
||||
* \; 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'
|
||||
*
|
||||
* 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-ssh-<id>` = attach-if-exists-else-create
|
||||
* (idempotent), so reconnect re-runs the same command and reattaches the
|
||||
* still-running agent.
|
||||
* - `-L codeman-remote` = a DEDICATED socket, NOT the canonical `-L codeman` a
|
||||
* remote Codeman would use, so our session never collides with / gets adopted by
|
||||
* an instance running on the remote host.
|
||||
* - 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.
|
||||
*/
|
||||
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). 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)}`,
|
||||
`set -t ${remoteName} status off`,
|
||||
`set -t ${remoteName} mouse off`,
|
||||
`set -t ${remoteName} prefix C-q`,
|
||||
'set -s escape-time 0',
|
||||
].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(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the SSH command that kills the durable remote tmux session created by
|
||||
* `buildRemoteLaunchCommand`. Because that session lives on a private socket
|
||||
* (`-L codeman-remote`) under a stable name, killing the LOCAL ssh wrapper alone
|
||||
* would orphan the remote agent forever (invisible to Codeman, still burning plan
|
||||
* quota). This is fired best-effort on session kill; the shared connection args
|
||||
* carry the default `-o ConnectTimeout=10` so an unreachable host fails fast.
|
||||
*/
|
||||
export function buildRemoteKillCommand(options: { remote: SessionRemote; sessionId: string }): string {
|
||||
const { remote, sessionId } = options;
|
||||
const remoteName = remoteTmuxSessionName(sessionId);
|
||||
const killCmd = `tmux -L ${REMOTE_TMUX_SOCKET} kill-session -t ${shellescape(remoteName)}`;
|
||||
const [ssh, ...connectionArgs] = buildSshConnectionArgs(remote);
|
||||
return [ssh, ...connectionArgs, remoteSshTarget(remote), shellescape(killCmd)].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 +1173,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 +1192,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
pid: 99999,
|
||||
createdAt: Date.now(),
|
||||
workingDir,
|
||||
remote,
|
||||
mode,
|
||||
attached: false,
|
||||
name,
|
||||
@@ -1121,7 +1237,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 +1289,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)}`,
|
||||
{
|
||||
@@ -1246,6 +1363,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
pid,
|
||||
createdAt: Date.now(),
|
||||
workingDir,
|
||||
remote,
|
||||
mode,
|
||||
attached: false,
|
||||
name,
|
||||
@@ -1330,6 +1448,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;
|
||||
@@ -1366,7 +1485,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
|
||||
@@ -1383,7 +1503,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)}`,
|
||||
{
|
||||
@@ -1555,6 +1676,20 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3b: Remote sessions run a DURABLE tmux server on the remote host
|
||||
// (survives ssh drops), so killing only the local ssh wrapper above would
|
||||
// orphan the remote agent forever. Fire a best-effort `ssh … tmux kill-session`
|
||||
// — fire-and-forget so it NEVER blocks or throws the local kill (bounded by the
|
||||
// shared ConnectTimeout on an unreachable host).
|
||||
if (session.remote) {
|
||||
try {
|
||||
const remoteKillCmd = buildRemoteKillCommand({ remote: session.remote, sessionId });
|
||||
exec(remoteKillCmd, { timeout: EXEC_TIMEOUT_MS }, () => {});
|
||||
} catch {
|
||||
// Best-effort — a failure here must not affect the local kill result.
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 4: Direct kill by PID as final fallback
|
||||
if (this.isProcessAlive(currentPid)) {
|
||||
try {
|
||||
|
||||
@@ -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 ==========
|
||||
|
||||
@@ -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<SessionMode, 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini'>;
|
||||
|
||||
/**
|
||||
* 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 <host:port> %h %p` (the cloudflared/SOCKS5 case).
|
||||
*/
|
||||
socksProxy?: string;
|
||||
/** SSH jump host (`[user@]host[:port]`) emitted as `-J <jumpHost>`. */
|
||||
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<Record<RemoteCommandMode, string>>;
|
||||
}
|
||||
|
||||
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<Record<RemoteCommandMode, string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 */
|
||||
|
||||
@@ -1723,6 +1723,7 @@
|
||||
<div class="modal-tabs">
|
||||
<button class="modal-tab-btn active" data-tab="case-create">Create New</button>
|
||||
<button class="modal-tab-btn" data-tab="case-link">Link Existing</button>
|
||||
<button class="modal-tab-btn" data-tab="case-remote">Remote</button>
|
||||
<button class="modal-tab-btn" data-tab="case-manage">Manage</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@@ -1751,6 +1752,66 @@
|
||||
<span class="form-hint">Absolute path to an existing project folder, e.g. /home/you/my-project</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Remote Tab -->
|
||||
<div class="modal-tab-content hidden" id="case-remote">
|
||||
<div class="form-row">
|
||||
<label>Case Name</label>
|
||||
<input type="text" id="remoteCaseName" placeholder="gpu-work" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">Name to identify this remote case in Codeman</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Remote Path</label>
|
||||
<input type="text" id="remoteCasePath" placeholder="/home/user/projects/work" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<span class="form-hint">Absolute path on the remote host. Codeman will not create or delete it.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Host ID</label>
|
||||
<input type="text" id="remoteHostId" placeholder="gpu-box" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>SSH Host/IP</label>
|
||||
<input type="text" id="remoteHostAddress" placeholder="10.0.0.42" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>SSH Username</label>
|
||||
<input type="text" id="remoteHostUsername" placeholder="ubuntu" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>SSH Port</label>
|
||||
<input type="number" id="remoteHostPort" placeholder="22" min="1" max="65535" autocomplete="off">
|
||||
<span class="form-hint">Optional. Leave blank for the default port 22.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Codex Command Override</label>
|
||||
<input type="text" id="remoteHostCodexCommand" placeholder="exec codx personal" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">Optional. Leave blank to use exec codex on the remote host.</span>
|
||||
</div>
|
||||
<details class="advanced-options">
|
||||
<summary>Advanced SSH</summary>
|
||||
<div class="advanced-options-content">
|
||||
<div class="form-row">
|
||||
<label>Identity File</label>
|
||||
<input type="text" id="remoteHostIdentityFile" placeholder="~/.ssh/remote_ed25519" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<span class="form-hint">Optional. Path to a private key on this machine (passed to ssh -i). Never the key contents.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>SOCKS Proxy</label>
|
||||
<input type="text" id="remoteHostSocksProxy" placeholder="127.0.0.1:1080" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">Optional. host:port of a SOCKS5 proxy (e.g. cloudflared). Routes ssh through it via a ProxyCommand.</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Jump Host</label>
|
||||
<input type="text" id="remoteHostJumpHost" placeholder="bastion@10.0.0.1:22" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<span class="form-hint">Optional. [user@]host[:port] for ssh -J (jump/bastion host).</span>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Extra -o Options</label>
|
||||
<textarea id="remoteHostExtraSshOptions" rows="3" placeholder="StrictHostKeyChecking=accept-new ConnectTimeout=10" autocomplete="off" autocapitalize="off" spellcheck="false"></textarea>
|
||||
<span class="form-hint">Optional. One KEY=VALUE per line; each becomes an ssh -o option.</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<!-- Manage Tab -->
|
||||
<div class="modal-tab-content hidden" id="case-manage">
|
||||
<div class="case-manage-list" id="caseManageList">
|
||||
|
||||
+203
-31
@@ -71,9 +71,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
const maxNameLength = isMobile ? 8 : 20; // Truncate to 8 chars on mobile
|
||||
|
||||
cases.forEach(c => {
|
||||
const displayName = c.name.length > maxNameLength
|
||||
? c.name.substring(0, maxNameLength) + '…'
|
||||
const baseLabel = c.location === 'remote' && c.remote
|
||||
? `${c.name} @ ${c.remote.hostId}`
|
||||
: c.name;
|
||||
const displayName = baseLabel.length > maxNameLength
|
||||
? baseLabel.substring(0, maxNameLength) + '…'
|
||||
: baseLabel;
|
||||
options += `<option value="${escapeHtml(c.name)}">${escapeHtml(displayName)}</option>`;
|
||||
});
|
||||
|
||||
@@ -328,6 +331,31 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const workingDir = caseData.path;
|
||||
if (!workingDir) throw new Error('Case path not found');
|
||||
|
||||
// Remote cases run over ssh — POST /api/sessions stat-validates workingDir on
|
||||
// the LOCAL fs (a remote user@host:/path never exists locally), so route them
|
||||
// through /api/quick-start, which resolves the remote case + launches via ssh.
|
||||
if (caseData.location === 'remote') {
|
||||
const remoteIds = [];
|
||||
for (let i = 0; i < tabCount; i++) {
|
||||
const res = await fetch('/api/quick-start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseName, mode: 'claude' })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start remote Claude session');
|
||||
remoteIds.push(data.data.sessionId);
|
||||
}
|
||||
this.terminal.writeln(`\x1b[90m All ${tabCount} remote session(s) ready\x1b[0m`);
|
||||
if (remoteIds[0]) {
|
||||
await this.selectSession(remoteIds[0]);
|
||||
this.loadQuickStartCases();
|
||||
}
|
||||
this.terminal.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
let firstSessionId = null;
|
||||
|
||||
// Find the highest existing w-number for THIS case to avoid duplicates
|
||||
@@ -486,6 +514,27 @@ Object.assign(CodemanApp.prototype, {
|
||||
const workingDir = caseData.path;
|
||||
if (!workingDir) throw new Error('Case path not found');
|
||||
|
||||
// Remote cases run over ssh — route through /api/quick-start (see runClaude).
|
||||
if (caseData.location === 'remote') {
|
||||
const remoteIds = [];
|
||||
for (let i = 0; i < shellCount; i++) {
|
||||
const res = await fetch('/api/quick-start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseName, mode: 'shell' })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.error || 'Failed to start remote shell session');
|
||||
remoteIds.push(data.data.sessionId);
|
||||
}
|
||||
if (remoteIds[0]) {
|
||||
this.activeSessionId = remoteIds[0];
|
||||
await this.selectSession(remoteIds[0]);
|
||||
}
|
||||
this.terminal.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the highest existing s-number for THIS case to avoid duplicates
|
||||
let startNumber = 1;
|
||||
for (const [, session] of this.sessions) {
|
||||
@@ -551,6 +600,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
async runOpenCode() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote cases run the CLI on the REMOTE host — the local /api/opencode/status
|
||||
// probe and the local-only config/env below don't apply (quick-start rejects them).
|
||||
const isRemote = (this.cases || []).find(c => c.name === caseName)?.location === 'remote';
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting OpenCode session in ${caseName}...\x1b[0m`);
|
||||
@@ -559,13 +611,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
// Check if OpenCode is available
|
||||
const statusRes = await fetch('/api/opencode/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this.terminal.writeln('\x1b[1;31m OpenCode CLI not found.\x1b[0m');
|
||||
this.terminal.writeln('\x1b[90m Install with: curl -fsSL https://opencode.ai/install | bash\x1b[0m');
|
||||
return;
|
||||
// Check if OpenCode is available (local sessions only)
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/opencode/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this.terminal.writeln('\x1b[1;31m OpenCode CLI not found.\x1b[0m');
|
||||
this.terminal.writeln('\x1b[90m Install with: curl -fsSL https://opencode.ai/install | bash\x1b[0m');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Quick-start with opencode mode (auto-allow tools by default).
|
||||
@@ -577,8 +631,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
body: JSON.stringify({
|
||||
caseName,
|
||||
mode: 'opencode',
|
||||
openCodeConfig: { autoAllowTools: true },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(isRemote ? {} : {
|
||||
openCodeConfig: { autoAllowTools: true },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
}),
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -598,6 +654,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
async runCodex() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote cases run Codex on the REMOTE host — skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const isRemote = (this.cases || []).find(c => c.name === caseName)?.location === 'remote';
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting Codex session in ${caseName}...\x1b[0m`);
|
||||
@@ -605,12 +664,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
const statusRes = await fetch('/api/codex/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this.terminal.writeln('\x1b[1;31m Codex CLI not found.\x1b[0m');
|
||||
this.terminal.writeln('\x1b[90m Install with: npm install -g @openai/codex\x1b[0m');
|
||||
return;
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/codex/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this.terminal.writeln('\x1b[1;31m Codex CLI not found.\x1b[0m');
|
||||
this.terminal.writeln('\x1b[90m Install with: npm install -g @openai/codex\x1b[0m');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const globalSettings = this.loadAppSettingsFromStorage();
|
||||
@@ -621,11 +682,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
body: JSON.stringify({
|
||||
caseName,
|
||||
mode: 'codex',
|
||||
codexConfig: {
|
||||
dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
|
||||
renderMode: 'hybrid',
|
||||
},
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(isRemote ? {} : {
|
||||
codexConfig: {
|
||||
dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
|
||||
renderMode: 'hybrid',
|
||||
},
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
}),
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -645,6 +708,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
async runGemini() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
// Remote cases run Gemini on the REMOTE host — skip the local status probe and the
|
||||
// local-only config/env below (quick-start rejects them for remote cases).
|
||||
const isRemote = (this.cases || []).find(c => c.name === caseName)?.location === 'remote';
|
||||
|
||||
this.terminal.clear();
|
||||
this.terminal.writeln(`\x1b[1;32m Starting Gemini session in ${caseName}...\x1b[0m`);
|
||||
@@ -652,12 +718,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.focus();
|
||||
|
||||
try {
|
||||
const statusRes = await fetch('/api/gemini/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this.terminal.writeln('\x1b[1;31m Gemini CLI not found.\x1b[0m');
|
||||
this.terminal.writeln('\x1b[90m Install with: npm install -g @google/gemini-cli\x1b[0m');
|
||||
return;
|
||||
if (!isRemote) {
|
||||
const statusRes = await fetch('/api/gemini/status');
|
||||
const status = (await statusRes.json()).data;
|
||||
if (!status.available) {
|
||||
this.terminal.writeln('\x1b[1;31m Gemini CLI not found.\x1b[0m');
|
||||
this.terminal.writeln('\x1b[90m Install with: npm install -g @google/gemini-cli\x1b[0m');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
|
||||
@@ -667,8 +735,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
body: JSON.stringify({
|
||||
caseName,
|
||||
mode: 'gemini',
|
||||
geminiConfig: { approvalMode: 'yolo' },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
...(isRemote ? {} : {
|
||||
geminiConfig: { approvalMode: 'yolo' },
|
||||
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
|
||||
}),
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
@@ -1254,6 +1324,23 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('newCaseDescription').value = '';
|
||||
document.getElementById('linkCaseName').value = '';
|
||||
document.getElementById('linkCasePath').value = '';
|
||||
const remoteFields = [
|
||||
'remoteCaseName',
|
||||
'remoteCasePath',
|
||||
'remoteHostId',
|
||||
'remoteHostAddress',
|
||||
'remoteHostUsername',
|
||||
'remoteHostPort',
|
||||
'remoteHostCodexCommand',
|
||||
'remoteHostIdentityFile',
|
||||
'remoteHostSocksProxy',
|
||||
'remoteHostJumpHost',
|
||||
'remoteHostExtraSshOptions',
|
||||
];
|
||||
remoteFields.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = '';
|
||||
});
|
||||
// Reset to first tab
|
||||
this.caseModalTab = 'case-create';
|
||||
this.switchCaseModalTab('case-create');
|
||||
@@ -1295,13 +1382,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.renderCaseManageList();
|
||||
} else {
|
||||
submitBtn.style.display = '';
|
||||
submitBtn.textContent = tabName === 'case-create' ? 'Create' : 'Link';
|
||||
submitBtn.textContent =
|
||||
tabName === 'case-create' ? 'Create' : tabName === 'case-remote' ? 'Link Remote' : 'Link';
|
||||
}
|
||||
// Focus appropriate input
|
||||
if (tabName === 'case-create') {
|
||||
document.getElementById('newCaseName').focus();
|
||||
} else if (tabName === 'case-link') {
|
||||
document.getElementById('linkCaseName').focus();
|
||||
} else if (tabName === 'case-remote') {
|
||||
document.getElementById('remoteCaseName').focus();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1317,6 +1407,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
try {
|
||||
if (this.caseModalTab === 'case-create') {
|
||||
await this.createCase();
|
||||
} else if (this.caseModalTab === 'case-remote') {
|
||||
await this.linkRemoteCase();
|
||||
} else {
|
||||
await this.linkCase();
|
||||
}
|
||||
@@ -1407,6 +1499,86 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
async linkRemoteCase() {
|
||||
const name = document.getElementById('remoteCaseName').value.trim();
|
||||
const remotePath = document.getElementById('remoteCasePath').value.trim();
|
||||
const hostId = document.getElementById('remoteHostId').value.trim();
|
||||
const host = document.getElementById('remoteHostAddress').value.trim();
|
||||
const username = document.getElementById('remoteHostUsername').value.trim();
|
||||
const codexCommand = document.getElementById('remoteHostCodexCommand').value.trim();
|
||||
// COD-107 — port + advanced SSH connection options.
|
||||
const portRaw = document.getElementById('remoteHostPort').value.trim();
|
||||
const identityFile = document.getElementById('remoteHostIdentityFile').value.trim();
|
||||
const socksProxy = document.getElementById('remoteHostSocksProxy').value.trim();
|
||||
const jumpHost = document.getElementById('remoteHostJumpHost').value.trim();
|
||||
const extraSshOptions = document.getElementById('remoteHostExtraSshOptions').value
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0);
|
||||
|
||||
if (!name || !remotePath || !hostId || !host || !username) {
|
||||
this.showToast('Please complete all required remote fields', 'error');
|
||||
return;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(name) || !/^[a-zA-Z0-9_-]+$/.test(hostId)) {
|
||||
this.showToast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error');
|
||||
return;
|
||||
}
|
||||
if (!remotePath.startsWith('/')) {
|
||||
this.showToast('Remote path must be absolute', 'error');
|
||||
return;
|
||||
}
|
||||
let port;
|
||||
if (portRaw) {
|
||||
port = Number(portRaw);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
this.showToast('SSH port must be a number between 1 and 65535', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const hostPayload = {
|
||||
id: hostId,
|
||||
label: hostId,
|
||||
host,
|
||||
username,
|
||||
...(port ? { port } : {}),
|
||||
...(identityFile ? { identityFile } : {}),
|
||||
...(socksProxy ? { socksProxy } : {}),
|
||||
...(jumpHost ? { jumpHost } : {}),
|
||||
...(extraSshOptions.length ? { extraSshOptions } : {}),
|
||||
...(codexCommand ? { commands: { codex: codexCommand } } : {}),
|
||||
};
|
||||
const hostRes = await fetch('/api/remote-hosts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(hostPayload)
|
||||
});
|
||||
const hostData = await hostRes.json();
|
||||
if (!hostData.success && hostData.errorCode !== 'ALREADY_EXISTS') {
|
||||
throw new Error(hostData.error || 'Failed to save remote host');
|
||||
}
|
||||
|
||||
const caseRes = await fetch('/api/cases/remote-link', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, hostId, remotePath })
|
||||
});
|
||||
const caseData = await caseRes.json();
|
||||
if (caseData.success) {
|
||||
this.closeCreateCaseModal();
|
||||
this.showToast(`Remote case "${name}" linked`, 'success');
|
||||
await this.loadQuickStartCases(name);
|
||||
await this.saveLastUsedCase(name);
|
||||
} else {
|
||||
this.showToast(caseData.error || 'Failed to link remote case', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to link remote case:', err);
|
||||
this.showToast('Failed to link remote case: ' + err.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Case Management (reorder + delete)
|
||||
|
||||
@@ -11,15 +11,30 @@ import { join, resolve } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import type { ApiResponse, CaseInfo } from '../../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import { CreateCaseSchema, LinkCaseSchema, CaseOrderSchema } from '../schemas.js';
|
||||
import {
|
||||
CreateCaseSchema,
|
||||
LinkCaseSchema,
|
||||
CaseOrderSchema,
|
||||
RemoteCaseLinkSchema,
|
||||
RemoteHostSchema,
|
||||
} from '../schemas.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { writeHooksConfig } from '../../hooks-config.js';
|
||||
import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { EventPort, ConfigPort } from '../ports/index.js';
|
||||
import { dataPath, getDataDir } from '../../config/instance.js';
|
||||
import {
|
||||
checkRemoteTmuxAvailable,
|
||||
readRemoteCases,
|
||||
readRemoteHosts,
|
||||
remoteDisplayPath,
|
||||
writeRemoteCases,
|
||||
writeRemoteHosts,
|
||||
} from '../../remote-hosts.js';
|
||||
|
||||
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
||||
const CODEMAN_CONFIG_DIR = getDataDir();
|
||||
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
|
||||
|
||||
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
|
||||
@@ -53,6 +68,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
name: e.name,
|
||||
path: join(CASES_DIR, e.name),
|
||||
hasClaudeMd: existsSync(join(CASES_DIR, e.name, 'CLAUDE.md')),
|
||||
location: 'local',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -69,10 +85,39 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
name,
|
||||
path,
|
||||
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
|
||||
linked: true,
|
||||
location: 'linked-local',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get remote cases
|
||||
const remoteHosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
||||
const remoteHostMap = new Map(remoteHosts.map((host) => [host.id, host]));
|
||||
for (const remoteCase of await readRemoteCases(CODEMAN_CONFIG_DIR)) {
|
||||
const host = remoteHostMap.get(remoteCase.hostId);
|
||||
if (!host || !SAFE_CASE_NAME.test(remoteCase.name)) continue;
|
||||
existingNames.add(remoteCase.name);
|
||||
const remoteCaseInfo: CaseInfo = {
|
||||
name: remoteCase.name,
|
||||
path: remoteDisplayPath({ username: host.username, host: host.host, path: remoteCase.remotePath }),
|
||||
hasClaudeMd: false,
|
||||
location: 'remote',
|
||||
remote: {
|
||||
hostId: host.id,
|
||||
host: host.host,
|
||||
username: host.username,
|
||||
path: remoteCase.remotePath,
|
||||
},
|
||||
};
|
||||
const existingIndex = cases.findIndex((item) => item.name === remoteCase.name);
|
||||
if (existingIndex === -1) {
|
||||
cases.push(remoteCaseInfo);
|
||||
} else {
|
||||
cases[existingIndex] = remoteCaseInfo;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by persisted caseOrder from settings.json
|
||||
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings', {});
|
||||
const caseOrder = Array.isArray(settings.caseOrder) ? (settings.caseOrder as string[]) : [];
|
||||
@@ -120,6 +165,73 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/remote-hosts', async () => readRemoteHosts(CODEMAN_CONFIG_DIR));
|
||||
|
||||
app.post('/api/remote-hosts', async (req): Promise<ApiResponse<{ host: unknown }>> => {
|
||||
const host = parseBody(RemoteHostSchema, req.body);
|
||||
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
||||
if (hosts.some((item) => item.id === host.id)) {
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Remote host already exists');
|
||||
}
|
||||
await writeRemoteHosts(CODEMAN_CONFIG_DIR, [...hosts, host]);
|
||||
return { success: true, data: { host } };
|
||||
});
|
||||
|
||||
app.put('/api/remote-hosts/:id', async (req): Promise<ApiResponse<{ host: unknown }>> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const host = parseBody(RemoteHostSchema, { ...(req.body as object), id });
|
||||
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
||||
const index = hosts.findIndex((item) => item.id === id);
|
||||
if (index === -1) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||||
const next = [...hosts];
|
||||
next[index] = host;
|
||||
await writeRemoteHosts(CODEMAN_CONFIG_DIR, next);
|
||||
return { success: true, data: { host } };
|
||||
});
|
||||
|
||||
app.delete('/api/remote-hosts/:id', async (req): Promise<ApiResponse<{ id: string }>> => {
|
||||
const { id } = req.params as { id: string };
|
||||
const cases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
if (cases.some((item) => item.hostId === id)) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Remote host is still used by remote cases');
|
||||
}
|
||||
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
||||
await writeRemoteHosts(
|
||||
CODEMAN_CONFIG_DIR,
|
||||
hosts.filter((item) => item.id !== id)
|
||||
);
|
||||
return { success: true, data: { id } };
|
||||
});
|
||||
|
||||
app.post('/api/cases/remote-link', async (req): Promise<ApiResponse<{ case: unknown }>> => {
|
||||
const remoteCase = { ...parseBody(RemoteCaseLinkSchema, req.body), type: 'remote' as const };
|
||||
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
|
||||
const host = hosts.find((item) => item.id === remoteCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||||
|
||||
const linkedCases = await readLinkedCases();
|
||||
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
if (
|
||||
remoteCases.some((item) => item.name === remoteCase.name) ||
|
||||
linkedCases[remoteCase.name] ||
|
||||
existsSync(join(CASES_DIR, remoteCase.name))
|
||||
) {
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
|
||||
}
|
||||
|
||||
// Courtesy validation: tmux is a hard prerequisite for durable remote sessions.
|
||||
// Verify it up-front so linking surfaces a clear error now instead of a dead pane
|
||||
// at first launch (also confirms the SSH connection actually works).
|
||||
const tmuxCheck = await checkRemoteTmuxAvailable(host);
|
||||
if (!tmuxCheck.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'remote host is missing tmux');
|
||||
}
|
||||
|
||||
await writeRemoteCases(CODEMAN_CONFIG_DIR, [...remoteCases, remoteCase]);
|
||||
ctx.broadcast(SseEvent.CaseLinked, { name: remoteCase.name, path: remoteCase.remotePath, type: 'remote' });
|
||||
return { success: true, data: { case: remoteCase } };
|
||||
});
|
||||
|
||||
// Link an existing folder as a case
|
||||
app.post('/api/cases/link', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
|
||||
const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body');
|
||||
@@ -173,6 +285,16 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
if (remoteCases.some((item) => item.name === name)) {
|
||||
await writeRemoteCases(
|
||||
CODEMAN_CONFIG_DIR,
|
||||
remoteCases.filter((item) => item.name !== name)
|
||||
);
|
||||
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'remote-unlinked' });
|
||||
return { success: true, data: { name } };
|
||||
}
|
||||
|
||||
// Check linked cases first — unlink only, don't delete the actual directory
|
||||
const linkedCases = await readLinkedCases();
|
||||
if (linkedCases[name]) {
|
||||
@@ -233,6 +355,25 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
|
||||
}
|
||||
|
||||
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
const remoteCase = remoteCases.find((item) => item.name === name);
|
||||
if (remoteCase) {
|
||||
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||||
return {
|
||||
name,
|
||||
path: remoteDisplayPath({ username: host.username, host: host.host, path: remoteCase.remotePath }),
|
||||
hasClaudeMd: false,
|
||||
location: 'remote',
|
||||
remote: {
|
||||
hostId: host.id,
|
||||
host: host.host,
|
||||
username: host.username,
|
||||
path: remoteCase.remotePath,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const casePath = await resolveCasePath(name);
|
||||
|
||||
if (!existsSync(casePath)) {
|
||||
|
||||
@@ -70,10 +70,12 @@ import { RunSummaryTracker } from '../../run-summary.js';
|
||||
|
||||
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
|
||||
import { MAX_PASTE_IMAGE_BYTES } from '../../config/buffer-limits.js';
|
||||
import { dataPath } from '../../config/instance.js';
|
||||
import { dataPath, getDataDir } from '../../config/instance.js';
|
||||
import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, toSessionRemote } from '../../remote-hosts.js';
|
||||
|
||||
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
|
||||
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
||||
const CODEMAN_CONFIG_DIR = getDataDir();
|
||||
|
||||
// Pre-compiled regex for terminal buffer cleaning (avoids per-request compilation)
|
||||
// eslint-disable-next-line no-control-regex
|
||||
@@ -1269,81 +1271,125 @@ export function registerSessionRoutes(
|
||||
effort,
|
||||
} = parseBody(QuickStartSchema, req.body);
|
||||
|
||||
// Check OpenCode availability if requested
|
||||
if (mode === 'opencode') {
|
||||
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
|
||||
if (!isOpenCodeAvailable()) {
|
||||
// Resolve the remote case FIRST — the CLI executes on the REMOTE host over ssh,
|
||||
// so the LOCAL availability gates below (isCodexAvailable() etc.) don't apply and
|
||||
// would wrongly reject a machine that hasn't got the CLI installed locally.
|
||||
let remote = undefined;
|
||||
let casePath: string | null = null;
|
||||
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
|
||||
const remoteCase = remoteCases.find((item) => item.name === caseName);
|
||||
if (remoteCase) {
|
||||
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
|
||||
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
|
||||
|
||||
// Per-session config that is applied to the LOCAL tmux/CLI wrapper (env vars via
|
||||
// tmux setenv, effort/model CLI args, codex/gemini/opencode config) does NOT
|
||||
// cross ssh, so it would silently no-op. Reject rather than pretend it worked —
|
||||
// remote command/env customization goes through the per-host command override.
|
||||
if (
|
||||
(envOverrides && Object.keys(envOverrides).length > 0) ||
|
||||
effort ||
|
||||
codexConfig ||
|
||||
geminiConfig ||
|
||||
openCodeConfig
|
||||
) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'envOverrides, effort, and per-CLI config are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check Codex availability if requested
|
||||
if (mode === 'codex') {
|
||||
const { isCodexAvailable } = await import('../../utils/codex-cli-resolver.js');
|
||||
if (!isCodexAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Codex CLI not found. Install with: npm install -g @openai/codex'
|
||||
);
|
||||
// tmux is a hard prerequisite on the remote host (the agent runs inside a remote
|
||||
// tmux server so it survives ssh drops). Probe before spawning so a missing tmux
|
||||
// surfaces a clear, structured error instead of a dead "tmux: command not found" pane.
|
||||
const tmuxCheck = await checkRemoteTmuxAvailable(host);
|
||||
if (!tmuxCheck.ok) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'remote host is missing tmux');
|
||||
}
|
||||
}
|
||||
|
||||
// Check Gemini availability if requested
|
||||
if (mode === 'gemini') {
|
||||
const { isGeminiAvailable } = await import('../../utils/gemini-cli-resolver.js');
|
||||
if (!isGeminiAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
|
||||
);
|
||||
casePath = remoteCase.remotePath;
|
||||
remote = toSessionRemote(host, remoteCase);
|
||||
} else {
|
||||
// Check OpenCode availability if requested
|
||||
if (mode === 'opencode') {
|
||||
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
|
||||
if (!isOpenCodeAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
|
||||
// This mirrors the behaviour of resolveCasePath() in case-routes so that linked
|
||||
// external project directories are honoured by quick-start just like regular case routes.
|
||||
let linkedCases: Record<string, string> = {};
|
||||
try {
|
||||
const raw = await fs.readFile(LINKED_CASES_FILE, 'utf-8');
|
||||
linkedCases = JSON.parse(raw);
|
||||
} catch {
|
||||
// File missing or unparseable — treat as empty registry
|
||||
}
|
||||
const linkedCasePath = linkedCases[caseName];
|
||||
const casePath = linkedCasePath || validatePathWithinBase(caseName, CASES_DIR);
|
||||
if (!casePath) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||
}
|
||||
// Check Codex availability if requested
|
||||
if (mode === 'codex') {
|
||||
const { isCodexAvailable } = await import('../../utils/codex-cli-resolver.js');
|
||||
if (!isCodexAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Codex CLI not found. Install with: npm install -g @openai/codex'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked cases)
|
||||
if (!existsSync(casePath)) {
|
||||
// Check Gemini availability if requested
|
||||
if (mode === 'gemini') {
|
||||
const { isGeminiAvailable } = await import('../../utils/gemini-cli-resolver.js');
|
||||
if (!isGeminiAvailable()) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.OPERATION_FAILED,
|
||||
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
|
||||
// This mirrors the behaviour of resolveCasePath() in case-routes so that linked
|
||||
// external project directories are honoured by quick-start just like regular case routes.
|
||||
let linkedCases: Record<string, string> = {};
|
||||
try {
|
||||
mkdirSync(casePath, { recursive: true });
|
||||
mkdirSync(join(casePath, 'src'), { recursive: true });
|
||||
const raw = await fs.readFile(LINKED_CASES_FILE, 'utf-8');
|
||||
linkedCases = JSON.parse(raw);
|
||||
} catch {
|
||||
// File missing or unparseable — treat as empty registry
|
||||
}
|
||||
casePath = linkedCases[caseName] || validatePathWithinBase(caseName, CASES_DIR);
|
||||
if (!casePath) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
|
||||
}
|
||||
}
|
||||
|
||||
// By this point casePath is guaranteed non-null: for remote cases it was set from remoteCase.remotePath,
|
||||
// for local cases the !casePath guard above returned early. TypeScript can't narrow across the if/else.
|
||||
const resolvedCasePath = casePath as string;
|
||||
|
||||
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote cases)
|
||||
if (!remote && !existsSync(resolvedCasePath)) {
|
||||
try {
|
||||
mkdirSync(resolvedCasePath, { recursive: true });
|
||||
mkdirSync(join(resolvedCasePath, 'src'), { recursive: true });
|
||||
|
||||
// Read settings to get custom template path
|
||||
const templatePath = await ctx.getDefaultClaudeMdPath();
|
||||
const claudeMd = generateClaudeMd(caseName, '', templatePath);
|
||||
writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
|
||||
writeFileSync(join(resolvedCasePath, 'CLAUDE.md'), claudeMd);
|
||||
|
||||
// Write .claude/settings.local.json with hooks for desktop notifications
|
||||
// (Claude-specific — OpenCode, Codex, and Gemini use their own systems)
|
||||
if (mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini') {
|
||||
await writeHooksConfig(casePath);
|
||||
await writeHooksConfig(resolvedCasePath);
|
||||
}
|
||||
|
||||
ctx.broadcast(SseEvent.CaseCreated, { name: caseName, path: casePath });
|
||||
ctx.broadcast(SseEvent.CaseCreated, { name: caseName, path: resolvedCasePath });
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
|
||||
}
|
||||
} else if (mode !== 'opencode') {
|
||||
} else if (!remote && mode !== 'opencode') {
|
||||
// COD-91 self-heal for an EXISTING case: refresh a pre-secret hooks block so the
|
||||
// now-unconditional hook-secret gate keeps accepting its hook events. No-op when
|
||||
// the hooks aren't ours or already carry the secret.
|
||||
await refreshStaleHookSecret(casePath).catch(() => {});
|
||||
// the hooks aren't ours or already carry the secret. Skipped for remote cases —
|
||||
// resolvedCasePath is a REMOTE path that doesn't exist on the local filesystem.
|
||||
await refreshStaleHookSecret(resolvedCasePath).catch(() => {});
|
||||
}
|
||||
|
||||
// Strip stale disk entries for keys this request is actively setting (Claude only —
|
||||
@@ -1352,10 +1398,11 @@ export function registerSessionRoutes(
|
||||
mode !== 'opencode' &&
|
||||
mode !== 'codex' &&
|
||||
mode !== 'gemini' &&
|
||||
!remote &&
|
||||
envOverrides &&
|
||||
Object.keys(envOverrides).length > 0
|
||||
) {
|
||||
await stripCaseEnvKeys(casePath, Object.keys(envOverrides));
|
||||
await stripCaseEnvKeys(resolvedCasePath, Object.keys(envOverrides));
|
||||
}
|
||||
|
||||
// Create a new session with the case as working directory
|
||||
@@ -1375,7 +1422,7 @@ export function registerSessionRoutes(
|
||||
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
|
||||
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
|
||||
const session = new Session({
|
||||
workingDir: casePath,
|
||||
workingDir: resolvedCasePath,
|
||||
mux: ctx.mux,
|
||||
useMux: true,
|
||||
mode: mode,
|
||||
@@ -1388,13 +1435,14 @@ export function registerSessionRoutes(
|
||||
geminiConfig: mode === 'gemini' ? geminiConfig : undefined,
|
||||
envOverrides,
|
||||
effort,
|
||||
remote,
|
||||
tmuxHistoryLimit: qsTerminalHistoryConfig.tmuxHistoryLimit,
|
||||
});
|
||||
|
||||
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
|
||||
// so the initial state already has the phrase configured (only if globally enabled)
|
||||
if (mode === 'claude' && ctx.store.getConfig().ralphEnabled) {
|
||||
autoConfigureRalph(session, casePath, ctx);
|
||||
if (mode === 'claude' && !remote && ctx.store.getConfig().ralphEnabled) {
|
||||
autoConfigureRalph(session, resolvedCasePath, ctx);
|
||||
if (!session.ralphTracker.enabled) {
|
||||
session.ralphTracker.enable();
|
||||
session.ralphTracker.enableAutoEnable(); // Allow re-enabling on restart
|
||||
@@ -1463,7 +1511,7 @@ export function registerSessionRoutes(
|
||||
|
||||
return {
|
||||
sessionId: session.id,
|
||||
casePath,
|
||||
casePath: resolvedCasePath,
|
||||
caseName,
|
||||
};
|
||||
} catch (err) {
|
||||
|
||||
@@ -271,6 +271,94 @@ export const CreateCaseSchema = z.object({
|
||||
description: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
const RemoteCommandOverridesSchema = z
|
||||
.object({
|
||||
shell: z.string().min(1).max(300).optional(),
|
||||
claude: z.string().min(1).max(300).optional(),
|
||||
opencode: z.string().min(1).max(300).optional(),
|
||||
codex: z.string().min(1).max(300).optional(),
|
||||
gemini: z.string().min(1).max(300).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
// COD-107 — advanced SSH connection options. These ultimately exec as shell
|
||||
// (ProxyCommand etc.), but are OPERATOR-entered host config (never attacker- or
|
||||
// terminal-output-influenced), so we validate as defense-in-depth, not as the
|
||||
// security boundary. Reject newline/NUL/backtick/`$(` shell-injection vectors.
|
||||
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.
|
||||
const NO_SHELL_META = /^[^\n\r\0`$]*$/;
|
||||
|
||||
export const RemoteHostSchema = z.object({
|
||||
id: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid remote host id'),
|
||||
label: z.string().min(1).max(100),
|
||||
host: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(255)
|
||||
.regex(/^[a-zA-Z0-9._:-]+$/, 'Invalid SSH host'),
|
||||
username: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9._-]+$/, 'Invalid SSH username'),
|
||||
port: z.number().int().min(1).max(65535).optional(),
|
||||
// Identity (private-key) file PATH only — never key bytes. Reject shell
|
||||
// metacharacters ($, backtick) that survive into the `bash -c` launch layer.
|
||||
identityFile: z.string().min(1).max(4096).regex(NO_SHELL_META, 'Invalid identity file path').optional(),
|
||||
// SOCKS5 proxy as host:port (e.g. 127.0.0.1:1080).
|
||||
socksProxy: z
|
||||
.string()
|
||||
.regex(/^[\w.-]+:\d{1,5}$/, 'SOCKS proxy must be host:port')
|
||||
.optional(),
|
||||
// SSH jump host: a comma-separated chain of [user@]host[:port] hops. Structural
|
||||
// ALLOWLIST (not an open denylist) — only chars valid in user/host/port/IPv6,
|
||||
// so no shell metacharacter (;, |, &, space, $, quotes, …) can appear. The value
|
||||
// is also shellescaped at command-build time (buildSshConnectionArgs); this is the
|
||||
// belt to that suspenders.
|
||||
jumpHost: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(255)
|
||||
.regex(
|
||||
/^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9.:[\]-]+(?::\d{1,5})?(?:,(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9.:[\]-]+(?::\d{1,5})?)*$/,
|
||||
'Jump host must be [user@]host[:port] (comma-separated for multiple hops)'
|
||||
)
|
||||
.optional(),
|
||||
// Arbitrary extra -o KEY=VALUE options (escape hatch); each must be KEY=VALUE.
|
||||
extraSshOptions: z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(1024)
|
||||
.regex(/^[A-Za-z][A-Za-z0-9]*=.+$/, 'Extra SSH option must be KEY=VALUE')
|
||||
.regex(NO_SHELL_INJECTION, 'Invalid characters in SSH option')
|
||||
.refine(noCommandSubstitution, 'Invalid characters in SSH option')
|
||||
)
|
||||
.max(32)
|
||||
.optional(),
|
||||
commands: RemoteCommandOverridesSchema,
|
||||
});
|
||||
|
||||
export const RemoteCaseLinkSchema = z.object({
|
||||
name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'),
|
||||
hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid remote host id'),
|
||||
remotePath: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2000)
|
||||
.regex(/^\//, 'Remote path must be absolute')
|
||||
.regex(NO_SHELL_META, 'Invalid characters in remote path'),
|
||||
});
|
||||
|
||||
// ========== Quick Start ==========
|
||||
|
||||
/**
|
||||
|
||||
@@ -2180,6 +2180,12 @@ export class WebServer extends EventEmitter {
|
||||
envOverrides: savedEnvOverrides,
|
||||
effort: savedState?.effort,
|
||||
attachmentHistory: savedAttachmentHistory,
|
||||
// Remote SSH metadata must round-trip on recovery: without it the
|
||||
// attach cwd falls back to the (nonexistent-locally) remote path and
|
||||
// respawn rebuilds a LOCAL command, breaking the pane and silently
|
||||
// erasing `remote` from state.json on the next persist. mux-sessions.json
|
||||
// round-trips MuxSession.remote; state.json carries SessionState.remote.
|
||||
remote: muxSession.remote ?? savedState?.remote,
|
||||
});
|
||||
|
||||
// Update session name if it was a "Restored:" placeholder or doesn't match saved name
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
defaultRemoteCommandForMode,
|
||||
readRemoteCases,
|
||||
readRemoteHosts,
|
||||
remoteDisplayPath,
|
||||
remoteSshTarget,
|
||||
writeRemoteCases,
|
||||
writeRemoteHosts,
|
||||
} from '../src/remote-hosts.js';
|
||||
|
||||
describe('remote-hosts domain', () => {
|
||||
let dir: string | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (dir) rmSync(dir, { recursive: true, force: true });
|
||||
dir = null;
|
||||
});
|
||||
|
||||
function configDir(): string {
|
||||
dir = mkdtempSync(join(tmpdir(), 'codeman-remote-hosts-'));
|
||||
return dir;
|
||||
}
|
||||
|
||||
it('round-trips remote hosts and remote cases from a config directory', async () => {
|
||||
const root = configDir();
|
||||
await writeRemoteHosts(root, [
|
||||
{
|
||||
id: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
commands: { codex: 'exec codx personal' },
|
||||
},
|
||||
]);
|
||||
await writeRemoteCases(root, [
|
||||
{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' },
|
||||
]);
|
||||
|
||||
await expect(readRemoteHosts(root)).resolves.toEqual([
|
||||
{
|
||||
id: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
commands: { codex: 'exec codx personal' },
|
||||
},
|
||||
]);
|
||||
await expect(readRemoteCases(root)).resolves.toEqual([
|
||||
{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns safe mode defaults and remote display values', () => {
|
||||
expect(defaultRemoteCommandForMode('shell')).toBe('exec bash -l');
|
||||
expect(defaultRemoteCommandForMode('codex')).toBe('exec codex');
|
||||
// Mirrors the local claude default so the remote agent runs non-interactively.
|
||||
expect(defaultRemoteCommandForMode('claude')).toBe('exec claude --dangerously-skip-permissions');
|
||||
expect(remoteSshTarget({ id: 'h1', label: 'H1', host: 'box.local', username: 'aamer' })).toBe('aamer@box.local');
|
||||
expect(remoteDisplayPath({ username: 'aamer', host: 'box.local', path: '/opt/work' })).toBe(
|
||||
'aamer@box.local:/opt/work'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* @fileoverview COD-107 — Remote-host SSH: custom port + advanced connection options.
|
||||
*
|
||||
* Unit tests for the shared, pure `buildSshConnectionArgs(remote)` and its two
|
||||
* consumers (`buildRemoteLaunchCommand`, `buildRemoteTmuxCheckCommand`). The
|
||||
* acceptance target is the aa-desktop option set (custom port 2222, ed25519
|
||||
* identity under `~`, a cloudflared SOCKS5 ProxyCommand, plus an arbitrary
|
||||
* `-o` escape-hatch option) — the same connection `~/repos/claude-config/bin/
|
||||
* ssh-aa-desktop` makes, WITHOUT shelling out to that wrapper.
|
||||
*
|
||||
* Critical, easy-to-break invariants pinned here:
|
||||
* - `%h %p` in the ProxyCommand reach ssh LITERALLY (one shellescaped
|
||||
* `-o ProxyCommand=…` token; the local shell must not expand/mangle them).
|
||||
* - a leading `~`/`$HOME` in `identityFile` is expanded to an absolute path at
|
||||
* build time (ssh does NOT expand `~` in `-i`), then shellescaped.
|
||||
* - empty options ⇒ byte-identical ssh to today (full back-compat).
|
||||
*
|
||||
* Pure command-string construction; no real tmux, no ssh. Port: N/A.
|
||||
*/
|
||||
|
||||
import { homedir } from 'node:os';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildSshConnectionArgs, buildRemoteTmuxCheckCommand, remoteSshTarget } from '../src/remote-hosts.js';
|
||||
import { buildRemoteLaunchCommand } from '../src/tmux-manager.js';
|
||||
import type { SessionRemote } from '../src/types.js';
|
||||
|
||||
const HOME = homedir();
|
||||
|
||||
const baseRemote: SessionRemote = {
|
||||
hostId: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/work',
|
||||
};
|
||||
|
||||
// The acceptance host: aa-desktop reached over the cloudflared SOCKS5 proxy.
|
||||
const aaDesktop: SessionRemote = {
|
||||
hostId: 'aa-desktop',
|
||||
label: 'aa-desktop',
|
||||
host: '192.168.55.170',
|
||||
username: 'aakht',
|
||||
port: 2222,
|
||||
remotePath: '/tmp',
|
||||
identityFile: '~/.ssh/remote_ed25519',
|
||||
socksProxy: '127.0.0.1:1080',
|
||||
extraSshOptions: ['StrictHostKeyChecking=accept-new'],
|
||||
commands: { shell: 'exec bash -l' },
|
||||
};
|
||||
|
||||
const SESSION_ID = 'cod107chk';
|
||||
|
||||
describe('COD-107 buildSshConnectionArgs — shared ssh connection tokens', () => {
|
||||
it('always leads with -o BatchMode=yes then the default -o ConnectTimeout=10', () => {
|
||||
expect(buildSshConnectionArgs(baseRemote)).toEqual(['ssh', '-o BatchMode=yes', '-o ConnectTimeout=10']);
|
||||
});
|
||||
|
||||
it('emits the full aa-desktop option set in order with escaping + %h %p intact', () => {
|
||||
const args = buildSshConnectionArgs(aaDesktop);
|
||||
const joined = args.join(' ');
|
||||
|
||||
// -p before -i before the proxy -o; identity ~ expanded absolute, then escaped.
|
||||
expect(joined).toContain('-o BatchMode=yes');
|
||||
expect(joined).toContain('-p 2222');
|
||||
expect(joined).toContain(`-i '${HOME}/.ssh/remote_ed25519'`);
|
||||
// No literal tilde survives into the -i token.
|
||||
expect(joined).not.toContain('-i ~');
|
||||
expect(joined).not.toContain("-i '~");
|
||||
|
||||
// The whole ProxyCommand (with its spaces and %h %p) is ONE shellescaped -o token.
|
||||
expect(joined).toContain("-o 'ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p'");
|
||||
// %h %p must survive verbatim — they are ssh tokens, not shell tokens.
|
||||
expect(joined).toContain('%h %p');
|
||||
|
||||
// The escape-hatch extra option, shellescaped.
|
||||
expect(joined).toContain("-o 'StrictHostKeyChecking=accept-new'");
|
||||
|
||||
// Ordering: BatchMode -> port -> identity -> ProxyCommand -> extras.
|
||||
const idxBatch = joined.indexOf('BatchMode=yes');
|
||||
const idxPort = joined.indexOf('-p 2222');
|
||||
const idxIdentity = joined.indexOf('-i ');
|
||||
const idxProxy = joined.indexOf('ProxyCommand=');
|
||||
const idxExtra = joined.indexOf('StrictHostKeyChecking');
|
||||
expect(idxBatch).toBeLessThan(idxPort);
|
||||
expect(idxPort).toBeLessThan(idxIdentity);
|
||||
expect(idxIdentity).toBeLessThan(idxProxy);
|
||||
expect(idxProxy).toBeLessThan(idxExtra);
|
||||
});
|
||||
|
||||
it('supports an explicit -J jump host (shellescaped, like its siblings)', () => {
|
||||
const args = buildSshConnectionArgs({ ...baseRemote, jumpHost: 'bastion@10.0.0.1:22' });
|
||||
expect(args.join(' ')).toContain("-J 'bastion@10.0.0.1:22'");
|
||||
});
|
||||
|
||||
it('shellescapes a -J jump host containing shell metacharacters (no injection)', () => {
|
||||
// Defense-in-depth: even if a metachar-laden value slipped past schema validation,
|
||||
// it must stay a single shell token and never break out of the ssh command.
|
||||
const args = buildSshConnectionArgs({ ...baseRemote, jumpHost: 'x; touch /tmp/pwned' });
|
||||
const joined = args.join(' ');
|
||||
// The whole value is wrapped in single quotes — the `;` cannot start a new command.
|
||||
expect(joined).toContain("-J 'x; touch /tmp/pwned'");
|
||||
expect(joined).not.toContain('-J x;');
|
||||
});
|
||||
|
||||
it('expands a $HOME-prefixed identity path', () => {
|
||||
const args = buildSshConnectionArgs({ ...baseRemote, identityFile: '$HOME/.ssh/id_ed25519' });
|
||||
expect(args.join(' ')).toContain(`-i '${HOME}/.ssh/id_ed25519'`);
|
||||
});
|
||||
|
||||
it('empty options ⇒ BatchMode + the default ConnectTimeout (+ -p only when set)', () => {
|
||||
expect(buildSshConnectionArgs(baseRemote)).toEqual(['ssh', '-o BatchMode=yes', '-o ConnectTimeout=10']);
|
||||
expect(buildSshConnectionArgs({ ...baseRemote, port: 2200 })).toEqual([
|
||||
'ssh',
|
||||
'-o BatchMode=yes',
|
||||
'-o ConnectTimeout=10',
|
||||
'-p 2200',
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits the default ConnectTimeout when extraSshOptions already sets it (operator wins)', () => {
|
||||
const args = buildSshConnectionArgs({ ...baseRemote, extraSshOptions: ['ConnectTimeout=3'] });
|
||||
expect(args.filter((a) => a.includes('ConnectTimeout'))).toEqual(["-o 'ConnectTimeout=3'"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('COD-107 buildRemoteLaunchCommand — threads connection args', () => {
|
||||
it('emits the aa-desktop ssh connection options ahead of -t and the target', () => {
|
||||
const command = buildRemoteLaunchCommand({ mode: 'shell', remote: aaDesktop, sessionId: SESSION_ID });
|
||||
|
||||
expect(command).toContain('-p 2222');
|
||||
expect(command).toContain(`-i '${HOME}/.ssh/remote_ed25519'`);
|
||||
expect(command).toContain("-o 'ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p'");
|
||||
expect(command).toContain("-o 'StrictHostKeyChecking=accept-new'");
|
||||
expect(command).toContain('-t');
|
||||
expect(command).toContain('aakht@192.168.55.170');
|
||||
expect(command).toContain('tmux -L codeman-remote new-session -A');
|
||||
|
||||
// Connection options come BEFORE -t / the target / the tmux command.
|
||||
const idxProxy = command.indexOf('ProxyCommand=');
|
||||
const idxTarget = command.indexOf('aakht@192.168.55.170');
|
||||
expect(idxProxy).toBeLessThan(idxTarget);
|
||||
});
|
||||
|
||||
it('a remote with no advanced options is byte-identical to the expected form', () => {
|
||||
const command = buildRemoteLaunchCommand({ mode: 'shell', remote: baseRemote, sessionId: SESSION_ID });
|
||||
// Reconstruct the command using the SAME nested POSIX single-quote escaping the
|
||||
// production code uses, to prove byte-identity. Session runs on the DEDICATED
|
||||
// `-L codeman-remote` socket under a `codeman-ssh-` name that a remote Codeman's
|
||||
// discovery ignores; set-options are scoped per-session (never `-g`).
|
||||
const sh = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'";
|
||||
const remoteName = `codeman-ssh-${SESSION_ID.slice(0, 8)}`;
|
||||
const path = sh('/home/ubuntu/work');
|
||||
const paneCommand = `cd ${path} && exec bash -l`;
|
||||
const tmuxInvocation = [
|
||||
`tmux -L codeman-remote new-session -A -s ${remoteName} -c ${path} ${sh(paneCommand)}`,
|
||||
`set -t ${remoteName} status off`,
|
||||
`set -t ${remoteName} mouse off`,
|
||||
`set -t ${remoteName} prefix C-q`,
|
||||
'set -s escape-time 0',
|
||||
].join(' \\; ');
|
||||
// Connection args (with the default -o ConnectTimeout=10) sit after -t.
|
||||
const expected = `ssh -o BatchMode=yes -t -o ConnectTimeout=10 ${remoteSshTarget(baseRemote)} ${sh(tmuxInvocation)}`;
|
||||
expect(command).toBe(expected);
|
||||
});
|
||||
|
||||
it('port-only remote places -p after the -t/ConnectTimeout tokens', () => {
|
||||
const command = buildRemoteLaunchCommand({
|
||||
mode: 'shell',
|
||||
remote: { ...baseRemote, port: 2222 },
|
||||
sessionId: SESSION_ID,
|
||||
});
|
||||
expect(command).toMatch(/^ssh -o BatchMode=yes -t -o ConnectTimeout=10 -p 2222 ubuntu@10\.0\.0\.42 /);
|
||||
});
|
||||
});
|
||||
|
||||
describe('COD-107 buildRemoteTmuxCheckCommand — same connection options as the launch', () => {
|
||||
it('uses the shared connection args (proxy/identity/port) plus ConnectTimeout', () => {
|
||||
const cmd = buildRemoteTmuxCheckCommand(aaDesktop);
|
||||
expect(cmd).toContain('-o BatchMode=yes');
|
||||
expect(cmd).toContain('-o ConnectTimeout=10');
|
||||
expect(cmd).toContain('-p 2222');
|
||||
expect(cmd).toContain(`-i '${HOME}/.ssh/remote_ed25519'`);
|
||||
expect(cmd).toContain("-o 'ProxyCommand=nc -X 5 -x 127.0.0.1:1080 %h %p'");
|
||||
expect(cmd).toContain('aakht@192.168.55.170');
|
||||
expect(cmd).toContain("'command -v tmux'");
|
||||
});
|
||||
|
||||
it('back-compat: no advanced options ⇒ unchanged probe string', () => {
|
||||
expect(buildRemoteTmuxCheckCommand({ username: 'ubuntu', host: '10.0.0.42' })).toBe(
|
||||
"ssh -o BatchMode=yes -o ConnectTimeout=10 ubuntu@10.0.0.42 'command -v tmux'"
|
||||
);
|
||||
expect(buildRemoteTmuxCheckCommand({ username: 'ubuntu', host: '10.0.0.42', port: 2222 })).toContain('-p 2222');
|
||||
});
|
||||
});
|
||||
@@ -53,15 +53,28 @@ vi.mock('../../src/hooks-config.js', () => ({
|
||||
writeHooksConfig: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
// Stub the remote-tmux prereq probe so remote-link tests never shell out to ssh
|
||||
// (readRemoteHosts/writeRemoteHosts stay real, backed by the mocked fs).
|
||||
vi.mock('../../src/remote-hosts.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../src/remote-hosts.js')>();
|
||||
return {
|
||||
...actual,
|
||||
checkRemoteTmuxAvailable: vi.fn(async () => ({ ok: true, tmuxPath: '/usr/bin/tmux' })),
|
||||
};
|
||||
});
|
||||
|
||||
// Import mocked modules for test control
|
||||
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { checkRemoteTmuxAvailable } from '../../src/remote-hosts.js';
|
||||
|
||||
const mockedExistsSync = vi.mocked(existsSync);
|
||||
const mockedMkdirSync = vi.mocked(mkdirSync);
|
||||
const mockedReaddirSync = vi.mocked(readdirSync);
|
||||
const mockedReaddir = vi.mocked(fs.readdir);
|
||||
const mockedReadFile = vi.mocked(fs.readFile);
|
||||
const mockedWriteFile = vi.mocked(fs.writeFile);
|
||||
const mockedCheckRemoteTmux = vi.mocked(checkRemoteTmuxAvailable);
|
||||
|
||||
interface CaseRouteHarness {
|
||||
app: FastifyInstance;
|
||||
@@ -199,6 +212,278 @@ describe('case-routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote host and remote case routes', () => {
|
||||
function setupRemoteConfigStore() {
|
||||
const store = new Map<string, string>();
|
||||
mockedReadFile.mockImplementation(async (path) => {
|
||||
const key = String(path);
|
||||
if (store.has(key)) return store.get(key) || '';
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
});
|
||||
mockedWriteFile.mockImplementation(async (path, data) => {
|
||||
store.set(String(path), String(data));
|
||||
});
|
||||
}
|
||||
|
||||
it('creates a remote host and lists it', async () => {
|
||||
setupRemoteConfigStore();
|
||||
|
||||
const create = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: {
|
||||
id: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
commands: { codex: 'exec codx personal' },
|
||||
},
|
||||
});
|
||||
expect(create.statusCode).toBe(200);
|
||||
expect(JSON.parse(create.body)).toMatchObject({ success: true });
|
||||
|
||||
const list = await harness.app.inject({ method: 'GET', url: '/api/remote-hosts' });
|
||||
expect(list.statusCode).toBe(200);
|
||||
expect(JSON.parse(list.body).data).toEqual([
|
||||
expect.objectContaining({ id: 'gpu-box', label: 'GPU Box', commands: { codex: 'exec codx personal' } }),
|
||||
]);
|
||||
});
|
||||
|
||||
// COD-107 — advanced SSH connection options (port, identity, SOCKS proxy,
|
||||
// jump host, escape-hatch -o options) round-trip through the host schema.
|
||||
it('persists advanced SSH options (port/identity/socks/jump/extra) on a remote host', async () => {
|
||||
setupRemoteConfigStore();
|
||||
|
||||
const create = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: {
|
||||
id: 'aa-desktop',
|
||||
label: 'aa-desktop',
|
||||
host: '192.168.55.170',
|
||||
username: 'aakht',
|
||||
port: 2222,
|
||||
identityFile: '~/.ssh/remote_ed25519',
|
||||
socksProxy: '127.0.0.1:1080',
|
||||
jumpHost: 'bastion@10.0.0.1:22',
|
||||
extraSshOptions: ['StrictHostKeyChecking=accept-new'],
|
||||
},
|
||||
});
|
||||
expect(create.statusCode).toBe(200);
|
||||
expect(JSON.parse(create.body)).toMatchObject({ success: true });
|
||||
|
||||
const list = await harness.app.inject({ method: 'GET', url: '/api/remote-hosts' });
|
||||
expect(JSON.parse(list.body).data).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'aa-desktop',
|
||||
port: 2222,
|
||||
identityFile: '~/.ssh/remote_ed25519',
|
||||
socksProxy: '127.0.0.1:1080',
|
||||
jumpHost: 'bastion@10.0.0.1:22',
|
||||
extraSshOptions: ['StrictHostKeyChecking=accept-new'],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects a malformed extraSshOptions entry (not KEY=VALUE) with INVALID_INPUT', async () => {
|
||||
setupRemoteConfigStore();
|
||||
|
||||
const create = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: {
|
||||
id: 'bad-host',
|
||||
label: 'bad',
|
||||
host: '10.0.0.9',
|
||||
username: 'ubuntu',
|
||||
extraSshOptions: ['not a valid option'],
|
||||
},
|
||||
});
|
||||
expect(create.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.INVALID_INPUT));
|
||||
expect(JSON.parse(create.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
|
||||
it('rejects a malformed socksProxy (missing port) with INVALID_INPUT', async () => {
|
||||
setupRemoteConfigStore();
|
||||
|
||||
const create = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: { id: 'bad2', label: 'bad2', host: '10.0.0.9', username: 'ubuntu', socksProxy: '127.0.0.1' },
|
||||
});
|
||||
expect(create.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.INVALID_INPUT));
|
||||
expect(JSON.parse(create.body)).toMatchObject({ success: false });
|
||||
});
|
||||
|
||||
it('links a remote case and includes it in GET /api/cases', async () => {
|
||||
setupRemoteConfigStore();
|
||||
mockedReaddir.mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: { id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' },
|
||||
});
|
||||
|
||||
const link = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/cases/remote-link',
|
||||
payload: { name: 'gpu-work', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' },
|
||||
});
|
||||
expect(link.statusCode).toBe(200);
|
||||
|
||||
const cases = await harness.app.inject({ method: 'GET', url: '/api/cases' });
|
||||
expect(JSON.parse(cases.body).data).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: 'gpu-work',
|
||||
location: 'remote',
|
||||
path: 'ubuntu@10.0.0.42:/home/ubuntu/work',
|
||||
remote: expect.objectContaining({ hostId: 'gpu-box', path: '/home/ubuntu/work' }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers remote case metadata over a same-name local managed case', async () => {
|
||||
setupRemoteConfigStore();
|
||||
mockedReaddir.mockResolvedValue([{ name: 'gpu-work', isDirectory: () => true }] as never);
|
||||
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: { id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' },
|
||||
});
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/cases/remote-link',
|
||||
payload: { name: 'gpu-work', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' },
|
||||
});
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
|
||||
const cases = await harness.app.inject({ method: 'GET', url: '/api/cases' });
|
||||
expect(JSON.parse(cases.body).data).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: 'gpu-work',
|
||||
location: 'remote',
|
||||
path: 'ubuntu@10.0.0.42:/home/ubuntu/work',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes remote case metadata only', async () => {
|
||||
setupRemoteConfigStore();
|
||||
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: { id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' },
|
||||
});
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/cases/remote-link',
|
||||
payload: { name: 'gpu-work', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' },
|
||||
});
|
||||
|
||||
const deleted = await harness.app.inject({ method: 'DELETE', url: '/api/cases/gpu-work' });
|
||||
expect(deleted.statusCode).toBe(200);
|
||||
expect(JSON.parse(deleted.body)).toEqual({ success: true, data: { name: 'gpu-work' } });
|
||||
});
|
||||
|
||||
// Injection hardening: remotePath/identityFile are shell-escaped, then embedded
|
||||
// via JSON.stringify() inside `bash -c "..."` — a DOUBLE-quote layer that
|
||||
// re-exposes `$(...)`/backticks even inside the inner single quotes. The schema
|
||||
// MUST reject those before they reach the launch command.
|
||||
it('rejects an identityFile containing $(...) command substitution', async () => {
|
||||
setupRemoteConfigStore();
|
||||
|
||||
const create = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: {
|
||||
id: 'evil-host',
|
||||
label: 'evil',
|
||||
host: '10.0.0.9',
|
||||
username: 'ubuntu',
|
||||
identityFile: '/home/u/$(touch /tmp/pwned)',
|
||||
},
|
||||
});
|
||||
expect(create.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.INVALID_INPUT));
|
||||
expect(JSON.parse(create.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
|
||||
it('rejects an identityFile containing a backtick', async () => {
|
||||
setupRemoteConfigStore();
|
||||
|
||||
const create = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: {
|
||||
id: 'evil-host2',
|
||||
label: 'evil2',
|
||||
host: '10.0.0.9',
|
||||
username: 'ubuntu',
|
||||
identityFile: '/home/u/`touch /tmp/pwned`',
|
||||
},
|
||||
});
|
||||
expect(create.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.INVALID_INPUT));
|
||||
expect(JSON.parse(create.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
|
||||
it('rejects a remotePath containing $(...) command substitution', async () => {
|
||||
setupRemoteConfigStore();
|
||||
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: { id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' },
|
||||
});
|
||||
const link = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/cases/remote-link',
|
||||
payload: { name: 'gpu-work', hostId: 'gpu-box', remotePath: '/tmp/$(touch /tmp/pwned)' },
|
||||
});
|
||||
expect(link.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.INVALID_INPUT));
|
||||
expect(JSON.parse(link.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
|
||||
it('rejects a remotePath containing a backtick', async () => {
|
||||
setupRemoteConfigStore();
|
||||
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: { id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' },
|
||||
});
|
||||
const link = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/cases/remote-link',
|
||||
payload: { name: 'gpu-work', hostId: 'gpu-box', remotePath: '/tmp/`touch /tmp/pwned`' },
|
||||
});
|
||||
expect(link.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.INVALID_INPUT));
|
||||
expect(JSON.parse(link.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
|
||||
it('refuses remote-link when the remote host lacks tmux (courtesy prereq probe)', async () => {
|
||||
setupRemoteConfigStore();
|
||||
mockedCheckRemoteTmux.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: 'remote host 10.0.0.42 needs tmux installed for durable remote sessions',
|
||||
});
|
||||
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/remote-hosts',
|
||||
payload: { id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' },
|
||||
});
|
||||
const link = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/cases/remote-link',
|
||||
payload: { name: 'gpu-work', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' },
|
||||
});
|
||||
expect(link.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.OPERATION_FAILED));
|
||||
expect(JSON.parse(link.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.OPERATION_FAILED });
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/cases ==========
|
||||
|
||||
describe('POST /api/cases', () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import fastifyCookie from '@fastify/cookie';
|
||||
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
||||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||||
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
||||
import { Session } from '../../src/session.js';
|
||||
|
||||
// Mock execFile so the send-key route's `tmux` invocation is observable (not run for real).
|
||||
const { execFile } = vi.hoisted(() => ({ execFile: vi.fn() }));
|
||||
@@ -27,6 +28,23 @@ vi.mock('node:child_process', async (orig) => {
|
||||
return { ...actual, execFile };
|
||||
});
|
||||
|
||||
// In-memory remote store so remote-case tests can inject hosts/cases without real JSON files.
|
||||
const remoteStore = vi.hoisted(() => ({
|
||||
hosts: [] as unknown[],
|
||||
cases: [] as unknown[],
|
||||
tmuxCheck: { ok: true, tmuxPath: '/usr/bin/tmux' } as { ok: boolean; tmuxPath?: string; error?: string },
|
||||
}));
|
||||
vi.mock('../../src/remote-hosts.js', async (orig) => {
|
||||
const actual = await orig<typeof import('../../src/remote-hosts.js')>();
|
||||
return {
|
||||
...actual,
|
||||
readRemoteHosts: vi.fn(async () => remoteStore.hosts),
|
||||
readRemoteCases: vi.fn(async () => remoteStore.cases),
|
||||
// Stub the remote-tmux prereq probe so quick-start never shells out to ssh.
|
||||
checkRemoteTmuxAvailable: vi.fn(async () => remoteStore.tmuxCheck),
|
||||
};
|
||||
});
|
||||
|
||||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||||
|
||||
interface LocalHarness {
|
||||
@@ -78,6 +96,10 @@ describe('session-routes', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createEnvelopeHarness(registerSessionRoutes);
|
||||
// Reset remote store so tests start with empty hosts/cases and a passing tmux probe
|
||||
remoteStore.hosts = [];
|
||||
remoteStore.cases = [];
|
||||
remoteStore.tmuxCheck = { ok: true, tmuxPath: '/usr/bin/tmux' };
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -765,6 +787,142 @@ describe('session-routes', () => {
|
||||
// ========== POST /api/sessions (with resumeSessionId) ==========
|
||||
|
||||
describe('POST /api/sessions with resumeSessionId', () => {
|
||||
it('creates session from a remote case without local stat validation', async () => {
|
||||
// Remote cases go through /api/quick-start which skips local stat() of the workingDir.
|
||||
// /api/sessions always requires workingDir to exist on the local filesystem.
|
||||
const startShell = vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
|
||||
try {
|
||||
remoteStore.hosts = [
|
||||
{
|
||||
id: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
commands: { codex: 'exec codx personal' },
|
||||
},
|
||||
];
|
||||
remoteStore.cases = [{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' }];
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/quick-start',
|
||||
payload: { caseName: 'gpu-work', mode: 'shell', name: 'Remote Shell' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.casePath).toBe('/home/ubuntu/work');
|
||||
const session = [...harness.ctx.sessions.values()].find((item) => item.id === body.data.sessionId);
|
||||
expect(session?.toState()).toMatchObject({
|
||||
workingDir: '/home/ubuntu/work',
|
||||
remote: expect.objectContaining({
|
||||
hostId: 'gpu-box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/work',
|
||||
commands: { codex: 'exec codx personal' },
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
startShell.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('quick-start creates remote case sessions through ssh metadata', async () => {
|
||||
const startShell = vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
|
||||
try {
|
||||
remoteStore.hosts = [
|
||||
{
|
||||
id: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
commands: { codex: 'exec codx personal' },
|
||||
},
|
||||
];
|
||||
remoteStore.cases = [{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' }];
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/quick-start',
|
||||
payload: { caseName: 'gpu-work', mode: 'shell' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.casePath).toBe('/home/ubuntu/work');
|
||||
const session = [...harness.ctx.sessions.values()].find((item) => item.id === body.data.sessionId);
|
||||
expect(session?.toState()).toMatchObject({
|
||||
workingDir: '/home/ubuntu/work',
|
||||
remote: expect.objectContaining({
|
||||
hostId: 'gpu-box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/work',
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
startShell.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a remote quick-start that carries envOverrides (inert over ssh)', async () => {
|
||||
remoteStore.hosts = [{ id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' }];
|
||||
remoteStore.cases = [{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' }];
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/quick-start',
|
||||
payload: { caseName: 'gpu-work', mode: 'claude', envOverrides: { CLAUDE_CODE_FOO: 'bar' } },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.INVALID_INPUT));
|
||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
|
||||
it('rejects a remote quick-start when the remote host lacks tmux', async () => {
|
||||
remoteStore.hosts = [{ id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu' }];
|
||||
remoteStore.cases = [{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' }];
|
||||
remoteStore.tmuxCheck = {
|
||||
ok: false,
|
||||
error: 'remote host 10.0.0.42 needs tmux installed for durable remote sessions',
|
||||
};
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/quick-start',
|
||||
payload: { caseName: 'gpu-work', mode: 'shell' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(httpStatusForErrorCode(ApiErrorCode.OPERATION_FAILED));
|
||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.OPERATION_FAILED });
|
||||
});
|
||||
|
||||
it('does not run local codex availability check for a remote codex case', async () => {
|
||||
// A remote codex case must NOT be blocked by the LOCAL codex availability gate
|
||||
// (the CLI runs on the remote host). Probe is stubbed ok in remoteStore.tmuxCheck.
|
||||
const startInteractive = vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined);
|
||||
try {
|
||||
remoteStore.hosts = [
|
||||
{ id: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu', commands: { codex: 'exec codx' } },
|
||||
];
|
||||
remoteStore.cases = [{ name: 'gpu-work', type: 'remote', hostId: 'gpu-box', remotePath: '/home/ubuntu/work' }];
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/quick-start',
|
||||
payload: { caseName: 'gpu-work', mode: 'codex' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.body).success).toBe(true);
|
||||
} finally {
|
||||
startInteractive.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('creates session with valid resumeSessionId', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
import { resolveMuxAttachCwd, Session } from '../src/session.js';
|
||||
import type { SessionAttachmentHistoryItem } from '../src/types/session.js';
|
||||
import {
|
||||
ATTACHMENT_HISTORY_LIMIT,
|
||||
@@ -161,4 +161,37 @@ describe('session attachment history', () => {
|
||||
expect(persisted).toHaveLength(1);
|
||||
expect(persisted?.[0].fileName).toBe('ok.png');
|
||||
});
|
||||
|
||||
it('attaches remote mux sessions from a local cwd', () => {
|
||||
expect(
|
||||
resolveMuxAttachCwd('/Users/remote/project', {
|
||||
hostId: 'mac-mini',
|
||||
label: 'Mac Mini',
|
||||
host: '192.168.21.109',
|
||||
username: 'saqebakhter',
|
||||
remotePath: '/Users/remote/project',
|
||||
})
|
||||
).toBe('/tmp');
|
||||
expect(resolveMuxAttachCwd('/opt/projects/Codeman')).toBe('/opt/projects/Codeman');
|
||||
});
|
||||
|
||||
it('round-trips remote metadata through the Session constructor (restart-recovery contract)', () => {
|
||||
// restoreMuxSessions() reconstructs recovered sessions via
|
||||
// `new Session({ ..., remote: muxSession.remote ?? savedState.remote })`. If that
|
||||
// remote does not survive toState(), the next persistSessionState() erases it from
|
||||
// state.json AND the attach cwd falls back to the (nonexistent-locally) remote path.
|
||||
const remote = {
|
||||
hostId: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/work',
|
||||
commands: { claude: 'exec claude --dangerously-skip-permissions' },
|
||||
};
|
||||
const restored = new Session({ workingDir: '/home/ubuntu/work', remote });
|
||||
const state = restored.toState();
|
||||
expect(state.remote).toEqual(remote);
|
||||
// Recovered attach cwd must be a LOCAL path, never the remote-only workingDir.
|
||||
expect(resolveMuxAttachCwd(state.workingDir, state.remote)).toBe('/tmp');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,14 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { TmuxManager, formatPaneSnapshot, parsePaneList, resolveActivePaneTarget } from '../src/tmux-manager.js';
|
||||
import {
|
||||
TmuxManager,
|
||||
buildRemoteKillCommand,
|
||||
buildRemoteLaunchCommand,
|
||||
formatPaneSnapshot,
|
||||
parsePaneList,
|
||||
resolveActivePaneTarget,
|
||||
} from '../src/tmux-manager.js';
|
||||
import { execSync, exec } from 'node:child_process';
|
||||
|
||||
// ============================================================================
|
||||
@@ -91,6 +98,80 @@ describe('TmuxManager (unit)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote launch command builder', () => {
|
||||
it('wraps codex command overrides in ssh with remote tmux launch', () => {
|
||||
const command = buildRemoteLaunchCommand({
|
||||
mode: 'codex',
|
||||
remote: {
|
||||
hostId: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/work',
|
||||
commands: { codex: 'exec codx personal' },
|
||||
},
|
||||
sessionId: 'abc123def456',
|
||||
});
|
||||
|
||||
expect(command).toContain('ssh');
|
||||
expect(command).toContain('BatchMode=yes');
|
||||
expect(command).toContain('ubuntu@10.0.0.42');
|
||||
expect(command).toContain('/home/ubuntu/work');
|
||||
// Dedicated socket + a name that fails a remote Codeman's SAFE_MUX_NAME_PATTERN.
|
||||
expect(command).toContain('tmux -L codeman-remote new-session -A -s codeman-ssh-abc123de');
|
||||
expect(command).toContain('exec codx personal');
|
||||
// Session options are scoped per-session, never global (-g).
|
||||
expect(command).not.toContain('set -g');
|
||||
});
|
||||
|
||||
it('uses default shell command when no override is configured', () => {
|
||||
const command = buildRemoteLaunchCommand({
|
||||
mode: 'shell',
|
||||
remote: {
|
||||
hostId: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/work',
|
||||
},
|
||||
sessionId: 'abc123def456',
|
||||
});
|
||||
|
||||
expect(command).toContain('exec bash -l');
|
||||
});
|
||||
|
||||
it('defaults claude to a non-interactive launch (--dangerously-skip-permissions)', () => {
|
||||
const command = buildRemoteLaunchCommand({
|
||||
mode: 'claude',
|
||||
remote: { hostId: 'gpu-box', label: 'GPU Box', host: '10.0.0.42', username: 'ubuntu', remotePath: '/w' },
|
||||
sessionId: 'abc123def456',
|
||||
});
|
||||
expect(command).toContain('exec claude --dangerously-skip-permissions');
|
||||
});
|
||||
});
|
||||
|
||||
describe('remote kill command builder', () => {
|
||||
it('kills the durable remote tmux session on the dedicated socket via ssh', () => {
|
||||
const command = buildRemoteKillCommand({
|
||||
remote: {
|
||||
hostId: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/work',
|
||||
},
|
||||
sessionId: 'abc123def456',
|
||||
});
|
||||
|
||||
expect(command).toContain('ssh');
|
||||
// Shares the default ConnectTimeout so an unreachable host fails fast (never blocks kill).
|
||||
expect(command).toContain('-o ConnectTimeout=10');
|
||||
expect(command).toContain('ubuntu@10.0.0.42');
|
||||
expect(command).toContain('tmux -L codeman-remote kill-session -t');
|
||||
expect(command).toContain('codeman-ssh-abc123de');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAttachCommand', () => {
|
||||
it('should return tmux', () => {
|
||||
expect(manager.getAttachCommand()).toBe('tmux');
|
||||
|
||||
@@ -66,7 +66,14 @@ describe('TmuxManager restart recovery (test mode safety)', () => {
|
||||
mode: 'claude',
|
||||
attached: false,
|
||||
name: 'Recovery Test',
|
||||
respawnConfig: { enabled: true, idleTimeoutMs: 10000, updatePrompt: 'continue', interStepDelayMs: 2000, sendClear: false, sendInit: true },
|
||||
respawnConfig: {
|
||||
enabled: true,
|
||||
idleTimeoutMs: 10000,
|
||||
updatePrompt: 'continue',
|
||||
interStepDelayMs: 2000,
|
||||
sendClear: false,
|
||||
sendInit: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.reconcileSessions();
|
||||
@@ -86,6 +93,34 @@ describe('TmuxManager restart recovery (test mode safety)', () => {
|
||||
expect(result.discovered).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('preserves remote SSH metadata across reconcile (mux-sessions.json round-trip source)', async () => {
|
||||
manager.registerSession({
|
||||
sessionId: 'remote-recovery-1',
|
||||
muxName: 'codeman-de51ecaf',
|
||||
pid: 1,
|
||||
createdAt: Date.now(),
|
||||
workingDir: '/home/ubuntu/work',
|
||||
mode: 'claude',
|
||||
attached: false,
|
||||
name: 'Remote Recovery',
|
||||
remote: {
|
||||
hostId: 'gpu-box',
|
||||
label: 'GPU Box',
|
||||
host: '10.0.0.42',
|
||||
username: 'ubuntu',
|
||||
remotePath: '/home/ubuntu/work',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.reconcileSessions();
|
||||
expect(result.alive).toContain('remote-recovery-1');
|
||||
|
||||
// restoreMuxSessions() reads MuxSession.remote off exactly this map to rebuild
|
||||
// the recovered Session — if it were dropped here the session would respawn LOCAL.
|
||||
const recovered = manager.getSession('remote-recovery-1');
|
||||
expect(recovered?.remote).toMatchObject({ hostId: 'gpu-box', host: '10.0.0.42', remotePath: '/home/ubuntu/work' });
|
||||
});
|
||||
|
||||
it('should not execute any tmux commands in test mode', async () => {
|
||||
manager.registerSession({
|
||||
sessionId: 'alive-session',
|
||||
@@ -101,9 +136,7 @@ describe('TmuxManager restart recovery (test mode safety)', () => {
|
||||
await manager.reconcileSessions();
|
||||
|
||||
// Verify no tmux commands were executed
|
||||
const tmuxCalls = mockedExecSync.mock.calls.filter(
|
||||
([cmd]) => typeof cmd === 'string' && cmd.includes('tmux')
|
||||
);
|
||||
const tmuxCalls = mockedExecSync.mock.calls.filter(([cmd]) => typeof cmd === 'string' && cmd.includes('tmux'));
|
||||
expect(tmuxCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -155,9 +188,7 @@ describe('TmuxManager restart recovery (test mode safety)', () => {
|
||||
expect(manager.getSession('kill-me')).toBeUndefined();
|
||||
|
||||
// Verify no real kill commands were executed
|
||||
const killCalls = mockedExecSync.mock.calls.filter(
|
||||
([cmd]) => typeof cmd === 'string' && cmd.includes('kill')
|
||||
);
|
||||
const killCalls = mockedExecSync.mock.calls.filter(([cmd]) => typeof cmd === 'string' && cmd.includes('kill'));
|
||||
expect(killCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user