Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Has been cancelled
Go + Wails v3 + Vue 3 + SQLite + FreeRDP3 (purego) 183 tests, 76 source files, 9,910 lines of code Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
488 lines
18 KiB
Markdown
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
|