wraith/docs/superpowers/plans/2026-03-17-wraith-phase2-ssh-sftp.md

488 lines
18 KiB
Markdown

# Wraith Desktop — Phase 2: SSH + SFTP Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Connect to remote hosts via SSH with a real terminal (xterm.js), SFTP sidebar with file operations and CWD following, multi-tab sessions, and CodeMirror 6 file editor.
**Architecture:** Go SSH service wraps `x/crypto/ssh` for connections, PTY requests, and shell I/O. SFTP service wraps `pkg/sftp` riding the same SSH connection. Data flows: xterm.js ↔ Wails bindings ↔ Go SSH pipes. CWD tracking via OSC 7 shell injection.
**Tech Stack:** `golang.org/x/crypto/ssh`, `github.com/pkg/sftp`, xterm.js 5.x + WebGL addon + fit addon + search addon, CodeMirror 6
**Spec:** `docs/superpowers/specs/2026-03-17-wraith-desktop-design.md` (Sections 6, 10, 11)
---
## File Structure (New/Modified)
```
internal/
ssh/
service.go # SSH dial, PTY, shell, I/O goroutines
service_test.go # Connection config tests (unit, no real SSH)
hostkey.go # Host key verification + storage
hostkey_test.go
cwd.go # OSC 7 parser for CWD tracking
cwd_test.go
sftp/
service.go # SFTP operations (list, upload, download, etc.)
service_test.go
credentials/
service.go # Credential CRUD (encrypted passwords + SSH keys)
service_test.go
app/
app.go # Add SSH/SFTP/Credential services
frontend/
src/
components/
terminal/
TerminalView.vue # xterm.js instance wrapper
sftp/
FileTree.vue # Remote filesystem tree
TransferProgress.vue # Upload/download progress
editor/
EditorWindow.vue # CodeMirror 6 (placeholder for multi-window)
composables/
useTerminal.ts # xterm.js lifecycle + Wails binding bridge
useSftp.ts # SFTP operations via Wails bindings
stores/
session.store.ts # Update with real session management
assets/
css/
terminal.css # xterm.js overrides
package.json # Add xterm.js, codemirror deps
```
---
## Task 1: SSH Service — Connect, PTY, Shell I/O
**Files:**
- Create: `internal/ssh/service.go`
- Create: `internal/ssh/service_test.go`
The SSH service manages connections and exposes methods to Wails:
```go
// SSHService methods (exposed to frontend via Wails bindings):
// Connect(connectionID int64) (string, error) → returns sessionID
// Write(sessionID string, data string) error → write to stdin
// Resize(sessionID string, cols, rows int) error → window change
// Disconnect(sessionID string) error → close session
//
// Events emitted to frontend via Wails events:
// "ssh:data:{sessionID}" → terminal output (stdout)
// "ssh:connected:{sessionID}" → connection established
// "ssh:disconnected:{sessionID}" → connection closed
// "ssh:error:{sessionID}" → error message
```
Key implementation details:
- Each SSH session runs two goroutines: one reading stdout→Wails events, one for keepalive
- Sessions stored in a `map[string]*SSHSession` with mutex
- `SSHSession` holds: `*ssh.Client`, `*ssh.Session`, stdin `io.WriteCloser`, connection metadata
- PTY requested as `xterm-256color` with initial size from frontend
- Auth method determined by credential type (password, SSH key, keyboard-interactive)
- Host key verification delegates to `hostkey.go`
Tests (unit only — no real SSH server):
- TestSSHServiceCreation
- TestBuildAuthMethods (password → ssh.Password, key → ssh.PublicKeys)
- TestSessionTracking (create, get, remove)
- [ ] **Step 1:** Write tests
- [ ] **Step 2:** Implement service
- [ ] **Step 3:** Run tests, verify pass
- [ ] **Step 4:** Commit: `feat: SSH service — connect, PTY, shell I/O with goroutine pipes`
---
## Task 2: Host Key Verification
**Files:**
- Create: `internal/ssh/hostkey.go`
- Create: `internal/ssh/hostkey_test.go`
Host key verification stores/checks fingerprints in the `host_keys` SQLite table:
- New host → emit `ssh:hostkey-verify` event to frontend with fingerprint, wait for accept/reject
- Known host, matching fingerprint → proceed silently
- Known host, CHANGED fingerprint → emit warning event, block connection
For Phase 2, implement the storage and verification logic. The frontend prompt (accept/reject dialog) will be wired in the frontend task.
Tests:
- TestStoreHostKey
- TestVerifyKnownHost (match → ok)
- TestVerifyChangedHost (mismatch → error)
- TestVerifyNewHost (not found → returns "new")
- [ ] **Step 1:** Write tests
- [ ] **Step 2:** Implement hostkey.go
- [ ] **Step 3:** Run tests, verify pass
- [ ] **Step 4:** Commit: `feat: SSH host key verification — store, verify, detect changes`
---
## Task 3: CWD Tracker (OSC 7 Parser)
**Files:**
- Create: `internal/ssh/cwd.go`
- Create: `internal/ssh/cwd_test.go`
Parses OSC 7 escape sequences from terminal output to track the remote working directory:
```
Input: "some output\033]7;file://hostname/home/user\033\\more output"
Output: stripped="some output more output", cwd="/home/user"
```
The CWD tracker:
1. Scans byte stream for `\033]7;` prefix
2. Extracts URL between prefix and `\033\\` (or `\007`) terminator
3. Parses `file://hostname/path` to extract just the path
4. Strips the OSC 7 sequence from the output before forwarding to xterm.js
5. Returns the new CWD path when detected
Shell injection command (injected after PTY is established):
```bash
# bash
PROMPT_COMMAND='printf "\033]7;file://%s%s\033\\" "$(hostname)" "$PWD"'
# zsh
precmd() { printf "\033]7;file://%s%s\033\\" "$(hostname)" "$PWD" }
# fish
function fish_prompt; printf "\033]7;file://%s%s\033\\" (hostname) "$PWD"; end
```
Tests:
- TestParseOSC7Basic
- TestParseOSC7WithBEL (terminated by \007 instead of ST)
- TestParseOSC7NoMatch (no OSC 7 in output)
- TestParseOSC7MultipleInStream
- TestStripOSC7FromOutput
- [ ] **Step 1:** Write tests
- [ ] **Step 2:** Implement cwd.go
- [ ] **Step 3:** Run tests, verify pass
- [ ] **Step 4:** Commit: `feat: OSC 7 CWD tracker — parse and strip directory change sequences`
---
## Task 4: SFTP Service
**Files:**
- Create: `internal/sftp/service.go`
- Create: `internal/sftp/service_test.go`
SFTP service wraps `pkg/sftp` and exposes file operations to the frontend:
```go
// SFTPService methods (exposed via Wails bindings):
// OpenSFTP(sessionID string) error → start SFTP on existing SSH connection
// List(sessionID string, path string) ([]FileEntry, error) → directory listing
// ReadFile(sessionID string, path string) (string, error) → read file content (max 5MB)
// WriteFile(sessionID string, path string, content string) error → write file
// Upload(sessionID string, remotePath string, localPath string) error
// Download(sessionID string, remotePath string) (string, error) → returns local temp path
// Mkdir(sessionID string, path string) error
// Delete(sessionID string, path string) error
// Rename(sessionID string, oldPath, newPath string) error
// Stat(sessionID string, path string) (*FileEntry, error)
```
`FileEntry` type:
```go
type FileEntry struct {
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
IsDir bool `json:"isDir"`
Permissions string `json:"permissions"`
ModTime string `json:"modTime"`
Owner string `json:"owner"`
}
```
SFTP client is created from the existing `*ssh.Client` (same connection, separate channel). Stored alongside the SSH session.
Tests (unit — mock the sftp.Client interface):
- TestFileEntryFromFileInfo
- TestListSortsDirectoriesFirst
- TestReadFileRejectsLargeFiles (>5MB)
- [ ] **Step 1:** Write tests
- [ ] **Step 2:** Implement service
- [ ] **Step 3:** Add `pkg/sftp` dependency
- [ ] **Step 4:** Run tests, verify pass
- [ ] **Step 5:** Commit: `feat: SFTP service — list, read, write, upload, download, mkdir, delete`
---
## Task 5: Credential Service (Encrypted SSH Keys + Passwords)
**Files:**
- Create: `internal/credentials/service.go`
- Create: `internal/credentials/service_test.go`
CRUD for credentials and SSH keys with vault encryption:
```go
// CredentialService methods:
// CreatePassword(name, username, password, domain string) (*Credential, error)
// CreateSSHKey(name string, privateKey, passphrase []byte) (*SSHKey, error)
// GetCredential(id int64) (*Credential, error)
// ListCredentials() ([]Credential, error)
// DecryptPassword(id int64) (string, error) → decrypt for connection use only
// DecryptSSHKey(id int64) ([]byte, string, error) → returns (privateKey, passphrase, error)
// DeleteCredential(id int64) error
// ImportSSHKeyFile(name, filePath string) (*SSHKey, error) → read .pem file, detect type, store
```
All sensitive data encrypted via VaultService before storage. Decryption only happens at connection time.
Tests:
- TestCreatePasswordCredential
- TestCreateSSHKeyCredential
- TestDecryptPassword (round-trip through vault)
- TestDecryptSSHKey (round-trip)
- TestListCredentialsExcludesEncryptedValues
- TestDetectKeyType (RSA, Ed25519, ECDSA)
- [ ] **Step 1:** Write tests
- [ ] **Step 2:** Implement service
- [ ] **Step 3:** Run tests, verify pass
- [ ] **Step 4:** Commit: `feat: credential service — encrypted password and SSH key storage`
---
## Task 6: Wire SSH/SFTP/Credentials into App
**Files:**
- Modify: `internal/app/app.go`
- Modify: `main.go`
Add SSHService, SFTPService, and CredentialService to WraithApp. Register as Wails services.
- [ ] **Step 1:** Update app.go to create and expose new services
- [ ] **Step 2:** Update main.go to register them
- [ ] **Step 3:** Verify compilation: `go vet ./...`
- [ ] **Step 4:** Run all tests: `go test ./... -count=1`
- [ ] **Step 5:** Commit: `feat: wire SSH, SFTP, and credential services into Wails app`
---
## Task 7: Frontend — xterm.js Terminal
**Files:**
- Modify: `frontend/package.json` — add xterm.js + addons
- Create: `frontend/src/components/terminal/TerminalView.vue`
- Create: `frontend/src/composables/useTerminal.ts`
- Create: `frontend/src/assets/css/terminal.css`
- Modify: `frontend/src/components/session/SessionContainer.vue`
- Modify: `frontend/src/stores/session.store.ts`
Install xterm.js dependencies:
```
@xterm/xterm
@xterm/addon-fit
@xterm/addon-webgl
@xterm/addon-search
@xterm/addon-web-links
```
`useTerminal` composable:
- Creates xterm.js Terminal instance with theme from connection settings
- Attaches fit, WebGL, search, web-links addons
- Binds `terminal.onData` → Wails `SSHService.Write(sessionId, data)`
- Listens for Wails events `ssh:data:{sessionId}``terminal.write(data)`
- Handles resize via fit addon → Wails `SSHService.Resize(sessionId, cols, rows)`
- Cleanup on unmount
`TerminalView.vue`:
- Receives `sessionId` prop
- Mounts xterm.js into a div ref
- Applies theme colors from the active theme
- Handles focus management
`SessionContainer.vue` update:
- Replace placeholder with real TerminalView for SSH sessions
- Use `v-show` (not `v-if`) to keep terminals alive across tab switches
- [ ] **Step 1:** Install xterm.js deps: `cd frontend && npm install @xterm/xterm @xterm/addon-fit @xterm/addon-webgl @xterm/addon-search @xterm/addon-web-links`
- [ ] **Step 2:** Create terminal.css (xterm.js container styling)
- [ ] **Step 3:** Create useTerminal.ts composable
- [ ] **Step 4:** Create TerminalView.vue component
- [ ] **Step 5:** Update SessionContainer.vue to render TerminalView
- [ ] **Step 6:** Update session.store.ts with real Wails binding calls
- [ ] **Step 7:** Build frontend: `npm run build`
- [ ] **Step 8:** Commit: `feat: xterm.js terminal with WebGL rendering and Wails binding bridge`
---
## Task 8: Frontend — SFTP Sidebar
**Files:**
- Create: `frontend/src/components/sftp/FileTree.vue`
- Create: `frontend/src/components/sftp/TransferProgress.vue`
- Create: `frontend/src/composables/useSftp.ts`
- Modify: `frontend/src/layouts/MainLayout.vue` — SFTP sidebar rendering
- Modify: `frontend/src/components/sidebar/SidebarToggle.vue` — enable SFTP tab
`useSftp` composable:
- `listDirectory(sessionId, path)` → calls Wails SFTPService.List
- `uploadFile(sessionId, remotePath, file)` → chunked upload with progress
- `downloadFile(sessionId, remotePath)` → triggers browser download
- `deleteFile(sessionId, path)` → with confirmation
- `createDirectory(sessionId, path)`
- `renameFile(sessionId, old, new)`
- Tracks current path, file list, loading state, transfer progress
`FileTree.vue`:
- Renders file/directory tree (lazy-loaded on expand)
- Path bar at top showing current directory
- Toolbar: upload, download, new file, new folder, refresh, delete
- File entries show: icon (folder/file), name, size, modified date
- Double-click file → open in editor (Task 9)
- Drag-and-drop upload zone
- "Follow terminal folder" toggle at bottom
`TransferProgress.vue`:
- Shows active uploads/downloads with progress bars
- File name, percentage, speed, ETA
- [ ] **Step 1:** Create useSftp.ts composable
- [ ] **Step 2:** Create FileTree.vue component
- [ ] **Step 3:** Create TransferProgress.vue component
- [ ] **Step 4:** Update MainLayout.vue to render SFTP sidebar when toggled
- [ ] **Step 5:** Enable SFTP toggle in SidebarToggle.vue
- [ ] **Step 6:** Build frontend: `npm run build`
- [ ] **Step 7:** Commit: `feat: SFTP sidebar — file tree, upload/download, CWD following`
---
## Task 9: Frontend — Host Key Dialog + Connection Flow
**Files:**
- Create: `frontend/src/components/common/HostKeyDialog.vue`
- Modify: `frontend/src/components/sidebar/ConnectionTree.vue` — double-click to connect
- Modify: `frontend/src/stores/session.store.ts` — real connection flow
Wire up the full connection flow:
1. User double-clicks connection in sidebar
2. Session store calls Wails `SSHService.Connect(connectionId)`
3. If host key verification needed → show HostKeyDialog
4. On success → create tab, mount TerminalView, open SFTP sidebar
5. On error → show error toast
`HostKeyDialog.vue`:
- Modal showing: hostname, key type, fingerprint
- "New host" vs "CHANGED host key (WARNING)" modes
- Accept / Reject buttons
- "Always accept for this host" checkbox
- [ ] **Step 1:** Create HostKeyDialog.vue
- [ ] **Step 2:** Update ConnectionTree.vue with double-click handler
- [ ] **Step 3:** Update session.store.ts with connection flow
- [ ] **Step 4:** Build frontend: `npm run build`
- [ ] **Step 5:** Commit: `feat: connection flow — host key dialog, double-click to connect`
---
## Task 10: Frontend — CodeMirror 6 Editor (Placeholder)
**Files:**
- Modify: `frontend/package.json` — add CodeMirror deps
- Create: `frontend/src/components/editor/EditorWindow.vue`
Install CodeMirror 6:
```
codemirror
@codemirror/lang-javascript
@codemirror/lang-json
@codemirror/lang-html
@codemirror/lang-css
@codemirror/lang-python
@codemirror/lang-markdown
@codemirror/theme-one-dark
```
`EditorWindow.vue`:
- Renders CodeMirror 6 editor with dark theme
- Receives file content, path, and sessionId as props
- Syntax highlighting based on file extension
- Save button → calls Wails SFTPService.WriteFile
- Unsaved changes detection
- For Phase 2: renders inline (not separate window — multi-window is Phase 4)
- [ ] **Step 1:** Install CodeMirror deps
- [ ] **Step 2:** Create EditorWindow.vue
- [ ] **Step 3:** Wire file click in FileTree.vue to open EditorWindow
- [ ] **Step 4:** Build frontend: `npm run build`
- [ ] **Step 5:** Commit: `feat: CodeMirror 6 editor — syntax highlighting, dark theme, SFTP save`
---
## Task 11: Workspace Snapshot Persistence
**Files:**
- Create: `internal/app/workspace.go`
- Create: `internal/app/workspace_test.go`
Implements workspace snapshot saving/restoring per the spec:
```go
// SaveWorkspace() error — serialize current tab layout to settings
// LoadWorkspace() (*WorkspaceSnapshot, error) — read last saved layout
// Auto-save every 30 seconds via goroutine
// Save on clean shutdown
```
WorkspaceSnapshot JSON:
```json
{
"tabs": [
{"connectionId": 1, "protocol": "ssh", "position": 0},
{"connectionId": 5, "protocol": "rdp", "position": 1}
],
"sidebarWidth": 240,
"sidebarMode": "connections",
"activeTab": 0
}
```
Tests:
- TestSaveAndLoadWorkspace
- TestEmptyWorkspace
- [ ] **Step 1:** Write tests
- [ ] **Step 2:** Implement workspace.go
- [ ] **Step 3:** Run tests, verify pass
- [ ] **Step 4:** Commit: `feat: workspace snapshot persistence — auto-save layout every 30s`
---
## Task 12: Integration Test + Final Verification
- [ ] **Step 1:** Run all Go tests: `go test ./... -count=1`
- [ ] **Step 2:** Build frontend: `cd frontend && npm run build`
- [ ] **Step 3:** Verify Go compiles with embedded frontend: `go vet ./...`
- [ ] **Step 4:** Count tests and lines of code
- [ ] **Step 5:** Commit any fixes: `chore: Phase 2 complete — SSH + SFTP with terminal and file operations`
---
## Phase 2 Completion Checklist
- [ ] SSH service: connect, PTY, shell I/O with goroutine pipes
- [ ] Host key verification: store, verify, detect changes
- [ ] OSC 7 CWD tracker: parse and strip directory change sequences
- [ ] SFTP service: list, read, write, upload, download, mkdir, delete
- [ ] Credential service: encrypted password + SSH key storage
- [ ] All new services wired into Wails app
- [ ] xterm.js terminal with WebGL rendering
- [ ] SFTP file tree sidebar with upload/download
- [ ] Host key verification dialog
- [ ] Double-click connection to connect flow
- [ ] CodeMirror 6 inline editor with SFTP save
- [ ] Workspace snapshot persistence
- [ ] All Go tests passing
- [ ] Frontend builds clean