Skip to content

feat: remote host SSH cases (CRUD, case-link, session launch, injection hardening)#145

Open
aakhter wants to merge 7 commits into
Ark0N:masterfrom
aakhter:cod-94-remote-host-ssh
Open

feat: remote host SSH cases (CRUD, case-link, session launch, injection hardening)#145
aakhter wants to merge 7 commits into
Ark0N:masterfrom
aakhter:cod-94-remote-host-ssh

Conversation

@aakhter

@aakhter aakhter commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Introduces the remote host cases feature: a domain for managing named SSH remote hosts, CRUD endpoints + case-link, and a session-UI picker that lets users start Claude sessions on a remote host via SSH. Includes the COD-107 injection-hardening fix that closes a command-injection path in -J jumpHost handling.

Key files:

  • src/remote-hosts.ts — new domain: RemoteHost, RemoteCase, readRemoteHosts, readRemoteCases, buildSshConnectionArgs (fully shell-escaped), checkRemoteTmuxAvailable
  • src/types/session.tsSessionRemote, RemoteCommandMode, defaultRemoteCommandForMode; SessionConfig.remote field
  • src/web/routes/case-routes.tsGET /api/remote-hosts, POST /api/remote-hosts, PUT/DELETE /api/remote-hosts/:id, POST /api/cases/remote-link
  • src/web/schemas.tsRemoteHostSchema, RemoteCaseSchema, RemoteCaseLinkSchema
  • src/tmux-manager.tsbuildRemoteLaunchCommand for SSH-backed sessions (ssh → remote tmux new-session); all SSH tokens via buildSshConnectionArgs
  • src/session.ts, src/web/routes/session-routes.ts — wire remote through session create + quick-start; remote case resolves without local stat validation
  • src/web/public/session-ui.js — remote host picker in the new-session flow
  • src/web/public/index.html — remote host selector markup

Security: buildSshConnectionArgs shell-escapes every user-supplied field (host, user, -p port, -i identityFile, -J jumpHost, -o socksProxy, extraSshOptions). The -J flag was previously interpolated raw (command-injection); this commit closes that. A structural allowlist validates jumpHost format ([user@]host[:port], multi-hop, bracketed IPv6) so no shell metacharacter can appear.

Scope note: This PR covers the original remote-host cases surface (COD-24) plus the injection-hardening fix. It does not include durable remote tmux, discover/attach of existing sessions, or auto-reconnect — those are a follow-on.

Remote cases vs /api/sessions: Remote case support is wired into /api/quick-start. POST /api/sessions with caseName works for local cases only; remote case resolution skips local fs.stat and uses readRemoteCases instead.

Tests

  • test/remote-hosts.test.ts — domain unit tests (2 tests)
  • test/routes/case-routes.test.ts — CRUD + case-link route tests (33 tests)
  • test/routes/session-routes.test.ts — quick-start with remote case (55 tests)
  • test/tmux-manager.test.ts — SSH command building + injection-safety (45 tests)

All 135 tests pass. tsc --noEmit clean.

🤖 Generated with Claude Code

johoja12 and others added 7 commits July 9, 2026 08:51
… 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.
…mmand-injection)

buildSshConnectionArgs interpolated jumpHost raw while its siblings
(identityFile/socksProxy/extraSshOptions) were shellescaped. The token array is
joined and run via execAsync (/bin/sh -c), so a jumpHost like "x; touch /tmp/pwned"
executed. The Zod denylist only blocked backtick/newline/$( and let ;|& and spaces
through.

- shellescape jumpHost in buildSshConnectionArgs (primary fix)
- replace jumpHost denylist with a structural allowlist: [user@]host[:port],
  comma-separated multi-hop, bracketed IPv6; no shell metachar can appear
- update/extend tests: escaped -J assertion + injection-safety case

Verified: remote-ssh-options (11) + case-routes (33) pass, tsc --noEmit clean,
regex accepts valid forms / rejects 8 injection payloads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…egration

- src/remote-hosts.ts: add missing execAsync = promisify(exec) that was
  implied by intermediate commits not in the cherry-pick set
- src/web/routes/session-routes.ts: add getDataDir import and
  readRemoteCases/readRemoteHosts/toSessionRemote for remote case support
  in quick-start; narrow casePath string|null via resolvedCasePath cast
- test/routes/session-routes.test.ts: add vi.hoisted remoteStore mock for
  remote-hosts.js; fix 'creates session from remote case' test to use
  /api/quick-start (remote cases are not supported on /api/sessions)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… class

\[ inside [...] doesn't need backslash — ESLint no-useless-escape.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants