diff --git a/docs/superpowers/plans/2026-03-17-wraith-phase2-ssh-sftp.md b/docs/superpowers/plans/2026-03-17-wraith-phase2-ssh-sftp.md new file mode 100644 index 0000000..49005d5 --- /dev/null +++ b/docs/superpowers/plans/2026-03-17-wraith-phase2-ssh-sftp.md @@ -0,0 +1,487 @@ +# 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