Files
ClaudeDo/docs/superpowers/specs/2026-10-02-ssh-workspaces-design.md
T

5.9 KiB
Raw Blame History

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 (Host lines, 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.exe with a reverse tunnel to the worker's external MCP for the lifetime of the ssh process; the remote claude gets a claudedo MCP 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_session and "Recent sessions"/resume for SSH tiles — both build local paths (brief.md, cwd). SSH sessions are not recorded in mission_control_sessions and get no claudedo_pane entry / 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 = ssh resolved 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 (not claudedo_pane) so tool names match local sessions.
  • Remote prerequisites: POSIX shell with base64, claude on 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 (default 47823, 0 = feature off). The external MCP app additionally binds 127.0.0.1:<RemoteMcpPort>.
  • ExternalMcpAuthMiddleware: a request whose Connection.LocalPort == RemoteMcpPort must carry a valid X-ClaudeDo-Remote-Token (registry lookup) → else 401. The existing key/loopback rule applies unchanged to the normal port.
  • Tunnels always target RemoteMcpPort, never ExternalMcpPort.

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: RefreshSessionTargetsAsync appends one SessionTargetViewModel per workspace (label = name, icon/suffix marks it as remote) → OpenSshConPtySessionAsync(workspace) → new ConPtyPaneViewModel with title = name, spec via GetSshLaunchSpecAsync.
  • 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 Host line, comments).
  • Manual: real VPS — tile opens, /mcp shows claudedo connected, list_task_lists works, closing the tile tears the tunnel down; second tile to the same host works in parallel.