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
+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,
});