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
+66
View File
@@ -237,6 +237,72 @@ describe('case-routes', () => {
]);
});
// 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'));