docs(ssh): spec and plan for ssh workspace sessions
This commit is contained in:
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.
|
||||||
Reference in New Issue
Block a user