# 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 :127.0.0.1: umask 077 && mkdir -p ~/.claudedo && echo | base64 -d > ~/.claudedo/mcp-.json && cd '' && MCP_TOOL_TIMEOUT= claude --mcp-config ~/.claudedo/mcp-.json; rm -f ~/.claudedo/mcp-.json ``` `` = base64 of `{"mcpServers":{"claudedo":{"type":"http","url":"http://127.0.0.1:/mcp","headers":{"X-ClaudeDo-Remote-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:`. - `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.