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

18 KiB

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:

// 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
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:

// 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:

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:

// 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:

// 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:

{
  "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