docs(ssh): spec and plan for ssh workspace sessions

This commit is contained in:
mika kuns
2026-10-02 14:18:59 +02:00
parent 9e4a14040e
commit f7625baa24
2 changed files with 1167 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,104 @@
# 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.