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

105 lines
5.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.