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
+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;