feat: remote host SSH cases (CRUD, case-link, session launch, injection hardening)#145
Conversation
… 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.
…#145) - UI: add the missing data-tab="case-remote" tab button; dispatch it through submitCaseModal()/switchCaseModalTab() to linkRemoteCase() (was dead code). - Restore: restoreMuxSessions() now passes remote (muxSession.remote ?? savedState.remote) into the Session constructor, so remote metadata round-trips on restart instead of reattaching from a local cwd / respawning LOCAL / being erased from state.json. Recovery tests added. - Run flows: runClaude()/runShell() route remote cases through /api/quick-start (POST /api/sessions stat-validates workingDir locally); run*() skip the /api/*/status pre-check and omit inert config/env for remote cases. - Quick-start: resolve the remote case BEFORE the local CLI availability gates and skip isCodex/Gemini/OpenCodeAvailable() when remote; REJECT envOverrides/effort/codex/gemini/openCode config for remote (they don't cross ssh) instead of silently dropping them. - Injection: reject $, backtick, $( in remotePath + identityFile at the schema layer (they survive shellescape into the bash -c launch double-quote layer). Regression tests for $(...) and backtick payloads added. - Remote socket/name: launch on a DEDICATED -L codeman-remote socket under a codeman-ssh-<id> name that fails a remote Codeman's SAFE_MUX_NAME_PATTERN, so a remote instance can't adopt the session; scope tmux set-options per-session (never -g) so they don't mutate other sessions. - Kill: best-effort ssh 'tmux -L codeman-remote kill-session' on remote session kill (fire-and-forget, never blocks/throws the local kill) so the remote agent isn't orphaned forever. - Probe: wire checkRemoteTmuxAvailable() into POST /api/quick-start (structured OPERATION_FAILED) and as courtesy validation in remote-link; add a default -o ConnectTimeout=10 to buildSshConnectionArgs (overridable via extraSshOptions). - Command default: remote claude default is now 'exec claude --dangerously-skip-permissions' (per-host override stays the escape hatch), mirroring local non-interactive semantics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- help-modal extractor bounds at the next HTML comment (cron modal's 'Run At' text false-positived the stale-shortcut regex) - remote-shell run test expects the wired /api/quick-start path (#145) — POST /api/sessions has no caseName in its schema Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Merged — thank you @aakhter! Remote SSH cases are the most ambitious feature of this batch and the bones were right: durable remote tmux over ssh drops, per-host command overrides, injection-conscious escaping. Review hardened and completed it: the Remote tab is now reachable (button + submit dispatch were missing), remote metadata survives server restarts, run flows route through quick-start (local CLI checks were blocking remote codex/gemini), remotePath/identityFile get the anti-injection guard, the remote session name/socket no longer matches a remote Codeman's discovery pattern (a remote instance would have adopted the session), remote tmux gets killed on delete, the tmux probe + ConnectTimeout are wired, and remote claude defaults to --dangerously-skip-permissions for parity. 🙏 |
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 jumpHosthandling.Key files:
src/remote-hosts.ts— new domain:RemoteHost,RemoteCase,readRemoteHosts,readRemoteCases,buildSshConnectionArgs(fully shell-escaped),checkRemoteTmuxAvailablesrc/types/session.ts—SessionRemote,RemoteCommandMode,defaultRemoteCommandForMode;SessionConfig.remotefieldsrc/web/routes/case-routes.ts—GET /api/remote-hosts,POST /api/remote-hosts,PUT/DELETE /api/remote-hosts/:id,POST /api/cases/remote-linksrc/web/schemas.ts—RemoteHostSchema,RemoteCaseSchema,RemoteCaseLinkSchemasrc/tmux-manager.ts—buildRemoteLaunchCommandfor SSH-backed sessions (ssh → remote tmux new-session); all SSH tokens viabuildSshConnectionArgssrc/session.ts,src/web/routes/session-routes.ts— wireremotethrough session create + quick-start; remote case resolves without localstatvalidationsrc/web/public/session-ui.js— remote host picker in the new-session flowsrc/web/public/index.html— remote host selector markupSecurity:
buildSshConnectionArgsshell-escapes every user-supplied field (host,user,-p port,-i identityFile,-J jumpHost,-o socksProxy,extraSshOptions). The-Jflag 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/sessionswithcaseNameworks for local cases only; remote case resolution skips localfs.statand usesreadRemoteCasesinstead.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 --noEmitclean.🤖 Generated with Claude Code