5.9 KiB
SSH workspaces — remote interactive sessions
Date: 2026-10-02 · Status: approved (inline, Mika)
Goal
Start an interactive claude session on a server (e.g. the VPS) as a Mission Control tile,
with the claudedo MCP reachable from there. One extra entry per workspace in the
quick-session flyout ("VPS"), click → tile → real claude TUI over ssh.
Scope
In:
- SSH workspaces configured in Settings: Name, Host alias, Remote path.
- Host alias is picked from
%USERPROFILE%\.ssh\config(Hostlines, wildcards skipped); user/key/port stay in the ssh config — ClaudeDo stores none of it. - Quick-session flyout lists each workspace after the local targets.
- The tile runs
ssh.exewith a reverse tunnel to the worker's external MCP for the lifetime of the ssh process; the remote claude gets aclaudedoMCP entry pointing at it. - Per-session token on a dedicated remote-only MCP listener.
Out (v1):
- Autonomous tasks on a remote host — the whole worktree/merge/verify pipeline is local-FS.
handoff_sessionand "Recent sessions"/resume for SSH tiles — both build local paths (brief.md, cwd). SSH sessions are not recorded inmission_control_sessionsand get noclaudedo_paneentry / handoff hint.- Path translation in MCP tool results (worktree/attachment paths stay Windows paths).
- Password/key management UI — ssh prompts inside the tile like in a terminal.
Design
Storage
AppSettingsEntity.SshWorkspaces (ssh_workspaces) — JSON array of
{ id, name, host, remotePath }. Same pattern as RepoImportFolders: dedicated repository
Get/SetSshWorkspacesAsync + hub GetSshWorkspaces/SetSshWorkspaces, not part of
AppSettingsDto (AppSettingsRepository.UpdateAsync copies named fields only, so the column is
never clobbered by a general settings save). One EF migration adds the column.
Validation at the boundary (SshWorkspace.Validate, used by Settings before save and by the hub on
set): name non-empty; host matches ^[A-Za-z0-9._@][A-Za-z0-9._@-]*$ — a leading - would be
parsed by ssh as an option (-oProxyCommand=… = local code execution); remote path starts with
/ or ~, contains no ' and no line break (it is single-quoted on the remote shell).
Launch spec (Worker)
New hub method GetSshLaunchSpec(string workspaceId, string paneId) →
InteractiveLaunchSpecService.BuildForSshAsync:
Exe=sshresolved on PATH (Windows OpenSSH); clear error if missing.Cwd= local user profile (ssh needs a local cwd; nothing remote reads it).- Remote port: random per session in
20000–60999. - Token: random 32 bytes (hex), registered in a singleton
RemoteSessionTokenRegistry. - Args (each a separate token, no embedded double quotes — ConPTY flattens Args into one
Windows command line, see
docs/explore-notes/conpty-sessions.md):-t -o ExitOnForwardFailure=yes -R <remotePort>:127.0.0.1:<RemoteMcpPort> <hostAlias> umask 077 && mkdir -p ~/.claudedo && echo <b64> | base64 -d > ~/.claudedo/mcp-<paneId>.json && cd '<remotePath>' && MCP_TOOL_TIMEOUT=<ms> claude --mcp-config ~/.claudedo/mcp-<paneId>.json; rm -f ~/.claudedo/mcp-<paneId>.json<b64>= base64 of{"mcpServers":{"claudedo":{"type":"http","url":"http://127.0.0.1:<remotePort>/mcp","headers":{"X-ClaudeDo-Remote-Token":"<token>"}}}}. Base64 has no shell- or argv-sensitive characters; the file (mode 600 via umask) keeps the JSON out of claude's own argv. - The server name is
claudedo(notclaudedo_pane) so tool names match local sessions. - Remote prerequisites: POSIX shell with
base64,claudeon PATH and logged in.
Remote-only MCP listener (security)
The external MCP is loopback-only and keyless by default (ExternalMcpAuthMiddleware).
Tunnel traffic arrives as loopback, so exposing the existing port would hand every process
on the server that can reach the forwarded port full control of the local ClaudeDo.
- New
WorkerConfig.RemoteMcpPort(default47823,0= feature off). The external MCP app additionally binds127.0.0.1:<RemoteMcpPort>. ExternalMcpAuthMiddleware: a request whoseConnection.LocalPort == RemoteMcpPortmust carry a validX-ClaudeDo-Remote-Token(registry lookup) → else 401. The existing key/loopback rule applies unchanged to the normal port.- Tunnels always target
RemoteMcpPort, neverExternalMcpPort.
Known ceilings (accepted):
- The remote command line (incl. base64 → token) is visible to other users on the server via
ps//proc. Protects against processes/containers that reach the port without reading cmdlines; fine for a single-user VPS. Upgrade path: deliver the token via stdin before the TUI. - Tokens live in memory until worker restart (no revoke on tile close). A leaked token only works while some tunnel to that port is open.
UI
- Settings: new "SSH" category (Erweitert group, last tab) — list of workspaces with add/remove,
name textbox, host combobox (from ssh config, editable for aliases not in the file),
remote path textbox. Saves via
UpdateAppSettingsAsync. - Quick-session flyout:
RefreshSessionTargetsAsyncappends oneSessionTargetViewModelper workspace (label = name, icon/suffix marks it as remote) →OpenSshConPtySessionAsync(workspace)→ newConPtyPaneViewModelwith title = name, spec viaGetSshLaunchSpecAsync. - Localization keys in en + de.
Testing
RemoteSessionTokenRegistry+ middleware: remote port without/with wrong/with valid token; normal port unaffected.BuildForSshAsync: arg list shape, no"in any arg, base64 decodes to the expected JSON, path validation rejects'/ relative paths, unknown workspace id throws.- ssh-config host parsing (wildcards, multiple aliases per
Hostline, comments). - Manual: real VPS — tile opens,
/mcpshowsclaudedoconnected,list_task_listsworks, closing the tile tears the tunnel down; second tile to the same host works in parallel.