COD-107 remote SSH: custom port + advanced connection options (escape hatch)

The Remote case form could only reach port-22, default-identity, directly
SSH-able hosts. Add an escape-hatch set of SSH connection options so Codeman
can reach a host like aa-desktop (custom port 2222, ed25519 identity, cloudflared
SOCKS5 ProxyCommand) the way ssh-aa-desktop does — without shelling out to that
wrapper.

- Model (types/session.ts): new optional RemoteSshOptions (identityFile,
  socksProxy, jumpHost, extraSshOptions) on RemoteHost AND SessionRemote; all
  absent = today's behavior. toSessionRemote() carries them case->session.
- Shared buildSshConnectionArgs(remote) in remote-hosts.ts: pure, exported,
  ordered ssh connection tokens (-o BatchMode=yes, -p, -i <abs identity with
  ~/$HOME expanded + shellescaped>, -J, -o ProxyCommand=nc -X 5 -x <socks>
  %h %p emitted as ONE shellescaped token so %h %p reach ssh literally, then
  each extraSshOptions -o). Both buildRemoteLaunchCommand (tmux-manager.ts) and
  buildRemoteTmuxCheckCommand now use it, so the prereq probe and the real
  launch connect identically. checkRemoteTmuxAvailable widened to accept the
  options (callers already pass the full host).
- Validation (schemas.ts): identityFile (no newline/NUL), socksProxy
  (host:port), jumpHost (no shell metachars), extraSshOptions (KEY=VALUE,
  reject newline/NUL/backtick/$() — defense-in-depth on operator-entered config.
- UI (index.html + session-ui.js): SSH Port field + collapsible "Advanced SSH"
  section (identity, SOCKS proxy, jump host, extra -o options one per line);
  wired into the remote-host create payload.

Empty-options remotes emit byte-identical ssh to before (pinned by test).

Tests: test/remote-ssh-options.test.ts (buildSshConnectionArgs +
buildRemoteLaunchCommand + buildRemoteTmuxCheckCommand for the aa-desktop set,
escaping/%h %p/identity-~ expansion, byte-identical back-compat); case-routes
schema tests (advanced options round-trip; malformed extraSshOptions/socksProxy
rejected). tsc/eslint/frontend-syntax/prettier/build clean.

Acceptance (real remote, no wrapper): the emitted command connected to
aa-desktop through the cloudflared SOCKS proxy and created a durable remote
tmux session (verified independently via ssh-aa-desktop: CONNECTED_NO_WRAPPER,
STILL_ALIVE_AFTER_DETACH); checkRemoteTmuxAvailable over the proxy returned
{ok:true, tmuxPath:/usr/local/bin/tmux}; test session cleaned up.
This commit is contained in:
Aamer Akhter
2026-07-09 09:16:57 -04:00
parent 26e78daf58
commit 268a0bbdbd
10 changed files with 598 additions and 17 deletions
+7
View File
@@ -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;
}
/**
+142 -1
View File
@@ -1,7 +1,17 @@
import { existsSync, mkdirSync } from 'node:fs';
import fs from 'node:fs/promises';
import { join } from 'node:path';
import type { RemoteCase, RemoteCommandMode, RemoteHost, SessionMode, SessionRemote } from './types.js';
import { homedir } from 'node:os';
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import type {
RemoteCase,
RemoteCommandMode,
RemoteHost,
RemoteSshOptions,
SessionMode,
SessionRemote,
} from './types.js';
const REMOTE_HOSTS_FILE = 'remote-hosts.json';
const REMOTE_CASES_FILE = 'remote-cases.json';
@@ -60,6 +70,131 @@ export function remoteSshTarget(host: Pick<RemoteHost, 'username' | 'host'>): st
return `${host.username}@${host.host}`;
}
/**
* POSIX single-quote shell-escaping (end-quote, escaped-quote, restart-quote).
* Mirrors the helper in tmux-manager.ts so a value with spaces/metachars stays a
* single shell token. Used here for identity paths and `-o KEY=VALUE` options.
*/
function shellescape(str: string): string {
return "'" + str.replace(/'/g, "'\\''") + "'";
}
/**
* Expand a leading `~` or `$HOME` in an identity path to an absolute path.
*
* ssh does NOT expand `~` inside `-i` (the shell would, but we shellescape the
* value into a single quoted token so the shell never sees it). So we expand at
* build time, before escaping. Non-`~`/`$HOME` paths are returned unchanged.
*/
function expandIdentityPath(identityFile: string): string {
if (identityFile === '~') return homedir();
if (identityFile.startsWith('~/')) return join(homedir(), identityFile.slice(2));
if (identityFile === '$HOME') return homedir();
if (identityFile.startsWith('$HOME/')) return join(homedir(), identityFile.slice('$HOME/'.length));
return identityFile;
}
/**
* COD-107 — build the ordered, shell-safe ssh CONNECTION tokens shared by both
* the durable-launch command (`buildRemoteLaunchCommand`) and the tmux
* prerequisite probe (`buildRemoteTmuxCheckCommand`), so the prereq check and
* the real launch connect with IDENTICAL options (they can't drift).
*
* Returns the leading tokens of an ssh command line (NOT including `-t`, the
* target, or any remote command). Order:
* ssh -o BatchMode=yes
* [-p <port>]
* [-i <abs-identity>] (~/$HOME expanded, then shellescaped)
* [-J <jumpHost>]
* [-o ProxyCommand=nc -X 5 -x <socks> %h %p] (ONE shellescaped -o token)
* [-o <KEY=VALUE>] … (each extra option, shellescaped)
*
* Escaping notes (the risky part):
* - The ProxyCommand is emitted as a single shellescaped `-o KEY=VALUE`, so the
* whole value (spaces + `%h`/`%p`) reaches ssh as one argument and `%h %p`
* survive verbatim — ssh expands them to the real host/port, not the shell.
* - Empty options ⇒ `['ssh', '-o BatchMode=yes']` (+ `-p` only when set), i.e.
* byte-identical to the historical behavior.
*/
export function buildSshConnectionArgs(remote: RemoteSshOptions & Pick<RemoteHost, 'port'>): string[] {
const parts: string[] = ['ssh', '-o BatchMode=yes'];
if (remote.port) parts.push(`-p ${remote.port}`);
if (remote.identityFile) parts.push(`-i ${shellescape(expandIdentityPath(remote.identityFile))}`);
if (remote.jumpHost) parts.push(`-J ${remote.jumpHost}`);
if (remote.socksProxy) {
parts.push(`-o ${shellescape(`ProxyCommand=nc -X 5 -x ${remote.socksProxy} %h %p`)}`);
}
for (const opt of remote.extraSshOptions ?? []) {
parts.push(`-o ${shellescape(opt)}`);
}
return parts;
}
/**
* COD-104 — build the SSH command that checks the remote host has tmux.
*
* Durable remote sessions run the agent inside a tmux server ON the remote host
* (`tmux -L codeman new-session -A …`), so tmux is now a hard prerequisite there.
* `command -v tmux` exits 0 (and prints the path) when tmux is installed.
*
* COD-107 — connects with the SAME options as the real launch
* (`buildSshConnectionArgs`) so a proxied/custom-port/identity host that the
* launch can reach also passes the prereq probe (and vice-versa).
*/
export function buildRemoteTmuxCheckCommand(
host: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions
): string {
const [ssh, ...connectionArgs] = buildSshConnectionArgs(host);
const parts = [ssh, connectionArgs[0], '-o ConnectTimeout=10', ...connectionArgs.slice(1)];
parts.push(remoteSshTarget(host), "'command -v tmux'");
return parts.join(' ');
}
export interface RemoteTmuxCheckResult {
ok: boolean;
/** Resolved tmux path on the remote (when ok). */
tmuxPath?: string;
/** Human-readable failure reason (when !ok). */
error?: string;
}
/**
* COD-104 — verify the remote host has tmux installed (required for durable
* remote sessions). Returns a structured result with a clear, user-facing error
* when tmux is missing or the host is unreachable. Never throws.
*/
export async function checkRemoteTmuxAvailable(
host: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions
): Promise<RemoteTmuxCheckResult> {
const command = buildRemoteTmuxCheckCommand(host);
try {
const { stdout } = await execAsync(command, { timeout: 15_000 });
const tmuxPath = stdout.trim();
if (!tmuxPath) {
return {
ok: false,
error: `remote host ${host.host} needs tmux installed for durable remote sessions`,
};
}
return { ok: true, tmuxPath };
} catch (err) {
const stderr =
err && typeof err === 'object' && 'stderr' in err ? String((err as { stderr?: unknown }).stderr ?? '') : '';
// `command -v tmux` exits non-zero when tmux is absent (no stderr); a real
// connection failure surfaces ssh diagnostics on stderr.
if (stderr.trim()) {
return {
ok: false,
error: `could not verify tmux on remote host ${host.host}: ${stderr.trim()}`,
};
}
return {
ok: false,
error: `remote host ${host.host} needs tmux installed for durable remote sessions`,
};
}
}
export function remoteDisplayPath(
remote: Pick<SessionRemote, 'username' | 'host' | 'remotePath'> | { username: string; host: string; path: string }
): string {
@@ -76,5 +211,11 @@ export function toSessionRemote(host: RemoteHost, remoteCase: RemoteCase): Sessi
port: host.port,
remotePath: remoteCase.remotePath,
commands: host.commands,
// COD-107 — carry the advanced SSH options from host config into the session
// so the launch/prereq commands connect the same way the operator configured.
identityFile: host.identityFile,
socksProxy: host.socksProxy,
jumpHost: host.jumpHost,
extraSshOptions: host.extraSshOptions,
};
}
+81 -12
View File
@@ -45,7 +45,7 @@ import {
type SessionRemote,
} from './types.js';
import { buildEffortCliArgs } from './session-cli-builder.js';
import { defaultRemoteCommandForMode, remoteSshTarget } from './remote-hosts.js';
import { buildSshConnectionArgs, defaultRemoteCommandForMode, remoteSshTarget } from './remote-hosts.js';
import {
wrapWithNice,
SAFE_PATH_PATTERN,
@@ -671,14 +671,76 @@ function buildSpawnCommand(options: {
return '$SHELL';
}
export function buildRemoteLaunchCommand(options: { mode: SessionMode; remote: SessionRemote }): string {
const { mode, remote } = options;
/**
* Deterministic, reattach-stable remote tmux session name for a Codeman session.
*
* Derived from the same stable field the LOCAL muxName uses
* (`codeman-${sessionId.slice(0, 8)}`), so reconnecting (which re-issues the
* exact same `ssh … new-session -A`) lands back in the SAME remote session.
* Must NOT be random/time-based — it has to be stable across reconnects.
*/
export function remoteTmuxSessionName(sessionId: string): string {
return `codeman-${sessionId.slice(0, 8)}`;
}
/**
* COD-104 — build the SSH command that launches (or reattaches) a remote
* session INSIDE a tmux server on the remote host, so the remote agent survives
* an SSH drop.
*
* Emits:
* ssh -o BatchMode=yes -t [<COD-107 connection opts>] user@host \
* 'tmux -L codeman new-session -A -s codeman-<id> -c <path> "cd <path> && exec <cli>" \
* \; set -g status off \; set -g mouse off \; set -sg escape-time 0 \; set -g prefix C-q'
*
* COD-107 — the connection options (`-p`, `-i`, `-J`, SOCKS `-o ProxyCommand`,
* arbitrary `-o`) come from the shared `buildSshConnectionArgs(remote)`, so the
* prereq tmux probe and this launch connect with identical options.
*
* - `new-session -A -s codeman-<id>` = attach-if-exists-else-create (idempotent),
* so reconnect re-runs the same command and reattaches the still-running agent.
* - `-L codeman` = canonical remote socket (for Phase 2/3 discovery).
* - The whole tmux invocation is a SINGLE ssh argument (the remote login shell
* runs it), so it is shell-quoted as one unit; the `cd && exec` command is in
* turn a single tmux argument (tmux runs it via `/bin/sh -c`), so the path is
* shell-quoted inside it too. This keeps escaping correct through every layer
* even when the remote path contains spaces.
*/
export function buildRemoteLaunchCommand(options: {
mode: SessionMode;
remote: SessionRemote;
sessionId: string;
}): string {
const { mode, remote, sessionId } = options;
const modeCommand = remote.commands?.[mode] || defaultRemoteCommandForMode(mode);
const args = ['ssh', '-o', 'BatchMode=yes', '-t'];
if (remote.port) args.push('-p', String(remote.port));
const remoteCommand = `cd ${shellescape(remote.remotePath)} && ${modeCommand}`;
args.push(remoteSshTarget(remote), `bash -lc ${shellescape(remoteCommand)}`);
return args.map((arg) => shellescape(arg)).join(' ');
const remoteName = remoteTmuxSessionName(sessionId);
// Innermost: the command tmux runs in the new pane. Run via `/bin/sh -c` by
// tmux, so the path needs shell-quoting here. `exec` replaces the shell with
// the CLI so the pane PID is the agent itself.
const paneCommand = `cd ${shellescape(remote.remotePath)} && ${modeCommand}`;
// The tmux command line, with `\;` separating commands so the config `set`s
// apply on the SAME connection (and are idempotent on reattach).
const tmuxInvocation = [
`tmux -L codeman new-session -A -s ${remoteName} -c ${shellescape(remote.remotePath)} ${shellescape(paneCommand)}`,
'set -g status off',
'set -g mouse off',
'set -sg escape-time 0',
'set -g prefix C-q',
].join(' \\; ');
// ssh runs its trailing args through the remote login shell, so the entire
// tmux invocation is passed as one shell-quoted argument.
//
// COD-107 — connection options (port, identity, SOCKS ProxyCommand, jump host,
// arbitrary -o) come from the shared `buildSshConnectionArgs` so the launch and
// the tmux-prereq probe connect IDENTICALLY. `-t` is inserted right after
// `ssh -o BatchMode=yes` (preserving the historical token order), then the rest
// of the connection args, then the target and the quoted tmux invocation.
const [ssh, batchMode, ...connectionArgs] = buildSshConnectionArgs(remote);
const sshParts = [ssh, batchMode, '-t', ...connectionArgs, remoteSshTarget(remote), shellescape(tmuxInvocation)];
return sshParts.join(' ');
}
/**
@@ -1071,6 +1133,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
envOverrides,
effort,
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
remote,
} = options;
const muxName = `codeman-${sessionId.slice(0, 8)}`;
@@ -1089,6 +1152,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
pid: 99999,
createdAt: Date.now(),
workingDir,
remote,
mode,
attached: false,
name,
@@ -1133,7 +1197,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
try {
// Build the full command to run inside tmux
const fullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
const localFullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
const fullCmd = remote ? buildRemoteLaunchCommand({ mode, remote, sessionId }) : localFullCmd;
// Create tmux session in three steps to handle cold-start (no server running)
// and avoid the race where the command exits before remain-on-exit is set:
@@ -1184,7 +1249,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Replace the shell with the actual command (no echo in terminal). Keep
// pane launch in /tmp, then cd inside bash against the current mount table.
const launchCmd = `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
const launchCmd = remote ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
execSync(
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
{
@@ -1257,6 +1322,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
pid,
createdAt: Date.now(),
workingDir,
remote,
mode,
attached: false,
name,
@@ -1341,6 +1407,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
envOverrides,
effort,
historyLimit = DEFAULT_TMUX_HISTORY_LIMIT,
remote,
} = options;
const session = this.sessions.get(sessionId);
if (!session) return null;
@@ -1377,7 +1444,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
});
const config = niceConfig || DEFAULT_NICE_CONFIG;
const cmd = wrapWithNice(baseCmd, config);
const fullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
const localFullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
const fullCmd = remote ? buildRemoteLaunchCommand({ mode, remote, sessionId }) : localFullCmd;
try {
// For OpenCode: set sensitive env vars via tmux setenv before respawn
@@ -1394,7 +1462,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Re-apply user env overrides before respawn so the new shell inherits them.
this.applyEnvOverrides(muxName, envOverrides);
const launchCmd = `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
// -c /tmp + cd bounce — see createSession() for rationale (stale FUSE state).
const launchCmd = remote ? fullCmd : `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
await execAsync(
`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`,
{
+29 -2
View File
@@ -45,7 +45,34 @@ export type SessionMode = 'claude' | 'shell' | 'opencode' | 'codex' | 'gemini';
export type RemoteCommandMode = Extract<SessionMode, 'shell' | 'claude' | 'opencode' | 'codex' | 'gemini'>;
export interface RemoteHost {
/**
* Advanced SSH connection options shared by RemoteHost and SessionRemote.
*
* COD-107 — all fields are optional; every field absent reproduces today's
* behavior (port-22, default-identity, directly-SSH-able hosts). These describe
* HOW Codeman reaches the host (identity, proxy, jump host, arbitrary `-o`),
* letting it connect to e.g. a host fronted by a cloudflared SOCKS5 proxy on a
* custom port — the same connection `ssh-aa-desktop` makes — without a wrapper.
*/
export interface RemoteSshOptions {
/**
* Path to an SSH identity (private key) file — path ONLY, never key bytes.
* A leading `~`/`$HOME` is expanded to an absolute path at command-build time
* (ssh does not expand `~` in `-i`).
*/
identityFile?: string;
/**
* SOCKS5 proxy as `host:port` (e.g. `127.0.0.1:1080`). Expands to
* `-o ProxyCommand=nc -X 5 -x <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;
@@ -61,7 +88,7 @@ export interface RemoteCase {
remotePath: string;
}
export interface SessionRemote {
export interface SessionRemote extends RemoteSshOptions {
hostId: string;
label: string;
host: string;
+30
View File
@@ -1673,11 +1673,41 @@
<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&#10;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">
+27
View File
@@ -1263,7 +1263,12 @@ Object.assign(CodemanApp.prototype, {
'remoteHostId',
'remoteHostAddress',
'remoteHostUsername',
'remoteHostPort',
'remoteHostCodexCommand',
'remoteHostIdentityFile',
'remoteHostSocksProxy',
'remoteHostJumpHost',
'remoteHostExtraSshOptions',
];
remoteFields.forEach(id => {
const el = document.getElementById(id);
@@ -1429,6 +1434,15 @@ Object.assign(CodemanApp.prototype, {
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');
@@ -1442,6 +1456,14 @@ Object.assign(CodemanApp.prototype, {
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 = {
@@ -1449,6 +1471,11 @@ Object.assign(CodemanApp.prototype, {
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', {
+40
View File
@@ -282,6 +282,13 @@ const RemoteCommandOverridesSchema = z
.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('$(');
export const RemoteHostSchema = z.object({
id: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid remote host id'),
label: z.string().min(1).max(100),
@@ -296,6 +303,39 @@ export const RemoteHostSchema = z.object({
.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. No newline/NUL.
identityFile: z
.string()
.min(1)
.max(4096)
.regex(/^[^\n\r\0]*$/, '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 ([user@]host[:port]); reject shell metacharacters.
jumpHost: z
.string()
.min(1)
.max(255)
.regex(NO_SHELL_INJECTION, 'Invalid jump host')
.refine(noCommandSubstitution, 'Invalid jump host')
.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,
});