fix: wire SSH/SFTP/terminal to real Go backend — kill all stubs

Critical path wired end-to-end:
- ConnectSSH on WraithApp resolves credentials from vault, builds auth methods
- SSH output handler emits Wails events (base64) to frontend
- useTerminal.ts forwards keystrokes to SSHService.Write, resize to SSHService.Resize
- useTerminal.ts listens for ssh:data:{sessionId} events and writes to xterm.js
- session.store.ts connect() calls real Go ConnectSSH, not mock
- useSftp.ts calls real SFTPService.List instead of hardcoded mock data
- SFTP client auto-registered on SSH connection via pkg/sftp
- DisconnectSession cleans up both SSH and SFTP

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-17 10:49:21 -04:00
parent e5c69106c5
commit 8572e6e7ea
6 changed files with 226 additions and 87 deletions

View File

@ -1,4 +1,7 @@
import { ref, type Ref } from "vue";
import { Call } from "@wailsio/runtime";
const SFTP = "github.com/vstockwell/wraith/internal/sftp.SFTPService";
export interface FileEntry {
name: string;
@ -19,46 +22,24 @@ export interface UseSftpReturn {
refresh: () => Promise<void>;
}
/** Mock directory listings used until Wails SFTP bindings are connected. */
const mockDirectories: Record<string, FileEntry[]> = {
"/home/user": [
{ name: "docs", path: "/home/user/docs", size: 0, isDir: true, permissions: "drwxr-xr-x", modTime: "2026-03-17" },
{ name: "projects", path: "/home/user/projects", size: 0, isDir: true, permissions: "drwxr-xr-x", modTime: "2026-03-16" },
{ name: ".ssh", path: "/home/user/.ssh", size: 0, isDir: true, permissions: "drwx------", modTime: "2026-03-10" },
{ name: ".bashrc", path: "/home/user/.bashrc", size: 3771, isDir: false, permissions: "-rw-r--r--", modTime: "2026-03-15" },
{ name: "deploy.sh", path: "/home/user/deploy.sh", size: 1024, isDir: false, permissions: "-rwxr-xr-x", modTime: "2026-03-16" },
{ name: ".profile", path: "/home/user/.profile", size: 807, isDir: false, permissions: "-rw-r--r--", modTime: "2026-03-10" },
],
"/home/user/docs": [
{ name: "readme.md", path: "/home/user/docs/readme.md", size: 2048, isDir: false, permissions: "-rw-r--r--", modTime: "2026-03-17" },
{ name: "notes.txt", path: "/home/user/docs/notes.txt", size: 512, isDir: false, permissions: "-rw-r--r--", modTime: "2026-03-14" },
],
"/home/user/projects": [
{ name: "app", path: "/home/user/projects/app", size: 0, isDir: true, permissions: "drwxr-xr-x", modTime: "2026-03-16" },
{ name: "Makefile", path: "/home/user/projects/Makefile", size: 256, isDir: false, permissions: "-rw-r--r--", modTime: "2026-03-16" },
],
"/home/user/.ssh": [
{ name: "authorized_keys", path: "/home/user/.ssh/authorized_keys", size: 743, isDir: false, permissions: "-rw-------", modTime: "2026-03-10" },
{ name: "config", path: "/home/user/.ssh/config", size: 128, isDir: false, permissions: "-rw-------", modTime: "2026-03-10" },
],
};
/**
* Composable that manages SFTP file browsing state.
*
* Uses mock data until Wails SFTPService bindings are connected.
* Calls the real Go SFTPService via Wails bindings.
*/
export function useSftp(_sessionId: string): UseSftpReturn {
const currentPath = ref("/home/user");
export function useSftp(sessionId: string): UseSftpReturn {
const currentPath = ref("/");
const entries = ref<FileEntry[]>([]);
const isLoading = ref(false);
const followTerminal = ref(false);
async function listDirectory(path: string): Promise<FileEntry[]> {
// TODO: Replace with Wails binding call — SFTPService.List(sessionId, path)
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 150));
return mockDirectories[path] ?? [];
try {
const result = await Call.ByName(`${SFTP}.List`, sessionId, path) as FileEntry[];
return result ?? [];
} catch (err) {
console.error("SFTP list error:", err);
return [];
}
}
async function navigateTo(path: string): Promise<void> {
@ -85,8 +66,8 @@ export function useSftp(_sessionId: string): UseSftpReturn {
await navigateTo(currentPath.value);
}
// Load initial directory
navigateTo(currentPath.value);
// Load home directory on init
navigateTo("/home");
return {
currentPath,

View File

@ -1,10 +1,13 @@
import { ref, onBeforeUnmount } from "vue";
import { onBeforeUnmount } from "vue";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { SearchAddon } from "@xterm/addon-search";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { Call, Events } from "@wailsio/runtime";
import "@xterm/xterm/css/xterm.css";
const SSH = "github.com/vstockwell/wraith/internal/ssh.SSHService";
/** MobaXTerm Classicinspired terminal theme colors. */
const defaultTheme = {
background: "#0d1117",
@ -43,8 +46,10 @@ export interface UseTerminalReturn {
/**
* Composable that manages an xterm.js Terminal lifecycle.
*
* Creates the terminal with fit, search, and web-links addons.
* Data input and resize events are wired as TODOs for Wails bindings.
* Wires bidirectional I/O:
* - User keystrokes SSHService.Write (via Wails Call)
* - SSH stdout xterm.js (via Wails Events, base64 encoded)
* - Terminal resize SSHService.Resize (via Wails Call)
*/
export function useTerminal(sessionId: string): UseTerminalReturn {
const fitAddon = new FitAddon();
@ -67,18 +72,22 @@ export function useTerminal(sessionId: string): UseTerminalReturn {
terminal.loadAddon(searchAddon);
terminal.loadAddon(webLinksAddon);
// Capture typed data and forward to the SSH backend
terminal.onData((_data: string) => {
// TODO: Replace with Wails binding call — SSHService.Write(sessionId, data)
// For now, echo typed data back to the terminal for visual feedback
void sessionId;
// Forward typed data to the SSH backend
terminal.onData((data: string) => {
Call.ByName(`${SSH}.Write`, sessionId, data).catch((err: unknown) => {
console.error("SSH write error:", err);
});
});
// Handle terminal resize events
terminal.onResize((_size: { cols: number; rows: number }) => {
// TODO: Replace with Wails binding call — SSHService.Resize(sessionId, cols, rows)
void sessionId;
// Forward resize events to the SSH backend
terminal.onResize((size: { cols: number; rows: number }) => {
Call.ByName(`${SSH}.Resize`, sessionId, size.cols, size.rows).catch((err: unknown) => {
console.error("SSH resize error:", err);
});
});
// Listen for SSH output events from the Go backend (base64 encoded)
let cleanupEvent: (() => void) | null = null;
let resizeObserver: ResizeObserver | null = null;
@ -86,20 +95,31 @@ export function useTerminal(sessionId: string): UseTerminalReturn {
terminal.open(container);
fitAddon.fit();
// Subscribe to SSH output events for this session
cleanupEvent = Events.On(`ssh:data:${sessionId}`, (...args: unknown[]) => {
// Wails v3 events pass data as arguments
const b64data = typeof args[0] === "string" ? args[0] : String(args[0] ?? "");
try {
const decoded = atob(b64data);
terminal.write(decoded);
} catch {
// Fallback: write raw if not valid base64
terminal.write(b64data);
}
});
// Auto-fit when the container resizes
resizeObserver = new ResizeObserver(() => {
fitAddon.fit();
});
resizeObserver.observe(container);
// Write a placeholder welcome message (mock — replaced by real SSH output)
terminal.writeln("\x1b[1;34m Wraith Terminal\x1b[0m");
terminal.writeln("\x1b[90m Session: " + sessionId + "\x1b[0m");
terminal.writeln("\x1b[90m Waiting for SSH connection...\x1b[0m");
terminal.writeln("");
}
function destroy(): void {
if (cleanupEvent) {
cleanupEvent();
cleanupEvent = null;
}
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;

View File

@ -1,7 +1,10 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { Call } from "@wailsio/runtime";
import { useConnectionStore } from "@/stores/connection.store";
const APP = "github.com/vstockwell/wraith/internal/app.WraithApp";
export interface Session {
id: string;
connectionId: number;
@ -10,17 +13,10 @@ export interface Session {
active: boolean;
}
/**
* Session store.
* Manages active sessions and tab order.
*
* Sessions are populated by the Go SessionManager once plugins are wired up.
* For now, mock sessions are used to render the tab bar.
*/
export const useSessionStore = defineStore("session", () => {
const sessions = ref<Session[]>([]);
const activeSessionId = ref<string | null>(null);
const connecting = ref(false);
const activeSession = computed(() =>
sessions.value.find((s) => s.id === activeSessionId.value) ?? null,
@ -28,19 +24,25 @@ export const useSessionStore = defineStore("session", () => {
const sessionCount = computed(() => sessions.value.length);
/** Switch to a session tab. */
function activateSession(id: string): void {
activeSessionId.value = id;
}
/** Close a session tab. */
function closeSession(id: string): void {
async function closeSession(id: string): Promise<void> {
const idx = sessions.value.findIndex((s) => s.id === id);
if (idx === -1) return;
const session = sessions.value[idx];
// Disconnect the backend session
try {
await Call.ByName(`${APP}.DisconnectSession`, session.id);
} catch (err) {
console.error("Failed to disconnect session:", err);
}
sessions.value.splice(idx, 1);
// If we closed the active session, activate an adjacent one
if (activeSessionId.value === id) {
if (sessions.value.length === 0) {
activeSessionId.value = null;
@ -51,21 +53,11 @@ export const useSessionStore = defineStore("session", () => {
}
}
/** Add a new session (placeholder — will be called from connection double-click). */
function addSession(connectionId: number, name: string, protocol: "ssh" | "rdp"): void {
const id = `s${Date.now()}`;
sessions.value.push({ id, connectionId, name, protocol, active: false });
activeSessionId.value = id;
}
/**
* Connect to a server by connection ID.
* Creates a new session tab and sets it active.
*
* TODO: Replace with Wails binding call SSHService.Connect(hostname, port, ...)
* For now, creates a mock session using the connection's name.
* Calls the real Go backend to establish an SSH or RDP session.
*/
function connect(connectionId: number): void {
async function connect(connectionId: number): Promise<void> {
const connectionStore = useConnectionStore();
const conn = connectionStore.connections.find((c) => c.id === connectionId);
if (!conn) return;
@ -77,10 +69,35 @@ export const useSessionStore = defineStore("session", () => {
return;
}
// TODO: Replace with Wails binding call:
// const sessionId = await SSHService.Connect(conn.hostname, conn.port, username, authMethods, cols, rows)
// For now, create a mock session
addSession(connectionId, conn.name, conn.protocol);
connecting.value = true;
try {
if (conn.protocol === "ssh") {
// Call Go backend — resolves credentials, builds auth, returns sessionID
const sessionId = await Call.ByName(
`${APP}.ConnectSSH`,
connectionId,
120, // cols (will be resized by xterm.js fit addon)
40, // rows
) as string;
sessions.value.push({
id: sessionId,
connectionId,
name: conn.name,
protocol: "ssh",
active: true,
});
activeSessionId.value = sessionId;
} else if (conn.protocol === "rdp") {
// TODO: Wire RDP connect when ready
console.warn("RDP connections not yet wired");
}
} catch (err) {
console.error("Connection failed:", err);
// TODO: Show error toast in UI
} finally {
connecting.value = false;
}
}
return {
@ -88,9 +105,9 @@ export const useSessionStore = defineStore("session", () => {
activeSessionId,
activeSession,
sessionCount,
connecting,
activateSession,
closeSession,
addSession,
connect,
};
});

View File

@ -8,6 +8,8 @@ import (
"os"
"path/filepath"
"encoding/base64"
"github.com/vstockwell/wraith/internal/ai"
"github.com/vstockwell/wraith/internal/connections"
"github.com/vstockwell/wraith/internal/credentials"
@ -22,6 +24,9 @@ import (
"github.com/vstockwell/wraith/internal/theme"
"github.com/vstockwell/wraith/internal/updater"
"github.com/vstockwell/wraith/internal/vault"
sftplib "github.com/pkg/sftp"
gossh "golang.org/x/crypto/ssh"
"github.com/wailsapp/wails/v3/pkg/application"
)
// WraithApp is the main application struct that wires together all services
@ -41,9 +46,16 @@ type WraithApp struct {
AI *ai.AIService
Updater *updater.UpdateService
oauthMgr *ai.OAuthManager
wailsApp *application.App
unlocked bool
}
// SetWailsApp stores the Wails application reference for event emission.
// Must be called after application.New() and before app.Run().
func (a *WraithApp) SetWailsApp(app *application.App) {
a.wailsApp = app
}
// New creates and initializes the WraithApp, opening the database, running
// migrations, creating all services, and seeding built-in themes.
// The version string is the build-time semver (e.g. "0.2.0") used by the
@ -68,11 +80,16 @@ func New(version string) (*WraithApp, error) {
sessionMgr := session.NewManager()
pluginReg := plugin.NewRegistry()
// No-op output handler — Wails event emission will be wired at runtime.
// SSH output handler — emits Wails events to the frontend.
// The closure captures `app` (the WraithApp being built). The wailsApp
// field is set after application.New() in main.go, but SSH sessions only
// start after app.Run(), so wailsApp is always valid at call time.
var app *WraithApp
sshSvc := ssh.NewSSHService(database, func(sessionID string, data []byte) {
// TODO: Emit Wails event "ssh:output" with sessionID + data
_ = sessionID
_ = data
if app != nil && app.wailsApp != nil {
// Base64 encode binary data for safe transport over Wails events
app.wailsApp.Event.Emit("ssh:data:"+sessionID, base64.StdEncoding.EncodeToString(data))
}
})
sftpSvc := sftp.NewSFTPService()
@ -102,7 +119,7 @@ func New(version string) (*WraithApp, error) {
updaterSvc := updater.NewUpdateService(version)
return &WraithApp{
app = &WraithApp{
db: database,
Settings: settingsSvc,
Connections: connSvc,
@ -115,7 +132,8 @@ func New(version string) (*WraithApp, error) {
AI: aiSvc,
Updater: updaterSvc,
oauthMgr: oauthMgr,
}, nil
}
return app, nil
}
// dataDirectory returns the path where Wraith stores its data.
@ -230,6 +248,101 @@ func (a *WraithApp) initCredentials() {
}
}
// ConnectSSH opens an SSH session to the given connection ID.
// It resolves credentials from the vault, builds auth methods, and returns a session ID.
// The frontend calls this instead of SSHService.Connect directly (which takes Go-only types).
func (a *WraithApp) ConnectSSH(connectionID int64, cols, rows int) (string, error) {
conn, err := a.Connections.GetConnection(connectionID)
if err != nil {
return "", fmt.Errorf("connection not found: %w", err)
}
// Build SSH auth methods from the connection's credential
var authMethods []gossh.AuthMethod
username := "root" // default
if conn.CredentialID != nil && a.Credentials != nil {
cred, err := a.Credentials.GetCredential(*conn.CredentialID)
if err != nil {
slog.Warn("failed to load credential", "id", *conn.CredentialID, "error", err)
} else {
if cred.Username != "" {
username = cred.Username
}
switch cred.Type {
case "password":
pw, err := a.Credentials.DecryptPassword(cred.ID)
if err != nil {
slog.Warn("failed to decrypt password", "error", err)
} else {
authMethods = append(authMethods, gossh.Password(pw))
}
case "ssh_key":
if cred.SSHKeyID != nil {
keyPEM, passphrase, err := a.Credentials.DecryptSSHKey(*cred.SSHKeyID)
if err != nil {
slog.Warn("failed to decrypt SSH key", "error", err)
} else {
var signer gossh.Signer
if passphrase != "" {
signer, err = gossh.ParsePrivateKeyWithPassphrase(keyPEM, []byte(passphrase))
} else {
signer, err = gossh.ParsePrivateKey(keyPEM)
}
if err != nil {
slog.Warn("failed to parse SSH key", "error", err)
} else {
authMethods = append(authMethods, gossh.PublicKeys(signer))
}
}
}
}
}
}
// Fallback: keyboard-interactive (prompts for password)
if len(authMethods) == 0 {
slog.Info("no stored credentials, SSH will attempt keyboard-interactive auth")
authMethods = append(authMethods, gossh.KeyboardInteractive(
func(name, instruction string, questions []string, echos []bool) ([]string, error) {
// No interactive prompt available yet — return empty
return make([]string, len(questions)), nil
},
))
}
sessionID, err := a.SSH.Connect(conn.Hostname, conn.Port, username, authMethods, cols, rows)
if err != nil {
return "", fmt.Errorf("SSH connect failed: %w", err)
}
// Register SFTP client on the same SSH connection
if sess, ok := a.SSH.GetSession(sessionID); ok && sess != nil {
sftpClient, err := sftplib.NewClient(sess.Client)
if err != nil {
slog.Warn("failed to create SFTP client", "error", err)
// Non-fatal — SSH still works without SFTP
} else {
a.SFTP.RegisterClient(sessionID, sftpClient)
}
}
// Update last_connected timestamp
if _, err := a.db.Exec("UPDATE connections SET last_connected = CURRENT_TIMESTAMP WHERE id = ?", connectionID); err != nil {
slog.Warn("failed to update last_connected", "error", err)
}
slog.Info("SSH session started", "sessionID", sessionID, "host", conn.Hostname, "user", username)
return sessionID, nil
}
// DisconnectSession closes an active SSH session and its SFTP client.
func (a *WraithApp) DisconnectSession(sessionID string) error {
a.SFTP.RemoveClient(sessionID)
return a.SSH.Disconnect(sessionID)
}
// ImportMobaConf parses a MobaXTerm .mobaconf file and imports its contents
// (groups, connections, host keys) into the database.
func (a *WraithApp) ImportMobaConf(fileContent string) (*plugin.ImportResult, error) {

View File

@ -341,6 +341,11 @@ func classifyPublicKey(pub ssh.PublicKey) string {
}
// getCredential retrieves a single credential by ID.
// GetCredential retrieves a single credential by ID.
func (s *CredentialService) GetCredential(id int64) (*Credential, error) {
return s.getCredential(id)
}
func (s *CredentialService) getCredential(id int64) (*Credential, error) {
var c Credential
var username, domain sql.NullString

View File

@ -42,6 +42,9 @@ func main() {
},
})
// Wire Wails app reference for event emission (SSH output → frontend)
wraith.SetWailsApp(app)
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "Wraith",
Width: 1400,