Compare commits

..

No commits in common. "47fb066f1d110612d14f38ed1a773bb2532820e2" and "110f489ace70997a935c5463954180e58784fcf9" have entirely different histories.

193 changed files with 27724 additions and 21298 deletions

View File

@ -1,8 +1,9 @@
# =============================================================================
# Wraith v2 — Build & Sign Release (Tauri v2)
# Wraith — Build & Sign Release
# =============================================================================
# Builds the Tauri desktop app for Windows amd64, signs with Azure Key Vault
# EV cert, creates NSIS installer, uploads to Gitea packages + releases.
# Builds the Wails v3 desktop app for Windows amd64, cross-compiles FreeRDP3
# from source via MinGW, signs everything with Azure Key Vault EV cert,
# then uploads to SeaweedFS.
#
# Trigger: push a tag matching v* (e.g. v1.0.0) or run manually.
#
@ -12,9 +13,7 @@
# AZURE_CLIENT_SECRET — Service principal secret
# AZURE_KEY_VAULT_URL — e.g. https://my-vault.vault.azure.net
# AZURE_CERT_NAME — Certificate/key name in the vault
# GIT_TOKEN — PAT for cloning private repo + uploading packages
# TAURI_SIGNING_PRIVATE_KEY — Tauri updater signing key (base64)
# TAURI_SIGNING_PRIVATE_KEY_PASSWORD — Password for the signing key
# GIT_TOKEN — PAT for cloning private repo
# =============================================================================
name: Build & Sign Wraith
@ -28,20 +27,19 @@ on:
jobs:
build-and-sign:
name: Build Windows + Sign
runs-on: windows-latest
runs-on: linux
steps:
# ---------------------------------------------------------------
# Checkout
# ---------------------------------------------------------------
- name: Checkout code
uses: actions/checkout@v4
run: git clone --depth 1 --branch ${{ github.ref_name }} https://${{ secrets.GIT_TOKEN }}@git.command.vigilcyber.com/vstockwell/wraith.git .
# ---------------------------------------------------------------
# Extract version from tag
# ---------------------------------------------------------------
- name: Get version from tag
id: version
shell: bash
run: |
TAG=$(echo "${{ github.ref_name }}" | sed 's/^v//')
echo "version=${TAG}" >> $GITHUB_OUTPUT
@ -50,46 +48,197 @@ jobs:
# ---------------------------------------------------------------
# Install toolchain
# ---------------------------------------------------------------
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- name: Install frontend dependencies
run: npm ci
# ---------------------------------------------------------------
# Build with Tauri
# ---------------------------------------------------------------
- name: Build Tauri app
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: npx tauri build
# ---------------------------------------------------------------
# Code signing — jsign + Azure Key Vault (EV cert)
# ---------------------------------------------------------------
- name: Install Java (for jsign)
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
- name: Download jsign
shell: bash
- name: Install build dependencies
run: |
curl -sSL -o jsign.jar \
"https://github.com/ebourg/jsign/releases/download/7.0/jsign-7.0.jar"
apt-get update -qq
apt-get install -y -qq \
mingw-w64 mingw-w64-tools binutils-mingw-w64 \
cmake ninja-build nasm meson \
default-jre-headless \
python3 curl pkg-config nsis
# Node.js 22 — Tailwind CSS v4 and Naive UI require Node >= 20
NODE_MAJOR=$(node --version 2>/dev/null | sed 's/v\([0-9]*\).*/\1/' || echo "0")
if [ "$NODE_MAJOR" -lt 20 ]; then
echo "Node $NODE_MAJOR is too old, installing Node 22..."
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y -qq nodejs
fi
echo "=== Toolchain versions ==="
go version
node --version
x86_64-w64-mingw32-gcc --version | head -1
cmake --version | head -1
# ===============================================================
# FreeRDP3 dependencies — cross-compile zlib + OpenSSL for MinGW
# ===============================================================
- name: Build FreeRDP3 dependencies (zlib + OpenSSL for MinGW)
run: |
INSTALL_PREFIX="/tmp/mingw-deps"
mkdir -p "$INSTALL_PREFIX"
export CROSS=x86_64-w64-mingw32
# --- zlib ---
echo "=== Building zlib for MinGW ==="
ZLIB_VERSION="1.3.1"
curl -sSL -o /tmp/zlib.tar.gz "https://github.com/madler/zlib/releases/download/v${ZLIB_VERSION}/zlib-${ZLIB_VERSION}.tar.gz"
tar -xzf /tmp/zlib.tar.gz -C /tmp
cd /tmp/zlib-${ZLIB_VERSION}
CC=${CROSS}-gcc AR=${CROSS}-ar RANLIB=${CROSS}-ranlib \
./configure --prefix="$INSTALL_PREFIX" --static
make -j$(nproc)
make install
echo "zlib installed to $INSTALL_PREFIX"
# --- OpenSSL ---
echo "=== Building OpenSSL for MinGW ==="
OPENSSL_VERSION="3.4.1"
curl -sSL -o /tmp/openssl.tar.gz "https://github.com/openssl/openssl/releases/download/openssl-${OPENSSL_VERSION}/openssl-${OPENSSL_VERSION}.tar.gz"
tar -xzf /tmp/openssl.tar.gz -C /tmp
cd /tmp/openssl-${OPENSSL_VERSION}
./Configure mingw64 no-shared no-tests \
--cross-compile-prefix=${CROSS}- \
--prefix="$INSTALL_PREFIX"
make -j$(nproc)
make install_sw
echo "OpenSSL installed to $INSTALL_PREFIX"
# ===============================================================
# FreeRDP3 — Cross-compile from source via MinGW
# ===============================================================
- name: Build FreeRDP3 for Windows (MinGW cross-compile)
run: |
FREERDP_VERSION="3.10.3"
INSTALL_PREFIX="/tmp/mingw-deps"
echo "=== Building FreeRDP ${FREERDP_VERSION} for Windows amd64 via MinGW ==="
# Nuke any cached source/build from previous runs
rm -rf /tmp/FreeRDP-* /tmp/freerdp-install /tmp/freerdp.tar.gz
# Download FreeRDP source
curl -sSL -o /tmp/freerdp.tar.gz "https://github.com/FreeRDP/FreeRDP/archive/refs/tags/${FREERDP_VERSION}.tar.gz"
tar -xzf /tmp/freerdp.tar.gz -C /tmp
cd /tmp/FreeRDP-${FREERDP_VERSION}
# Create MinGW toolchain file pointing to our built deps
cat > /tmp/mingw-toolchain.cmake << TCEOF
set(CMAKE_SYSTEM_NAME Windows)
set(CMAKE_SYSTEM_PROCESSOR AMD64)
set(CMAKE_C_COMPILER x86_64-w64-mingw32-gcc)
set(CMAKE_CXX_COMPILER x86_64-w64-mingw32-g++)
set(CMAKE_RC_COMPILER x86_64-w64-mingw32-windres)
set(CMAKE_FIND_ROOT_PATH /usr/x86_64-w64-mingw32;${INSTALL_PREFIX})
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_PREFIX_PATH ${INSTALL_PREFIX})
set(OPENSSL_ROOT_DIR ${INSTALL_PREFIX})
set(ZLIB_ROOT ${INSTALL_PREFIX})
TCEOF
# Force suppress all warnings via environment — FreeRDP cmake overrides CMAKE_C_FLAGS
export CFLAGS="-w"
export CXXFLAGS="-w"
# Stub out unwind source — uses dlfcn.h which doesn't exist in MinGW
# Replace with empty implementations so cmake still finds the file
cat > winpr/libwinpr/utils/unwind/debug.c << 'STUBEOF'
#include <winpr/wtypes.h>
void* winpr_unwind_backtrace(void) { return NULL; }
char** winpr_unwind_backtrace_symbols(void* buffer, size_t* used) { *used = 0; return NULL; }
void winpr_unwind_backtrace_free(void* buffer) { }
STUBEOF
cmake -B build -G Ninja \
-DCMAKE_TOOLCHAIN_FILE=/tmp/mingw-toolchain.cmake \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_C_FLAGS_INIT="-w" \
-DCMAKE_INSTALL_PREFIX=/tmp/freerdp-install \
-DBUILD_SHARED_LIBS=ON \
-DWITH_CLIENT=OFF \
-DWITH_CLIENT_SDL=OFF \
-DWITH_CLIENT_WINDOWS=OFF \
-DWITH_SERVER=OFF \
-DWITH_SHADOW=OFF \
-DWITH_PROXY=OFF \
-DWITH_SAMPLE=OFF \
-DWITH_PLATFORM_SERVER=OFF \
-DWITH_WINPR_TOOLS=OFF \
-DWITH_FFMPEG=OFF \
-DWITH_SWSCALE=OFF \
-DWITH_CAIRO=OFF \
-DWITH_CUPS=OFF \
-DWITH_PULSE=OFF \
-DWITH_ALSA=OFF \
-DWITH_OSS=OFF \
-DWITH_WAYLAND=OFF \
-DWITH_X11=OFF \
-DCHANNEL_URBDRC=OFF \
-DWITH_OPENH264=OFF \
-DWITH_LIBSYSTEMD=OFF \
-DWITH_UNWIND=OFF \
-DWITH_PCSC=OFF \
-DWITH_SMARTCARD=OFF
# Build — use -v for verbose output on failure
cmake --build build --parallel $(nproc) -- -v 2>&1 || {
echo "=== Build failed — retrying with single thread for clear error output ==="
cmake --build build -j1 -- -v 2>&1 | tail -50
exit 1
}
cmake --install build
echo "=== FreeRDP3 DLLs built ==="
ls -la /tmp/freerdp-install/bin/*.dll 2>/dev/null || ls -la /tmp/freerdp-install/lib/*.dll 2>/dev/null || echo "Checking build output..."
find /tmp/freerdp-install -name "*.dll" -type f
- name: Stage FreeRDP3 DLLs
run: |
mkdir -p dist
# Copy all FreeRDP DLLs (MinGW produces lib-prefixed names)
find /tmp/freerdp-install -name "*.dll" -type f -exec cp {} dist/ \;
echo "=== Staged DLLs ==="
ls -la dist/*.dll 2>/dev/null || echo "No DLLs found — FreeRDP build may have failed"
# ===============================================================
# Build Wraith
# ===============================================================
- name: Build frontend
run: |
cd frontend
npm ci
npm run build
echo "Frontend build complete:"
ls -la dist/
- name: Build wraith.exe (Windows amd64)
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "=== Cross-compiling wraith.exe for Windows amd64 ==="
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 \
go build \
-ldflags="-s -w -H windowsgui -X main.version=${VERSION}" \
-o dist/wraith.exe \
.
ls -la dist/wraith.exe
# ===============================================================
# Code signing — jsign + Azure Key Vault (EV cert)
# ===============================================================
- name: Install jsign
run: |
JSIGN_VERSION="7.0"
curl -sSL -o /usr/local/bin/jsign.jar \
"https://github.com/ebourg/jsign/releases/download/${JSIGN_VERSION}/jsign-${JSIGN_VERSION}.jar"
- name: Get Azure Key Vault access token
id: azure-token
shell: bash
run: |
TOKEN=$(curl -s -X POST \
"https://login.microsoftonline.com/${{ secrets.AZURE_TENANT_ID }}/oauth2/v2.0/token" \
@ -97,21 +246,17 @@ jobs:
-d "client_secret=${{ secrets.AZURE_CLIENT_SECRET }}" \
-d "scope=https://vault.azure.net/.default" \
-d "grant_type=client_credentials" \
| python -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
echo "::add-mask::${TOKEN}"
echo "token=${TOKEN}" >> $GITHUB_OUTPUT
- name: Sign Windows binaries
shell: bash
- name: Sign all Windows binaries
run: |
echo "=== Signing Wraith binaries with EV certificate ==="
BUNDLE_DIR="src-tauri/target/release/bundle"
# Sign the main exe
for binary in ${BUNDLE_DIR}/nsis/*.exe; do
echo "=== Signing all .exe and .dll files with EV certificate ==="
for binary in dist/*.exe dist/*.dll; do
[ -f "$binary" ] || continue
echo "Signing: $binary"
java -jar jsign.jar \
java -jar /usr/local/bin/jsign.jar \
--storetype AZUREKEYVAULT \
--keystore "${{ secrets.AZURE_KEY_VAULT_URL }}" \
--storepass "${{ steps.azure-token.outputs.token }}" \
@ -122,83 +267,170 @@ jobs:
echo "Signed: $binary"
done
# ---------------------------------------------------------------
# Create version.json
# ---------------------------------------------------------------
# ===============================================================
# Version manifest
# ===============================================================
- name: Create version.json
shell: bash
run: |
VERSION="${{ steps.version.outputs.version }}"
BUNDLE_DIR="src-tauri/target/release/bundle/nsis"
INSTALLER=$(ls ${BUNDLE_DIR}/*.exe | head -1)
SHA=$(sha256sum "$INSTALLER" | awk '{print $1}')
EXE_SHA=$(sha256sum dist/wraith.exe | awk '{print $1}')
cat > version.json << EOF
# Build DLL manifest
DLL_ENTRIES=""
for dll in dist/*.dll; do
[ -f "$dll" ] || continue
DLL_NAME=$(basename "$dll")
DLL_SHA=$(sha256sum "$dll" | awk '{print $1}')
DLL_ENTRIES="${DLL_ENTRIES} \"${DLL_NAME}\": \"${DLL_SHA}\",
"
done
cat > dist/version.json << EOF
{
"version": "${VERSION}",
"filename": "$(basename $INSTALLER)",
"sha256": "${SHA}",
"filename": "wraith.exe",
"sha256": "${EXE_SHA}",
"platform": "windows",
"architecture": "amd64",
"released": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
"signed": true
"signed": true,
"dlls": {
${DLL_ENTRIES} "_note": "All DLLs are EV code-signed"
}
}
EOF
echo "=== version.json ==="
cat version.json
cat dist/version.json
# ---------------------------------------------------------------
# ===============================================================
# NSIS Installer
# ===============================================================
- name: Build NSIS installer
run: |
VERSION="${{ steps.version.outputs.version }}"
cat > /tmp/wraith-installer.nsi << 'NSIEOF'
!include "MUI2.nsh"
Name "Wraith"
OutFile "wraith-setup.exe"
InstallDir "$PROGRAMFILES64\Wraith"
InstallDirRegKey HKLM "Software\Wraith" "InstallDir"
RequestExecutionLevel admin
!define MUI_ICON "wraith-logo.ico"
!insertmacro MUI_PAGE_DIRECTORY
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_LANGUAGE "English"
Section "Install"
SetOutPath "$INSTDIR"
File "wraith.exe"
File "*.dll"
File "version.json"
File "wraith-logo.ico"
; Start Menu shortcut
CreateDirectory "$SMPROGRAMS\Wraith"
CreateShortcut "$SMPROGRAMS\Wraith\Wraith.lnk" "$INSTDIR\wraith.exe" "" "$INSTDIR\wraith-logo.ico"
CreateShortcut "$DESKTOP\Wraith.lnk" "$INSTDIR\wraith.exe" "" "$INSTDIR\wraith-logo.ico"
; Uninstaller
WriteUninstaller "$INSTDIR\uninstall.exe"
; Registry
WriteRegStr HKLM "Software\Wraith" "InstallDir" "$INSTDIR"
WriteRegStr HKLM "Software\Wraith" "Version" "${VERSION}"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Wraith" "DisplayName" "Wraith"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Wraith" "UninstallString" "$INSTDIR\uninstall.exe"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Wraith" "DisplayVersion" "${VERSION}"
WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Wraith" "Publisher" "Vantz Stockwell"
SectionEnd
Section "Uninstall"
Delete "$INSTDIR\wraith.exe"
Delete "$INSTDIR\*.dll"
Delete "$INSTDIR\version.json"
Delete "$INSTDIR\uninstall.exe"
RMDir "$INSTDIR"
Delete "$SMPROGRAMS\Wraith\Wraith.lnk"
RMDir "$SMPROGRAMS\Wraith"
Delete "$DESKTOP\Wraith.lnk"
DeleteRegKey HKLM "Software\Wraith"
DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Wraith"
SectionEnd
NSIEOF
# Replace VERSION placeholder
sed -i "s/\${VERSION}/${VERSION}/g" /tmp/wraith-installer.nsi
# Copy NSIS script and icon into dist so File directives find them
cp /tmp/wraith-installer.nsi dist/wraith-installer.nsi
cp images/wraith-logo.ico dist/wraith-logo.ico
cd dist
makensis -DVERSION="${VERSION}" wraith-installer.nsi
mv wraith-setup.exe ../wraith-${VERSION}-setup.exe
rm wraith-installer.nsi
cd ..
echo "=== Installer built ==="
ls -la wraith-${VERSION}-setup.exe
- name: Sign installer
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "=== Signing installer ==="
java -jar /usr/local/bin/jsign.jar \
--storetype AZUREKEYVAULT \
--keystore "${{ secrets.AZURE_KEY_VAULT_URL }}" \
--storepass "${{ steps.azure-token.outputs.token }}" \
--alias "${{ secrets.AZURE_CERT_NAME }}" \
--tsaurl http://timestamp.digicert.com \
--tsmode RFC3161 \
wraith-${VERSION}-setup.exe
echo "Installer signed."
# Move to dist for upload
mv wraith-${VERSION}-setup.exe dist/
# ===============================================================
# Upload to Gitea Package Registry
# ---------------------------------------------------------------
# ===============================================================
- name: Upload to Gitea packages
shell: bash
run: |
VERSION="${{ steps.version.outputs.version }}"
GITEA_URL="https://git.command.vigilcyber.com"
OWNER="vstockwell"
PACKAGE="wraith"
BUNDLE_DIR="src-tauri/target/release/bundle/nsis"
echo "=== Uploading Wraith v${VERSION} to Gitea packages ==="
echo "=== Uploading Wraith ${VERSION} to Gitea packages ==="
# Upload installer
INSTALLER=$(ls ${BUNDLE_DIR}/*.exe | head -1)
FILENAME=$(basename "$INSTALLER")
# Upload each file as a generic package
for file in dist/*; do
[ -f "$file" ] || continue
FILENAME=$(basename "$file")
echo "Uploading: ${FILENAME}"
curl -s -X PUT \
-H "Authorization: token ${{ secrets.GIT_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"$INSTALLER" \
--data-binary @"$file" \
"${GITEA_URL}/api/packages/${OWNER}/generic/${PACKAGE}/${VERSION}/${FILENAME}"
# Upload version.json
echo "Uploading: version.json"
curl -s -X PUT \
-H "Authorization: token ${{ secrets.GIT_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"version.json" \
"${GITEA_URL}/api/packages/${OWNER}/generic/${PACKAGE}/${VERSION}/version.json"
# Upload Tauri updater signature if it exists
SIG_FILE=$(ls ${BUNDLE_DIR}/*.sig 2>/dev/null | head -1)
if [ -f "$SIG_FILE" ]; then
echo "Uploading: $(basename $SIG_FILE)"
curl -s -X PUT \
-H "Authorization: token ${{ secrets.GIT_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"$SIG_FILE" \
"${GITEA_URL}/api/packages/${OWNER}/generic/${PACKAGE}/${VERSION}/$(basename $SIG_FILE)"
fi
echo " Done."
done
echo ""
echo "=== Upload complete ==="
echo "Package: ${GITEA_URL}/${OWNER}/-/packages/generic/${PACKAGE}/${VERSION}"
echo ""
echo "=== Contents ==="
ls -la dist/
# ---------------------------------------------------------------
# Create Gitea Release (with v prefix to match git tag)
# ---------------------------------------------------------------
# ===============================================================
# Create Gitea Release
# ===============================================================
- name: Create Gitea Release
shell: bash
run: |
VERSION="${{ steps.version.outputs.version }}"
GITEA_URL="https://git.command.vigilcyber.com"
@ -207,7 +439,7 @@ jobs:
curl -s -X POST \
-H "Authorization: token ${{ secrets.GIT_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"v${VERSION}\", \"name\": \"Wraith v${VERSION}\", \"body\": \"Wraith Desktop v${VERSION} — Tauri v2 build.\"}" \
-d "{\"tag_name\": \"v${VERSION}\", \"name\": \"Wraith v${VERSION}\", \"body\": \"Auto-release from CI build.\"}" \
"${GITEA_URL}/api/v1/repos/vstockwell/wraith/releases"
echo ""
echo "Release created."

30
.gitignore vendored
View File

@ -1,5 +1,29 @@
node_modules/
# Go
bin/
dist/
src-tauri/target/
*.log
*.exe
# Frontend
frontend/node_modules/
frontend/dist/
frontend/bindings/
# Wails
build/bin/
# IDE
.vscode/
.idea/
# OS
.DS_Store
Thumbs.db
# App data
*.db
*.db-wal
*.db-shm
# Superpowers
.superpowers/
.claude/

View File

@ -1,72 +0,0 @@
# CLAUDE.md — Wraith Desktop v2
## Project Overview
Wraith is a native desktop SSH/SFTP/RDP client — a MobaXTerm replacement. Rust backend (Tauri v2) + Vue 3 frontend (WebView2). Single binary, no Docker, no sidecar processes.
**Name:** Wraith — exists everywhere, all at once.
## Tech Stack
- **Runtime:** Tauri v2 (stable)
- **Backend:** Rust with `russh` (SSH/SFTP), `ironrdp` (RDP), `rusqlite` (SQLite), `aes-gcm` + `argon2` (vault), `dashmap` (concurrent session registry)
- **Frontend:** Vue 3 (Composition API, `<script setup>`), TypeScript, Vite, Pinia, Tailwind CSS v4, xterm.js 6, CodeMirror 6
- **Distribution:** Tauri bundler (NSIS installer), auto-updater with code signing
## Project Structure
```
src-tauri/ # Rust backend
src/
main.rs # Entry point
lib.rs # App state, module declarations, Tauri setup
ssh/ # SSH service (russh), host keys (TOFU), CWD tracker
sftp/ # SFTP operations (russh-sftp)
rdp/ # RDP service (ironrdp), scancode mapping
vault/ # Encryption (Argon2id + AES-256-GCM)
db/ # SQLite (rusqlite), migrations
connections/ # Connection CRUD, groups, search
credentials/ # Credential CRUD, encrypted storage
settings/ # Key-value settings
theme/ # Terminal themes (7 built-in)
session/ # Session manager (DashMap)
workspace/ # Workspace snapshots, crash recovery
commands/ # Tauri command wrappers
src/ # Vue 3 frontend
layouts/ # MainLayout, UnlockLayout
components/ # UI components
composables/ # useTerminal, useSftp, useRdp, useTransfers
stores/ # Pinia stores (app, session, connection)
assets/ # CSS, images
```
## Commands
```bash
npm install # Install frontend deps
npm run dev # Vite dev server only
cargo tauri dev # Full app (Rust + frontend)
cargo tauri build # Production build
cd src-tauri && cargo test # Run Rust tests (52 tests)
cd src-tauri && cargo build # Build Rust only
```
## Architecture
- **Sessions use DashMap** — lock-free concurrent access, no deadlocks during tab detach
- **Drop trait for cleanup** — SSH/SFTP/RDP connections close automatically when sessions drop
- **CWD following via exec channel** — polls `pwd` on a separate SSH channel every 2 seconds. Never touches the terminal data stream. This avoids ANSI escape sequence corruption.
- **RDP runs in dedicated thread** — ironrdp's trait objects aren't Send, so each RDP session gets its own tokio runtime in a std::thread
- **xterm.js font handling**`document.fonts.ready.then(() => fitAddon.fit())` prevents cell width miscalculation
## Key Design Decisions
1. **No terminal stream processing.** The Go version's CWD tracker parsed OSC 7 from the terminal output and corrupted ANSI sequences. Never again.
2. **Tauri v2 over Wails v3.** Wails v3 is alpha with breaking changes. Tauri v2 is stable with built-in multi-window, auto-updater, and active community.
3. **ironrdp over FreeRDP FFI.** Pure Rust, no DLL dependency, memory safe. FreeRDP is the fallback discussion if ironrdp can't hit performance targets.
4. **Fresh vault, no Go migration.** 6 connections — faster to re-enter than engineer format compatibility.
## Lineage
This is a ground-up Rust rewrite of `wraith` (Go/Wails v3). The Go version is archived at `wraith-go-legacy`. The original design spec is at `docs/superpowers/specs/2026-03-17-wraith-desktop-design.md` in the Go repo.

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Vantz Stockwell
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

242
README.md Normal file
View File

@ -0,0 +1,242 @@
<p align="center">
<img src="images/wraith-logo.png" alt="Wraith" width="128" />
</p>
<h1 align="center">Wraith</h1>
<p align="center">
Native desktop SSH + RDP + SFTP client — a MobaXTerm replacement built with Go and Vue.
</p>
<p align="center">
<!-- badges -->
<img alt="Go" src="https://img.shields.io/badge/Go-1.22+-00ADD8?logo=go&logoColor=white" />
<img alt="Wails v3" src="https://img.shields.io/badge/Wails-v3-red" />
<img alt="Vue 3" src="https://img.shields.io/badge/Vue-3-4FC08D?logo=vuedotjs&logoColor=white" />
<img alt="License" src="https://img.shields.io/badge/License-MIT-blue" />
</p>
---
## Features
- **Multi-tabbed SSH terminal** with xterm.js + WebGL rendering
- **SFTP sidebar** on every SSH session (MobaXTerm's killer feature) -- same SSH connection, separate channel
- **RDP** via FreeRDP3 dynamic linking (purego, no CGO)
- **Encrypted vault** -- master password derived with Argon2id, secrets sealed with AES-256-GCM
- **Connection manager** with hierarchical groups, tags, color labels, and full-text search
- **7 built-in terminal themes** -- Dracula, Nord, Monokai, One Dark, Solarized Dark, Gruvbox Dark, MobaXTerm Classic
- **Tab detach / reattach** -- sessions live in the Go backend; tabs can be torn off into separate windows and reattached without dropping the connection
- **MobaXTerm import** -- plugin interface for `.mobaconf` and other formats
- **Command palette** (Ctrl+K) for quick connection search and actions
- **Single binary** -- ships as `wraith.exe` + `freerdp3.dll`, no Docker, no database server
## Tech Stack
### Backend (Go)
| Component | Technology | Purpose |
|-----------|-----------|---------|
| Framework | Wails v3 | Desktop shell, multi-window, type-safe Go-to-JS bindings |
| SSH | `golang.org/x/crypto/ssh` | SSH client, PTY, key/password auth |
| SFTP | `github.com/pkg/sftp` | Remote filesystem over SSH channel |
| RDP | FreeRDP3 via `purego` | RDP protocol, bitmap rendering |
| Database | SQLite via `modernc.org/sqlite` (pure Go) | Connections, credentials, settings, themes |
| Encryption | AES-256-GCM + Argon2id | Vault encryption at rest |
### Frontend (Vue 3 in WebView2)
| Component | Technology | Purpose |
|-----------|-----------|---------|
| Framework | Vue 3 (Composition API) | UI framework |
| Terminal | xterm.js 5.x + WebGL addon | SSH terminal emulator |
| CSS | Tailwind CSS 4 | Utility-first styling |
| Components | Naive UI | Tree, tabs, modals, dialogs |
| State | Pinia | Reactive stores for sessions, connections, app state |
| Build | Vite 6 | Frontend build tooling |
## Prerequisites
| Tool | Version | Install |
|------|---------|---------|
| Go | 1.22+ | [go.dev/dl](https://go.dev/dl/) |
| Node.js | 20+ | [nodejs.org](https://nodejs.org/) |
| Wails CLI | v3 | `go install github.com/wailsapp/wails/v3/cmd/wails3@latest` |
## Quick Start
```bash
# Clone
git clone https://github.com/vstockwell/wraith.git
cd wraith
# Install frontend dependencies
cd frontend && npm install && cd ..
# Run in dev mode (hot-reload frontend + Go backend)
wails3 dev
```
The app opens a 1400x900 window. On first launch you will be prompted to create a master password for the vault.
## Building
```bash
# Production build for Windows
wails3 build
# Output: build/bin/wraith.exe
```
The build embeds the compiled frontend (`frontend/dist`) into the Go binary via `//go:embed`. Ship `wraith.exe` alongside `freerdp3.dll` for RDP support.
## Project Structure
```
wraith/
main.go # Entry point -- Wails app setup, service registration
go.mod # Go module (github.com/vstockwell/wraith)
internal/
app/
app.go # WraithApp -- wires all services, vault create/unlock
db/
sqlite.go # SQLite open with WAL mode, busy timeout, FK enforcement
migrations.go # Embedded SQL migration runner
migrations/
001_initial.sql # Schema: groups, connections, credentials, ssh_keys,
# themes, host_keys, connection_history, settings
vault/
service.go # Argon2id key derivation + AES-256-GCM encrypt/decrypt
connections/
service.go # Connection and Group CRUD (hierarchical tree)
search.go # Full-text search + tag filtering via json_each()
settings/
service.go # Key-value settings store (vault salt, preferences)
theme/
builtins.go # 7 built-in theme definitions
service.go # Theme CRUD + idempotent seeding
session/
session.go # SessionInfo struct + state machine (connecting/connected/detached)
manager.go # Concurrent session manager -- create, detach, reattach, 32-session cap
plugin/
interfaces.go # ProtocolHandler + Importer + Session interfaces
registry.go # Plugin registry -- register/lookup protocol handlers and importers
frontend/
package.json # Vue 3, Pinia, Naive UI, Tailwind CSS, Vite
vite.config.ts # Vite + Vue + Tailwind plugin config
src/
main.ts # App bootstrap -- createApp, Pinia, mount
App.vue # Root component
layouts/
MainLayout.vue # Sidebar + tab bar + session area + status bar
UnlockLayout.vue # Master password entry screen
components/
sidebar/
ConnectionTree.vue # Hierarchical connection/group tree
SidebarToggle.vue # Collapse/expand sidebar
session/
TabBar.vue # Draggable session tabs
SessionContainer.vue # Active session viewport
common/
StatusBar.vue # Bottom status bar
stores/
app.store.ts # Global app state (sidebar, vault status)
connection.store.ts # Connection + group state
session.store.ts # Active sessions state
images/
wraith-logo.png # Application logo
```
## Architecture
```
Go Backend Wails v3 Bindings Vue 3 Frontend
(services + business logic) (type-safe Go <-> JS) (WebView2)
|
WraithApp ─────────────────────────┼──────────────> Pinia Stores
|-- VaultService | |-- app.store
|-- ConnectionService | |-- connection.store
|-- ThemeService | |-- session.store
|-- SettingsService | |
|-- SessionManager | Vue Components
|-- PluginRegistry | |-- MainLayout
| |-- ConnectionTree
SQLite (WAL mode) | |-- TabBar
wraith.db | |-- SessionContainer
%APPDATA%\Wraith\ | |-- StatusBar
```
**How it fits together:**
1. `main.go` creates a `WraithApp` and registers Go services as Wails bindings.
2. Wails generates type-safe JavaScript bindings so the Vue frontend can call Go methods directly.
3. The Vue frontend uses Pinia stores to manage reactive state, calling into Go services for all data operations.
4. All secrets (passwords, SSH keys) are encrypted with AES-256-GCM before being written to SQLite. The encryption key is derived from the master password using Argon2id and is never persisted.
5. Sessions are managed by the Go `SessionManager` -- they are decoupled from windows, enabling tab detach/reattach without dropping connections.
**Data storage:** SQLite with WAL mode at `%APPDATA%\Wraith\wraith.db` (Windows) or `~/.local/share/wraith/wraith.db` (Linux/macOS dev). Foreign keys enforced, 5-second busy timeout.
## Plugin Development
Wraith uses a plugin registry with two interfaces: `ProtocolHandler` for new connection protocols and `Importer` for loading connections from external tools.
### Implementing a ProtocolHandler
```go
package myplugin
import "github.com/vstockwell/wraith/internal/plugin"
type MyProtocol struct{}
func (p *MyProtocol) Name() string { return "myproto" }
func (p *MyProtocol) Connect(config map[string]interface{}) (plugin.Session, error) {
// Establish connection, return a Session
}
func (p *MyProtocol) Disconnect(sessionID string) error {
// Clean up resources
}
```
### Implementing an Importer
```go
package myplugin
import "github.com/vstockwell/wraith/internal/plugin"
type MyImporter struct{}
func (i *MyImporter) Name() string { return "myformat" }
func (i *MyImporter) FileExtensions() []string { return []string{".myconf"} }
func (i *MyImporter) Parse(data []byte) (*plugin.ImportResult, error) {
// Parse file bytes into ImportResult (groups, connections, host keys, theme)
}
```
### Registering Plugins
Register handlers and importers with the plugin registry during app initialization:
```go
app.Plugins.RegisterProtocol(&myplugin.MyProtocol{})
app.Plugins.RegisterImporter(&myplugin.MyImporter{})
```
The `ImportResult` struct supports groups, connections, host keys, and an optional theme -- everything needed to migrate from another tool in a single import.
## Contributing
1. Fork the repository
2. Create a feature branch (`git checkout -b feat/my-feature`)
3. Make your changes
4. Run tests: `go test ./...`
5. Run frontend checks: `cd frontend && npm run build`
6. Commit and push your branch
7. Open a Pull Request
## License
[MIT](LICENSE) -- Copyright (c) 2026 Vantz Stockwell

54
docs/FUTURE-FEATURES.md Normal file
View File

@ -0,0 +1,54 @@
# Vigilance Remote — Future Features
Remaining spec items not yet built. Foundation is solid — all items below are additive, no rearchitecting required.
---
## Priority 1 — Power User
1. **Split panes** — Horizontal and vertical splits within a single tab (xterm.js instances in a flex grid)
2. **Session recording/playback** — asciinema-compatible casts for SSH, Guacamole native for RDP. Replay in browser. Audit trail for MSP compliance.
3. **Saved snippets/macros** — Quick-execute saved commands/scripts. Click to paste into active terminal.
## Priority 2 — MSP / Enterprise
4. **Jump hosts / bastion** — Configure SSH proxy/jump hosts for reaching targets behind firewalls (ProxyJump chain support)
5. **Port forwarding manager** — Graphical SSH tunnel manager: local, remote, and dynamic forwarding
6. **Entra ID SSO** — One-click Microsoft Entra ID integration (same pattern as Vigilance HQ)
7. **Client-scoped access** — MSP multi-tenancy: technicians see only the hosts for clients they're assigned to
8. **Shared connections** — Admins define connection templates. Technicians connect without seeing credentials.
## Priority 3 — Audit & Compliance
9. **Command-level audit logging** — Every command, file transfer logged with user, timestamp, duration (currently connection-level only)
10. **Session sharing** — Share a live terminal session with a colleague (read-only or collaborative)
## Priority 4 — File Transfer
11. **Dual-pane SFTP** — Optional second SFTP panel for server-to-server file operations (drag between panels)
12. **Transfer queue** — Background upload/download queue with progress bars, pause/resume, retry
## Priority 5 — RDP Enhancements
13. **Multi-monitor RDP** — Support for multiple virtual displays
14. **RDP file transfer** — Upload/download via Guacamole's built-in drive redirection
## Priority 6 — Auth Hardening
15. **FIDO2 / hardware key auth** — WebAuthn support for login and SSH
16. **SSH agent forwarding** — Forward local SSH agent to remote host
---
## Already Built (exceeds spec)
- SSH terminal (xterm.js + ssh2 + WebSocket proxy + WebGL)
- RDP (guacd + guacamole-common-js + display.scale())
- SFTP sidebar (auto-open, CWD following via OSC 7, drag-and-drop upload)
- Monaco file editor (fullscreen overlay with syntax highlighting)
- Connection manager (hosts, groups, quick connect, search, tags, colors)
- Credential vault (AES-256-GCM + **Argon2id key derivation**)
- Multi-tab sessions + Home navigation
- Terminal theming (6+ themes with visual picker)
- Multi-user with admin/user roles + per-user data isolation
- User management admin UI

View File

@ -1,67 +0,0 @@
# Go → Rust Migration Checklist
## Pre-Migration (Before Deploying Wraith v2)
- [ ] Test Wraith v2 on Windows — SSH to all 6 hosts, SFTP browse/upload/download, RDP to Hyper-V
- [ ] Verify vault creation + credential storage works
- [ ] Verify auto-updater finds releases
- [ ] Test code signing on installer
- [ ] Set up 6 connections manually (no import needed)
## Migration Steps
### 1. Create Gitea Repo
```bash
# Create wraith repo on Gitea (or rename current wraith to wraith-go-legacy)
# Push wraith codebase
cd /path/to/wraith
git remote add origin ssh://git.command.vigilcyber.com:3021/vstockwell/wraith.git
git push -u origin main
```
### 2. Configure CI Secrets
Add these secrets to the wraith repo on Gitea:
- `AZURE_TENANT_ID`
- `AZURE_CLIENT_ID`
- `AZURE_CLIENT_SECRET`
- `AZURE_KEY_VAULT_URL`
- `AZURE_CERT_NAME`
- `GIT_TOKEN`
- `TAURI_SIGNING_PRIVATE_KEY` (generate with `npx tauri signer generate`)
- `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`
### 3. First Release
```bash
git tag v1.0.0
git push origin v1.0.0
```
### 4. Uninstall Go Version
- Use a software uninstaller (the Go NSIS uninstaller is broken)
- Or manually delete: `C:\Program Files\Wraith\` and `%APPDATA%\Wraith\`
- Note: deleting `%APPDATA%\Wraith\wraith.db` removes Go version credentials (you're re-entering them anyway)
### 5. Archive Go Repository
On Gitea:
1. Rename `wraith` repo to `wraith-go-legacy`
2. Set repo to archived/read-only
3. Rename `wraith` to `wraith`
4. Update auto-updater endpoint in tauri.conf.json to new repo path
5. Delete old Gitea packages (Go-built versions)
### 6. Clean Up Local
```bash
# Remove old Go repo (keep backup if desired)
mv ~/repos/wraith ~/repos/wraith-go-legacy-backup
mv ~/repos/wraith ~/repos/wraith
```
## What Was NOT Migrated
| Component | Reason |
|---|---|
| AI Copilot (8 Go files + 4 Vue files) | Claude Code over SSH replaces this |
| MobaXTerm importer | 6 connections — entered by hand |
| Plugin system | Not needed |
| Go test suite (90 tests) | Replaced by 52 Rust tests |
| wraith.db data | Fresh vault, fresh credentials |

View File

@ -0,0 +1,369 @@
# Wraith Remote — Security Audit Report
**Date:** 2026-03-14
**Auditor:** Claude (Opus 4.6) — secure-code-guardian + security-reviewer + ISO 27001 frameworks
**Scope:** Full-stack — Auth, Vault, WebSocket/SSH/SFTP/RDP, Frontend, Infrastructure, ISO 27001 gap assessment
**Codebase:** RDP-SSH-Client (Nuxt 3 + NestJS + guacd)
---
## Executive Summary
**54 unique findings** across 4 audit domains after deduplication.
| Severity | Count |
|----------|-------|
| CRITICAL | 8 |
| HIGH | 16 |
| MEDIUM | 18 |
| LOW | 12 |
The platform has a solid encryption foundation (Argon2id vault is well-implemented) but has significant gaps in transport security, session management, infrastructure hardening, and real-time channel authorization. The most urgent issues are **unauthenticated guacd exposure**, **JWT in localStorage/URLs**, and **missing session ownership checks on WebSocket channels**.
---
## CRITICAL Findings (8)
### C-1. guacd exposed on all interfaces via `network_mode: host`
**Location:** `docker-compose.yml:23`
**Domain:** Infrastructure
guacd runs with `network_mode: host` and binds to `0.0.0.0:4822`. guacd is an **unauthenticated** service — anyone who can reach port 4822 can initiate RDP/VNC connections to any host reachable from the Docker host. This completely bypasses all application-level authentication.
**Impact:** Full unauthenticated RDP/VNC access to every target host in the environment.
**Fix:** Remove `network_mode: host`. Place guacd on the internal Docker network. Bind to `127.0.0.1`. The app container connects via the Docker service name `guacd` over the internal network.
---
### C-2. JWT stored in localStorage (XSS → full account takeover)
**Location:** `frontend/stores/auth.store.ts:19,42`
**Domain:** Auth / Frontend
`wraith_token` JWT stored in `localStorage`. Any XSS payload, browser extension, or injected script can read it. The token has a 7-day lifetime with no revocation mechanism — a stolen token is valid for up to a week with no way to invalidate it.
**Impact:** Single XSS vulnerability → 7-day persistent access to the victim's account, including all SSH/RDP sessions and stored credentials.
**Fix:** Issue JWT via `Set-Cookie: httpOnly; Secure; SameSite=Strict`. Remove all `localStorage` token operations. Browser automatically attaches the cookie to every request.
---
### C-3. JWT passed in WebSocket URL query parameters
**Location:** `backend/src/auth/ws-auth.guard.ts:11-13`, all three WS gateways
**Domain:** Auth / WebSocket
All WebSocket connections (`/api/ws/terminal`, `/api/ws/sftp`, `/api/ws/rdp`) accept JWT via `?token=<jwt>` in the URL. Query parameters are logged by: web server access logs, browser history, Referrer headers, network proxies, and the application itself (`main.ts:75` logs `req.url`).
**Impact:** JWT exposure in every log and monitoring system in the path. Combined with C-2, this creates multiple extraction vectors for a 7-day-lived credential.
**Fix:** Issue short-lived (30-second) single-use WebSocket tickets via an authenticated REST endpoint. Frontend exchanges JWT for a ticket, connects WS with `?ticket=<nonce>`. Server validates and destroys the ticket on use.
---
### C-4. No HTTPS/TLS anywhere in the stack
**Location:** `docker-compose.yml`, `backend/src/main.ts`
**Domain:** Infrastructure
No TLS termination configured. No nginx reverse proxy. No `helmet()` middleware. The application serves over plain HTTP. JWT tokens, SSH passwords, and TOTP codes all transit in cleartext.
**Impact:** Any network observer (same Wi-Fi, ISP, network tap) can intercept credentials, tokens, and terminal data.
**Fix:** Add nginx with TLS termination in front of the app. Install `helmet()` in NestJS for security headers (HSTS, X-Frame-Options, X-Content-Type-Options). Enforce HTTPS-only.
---
### C-5. SSH host key verification auto-accepts all keys (MITM blind spot)
**Location:** `terminal.gateway.ts:61`, `ssh-connection.service.ts:98-119`
**Domain:** SSH
`hostVerifier` callback returns `true` unconditionally. New fingerprints are silently accepted. **Changed** fingerprints (active MITM) are also silently accepted and overwrite the stored fingerprint.
**Impact:** Man-in-the-middle attacker between the Wraith server and SSH target is completely invisible. Attacker gets the decrypted credentials and a live shell.
**Fix:** Block connections to hosts with changed fingerprints. Require explicit user acceptance via a WS round-trip for new hosts. Never auto-accept changed fingerprints.
---
### C-6. SFTP gateway has no session ownership check (horizontal privilege escalation)
**Location:** `sftp.gateway.ts:36-215`
**Domain:** SFTP / Authorization
`SftpGateway.handleMessage()` looks up sessions by caller-supplied `sessionId` without verifying the requesting WebSocket client owns that session. User B can supply User A's `sessionId` and get full filesystem access on User A's server.
**Impact:** Any authenticated user can read/write/delete files on any other user's active SSH session.
**Fix:** Maintain a `clientSessions` map in `SftpGateway` (same pattern as `TerminalGateway`). Verify session ownership before every SFTP operation.
---
### C-7. Raw Guacamole instructions forwarded to guacd without validation
**Location:** `rdp.gateway.ts:43-47`
**Domain:** RDP
When `msg.type === 'guac'`, raw `msg.instruction` is written directly to the guacd TCP socket. Zero parsing, validation, or opcode whitelisting. The Guacamole protocol supports `file`, `put`, `pipe`, `disconnect`, and other instructions.
**Impact:** Authenticated user can inject arbitrary Guacamole protocol instructions — write files via guacd file transfer, crash guacd via malformed instructions, or cause protocol desync.
**Fix:** Parse incoming instructions via `guacamole.service.ts` `decode()`. Whitelist permitted opcodes (`input`, `mouse`, `key`, `size`, `sync`, `disconnect`). Enforce per-message size limit. Reject anything that doesn't parse.
---
### C-8. PostgreSQL port exposed to host network
**Location:** `docker-compose.yml:27`
**Domain:** Infrastructure
`ports: ["4211:5432"]` maps PostgreSQL to the host. Without a host-level firewall rule, the database is network-accessible. Contains encrypted credentials, SSH private keys, TOTP secrets, password hashes.
**Impact:** Direct database access from the network. Even with password auth, the attack surface is unnecessary.
**Fix:** Remove the `ports` mapping. Only the app container needs DB access via the internal Docker network. Use `docker exec` for admin access.
---
## HIGH Findings (16)
### H-1. 7-day JWT with no revocation mechanism
**Location:** `backend/src/auth/auth.module.ts:14`
**Domain:** Auth
JWTs signed with 7-day expiry. No token blocklist, no session table, no refresh token pattern. Admin password reset, TOTP reset, and role changes do not invalidate existing tokens.
**Fix:** Short-lived access token (15min) + refresh token in httpOnly cookie. Or: Redis-backed blocklist checked on every request.
### H-2. RDP certificate verification hardcoded to disabled
**Location:** `rdp.gateway.ts:90`, `guacamole.service.ts:142`
**Domain:** RDP
`ignoreCert: true` hardcoded unconditionally. Every RDP connection accepts any certificate — MITM attacks are invisible.
**Fix:** Store `ignoreCert` as a per-host setting (default `false`). Surface a UI warning when enabled.
### H-3. TOTP secret stored as plaintext in database
**Location:** `users` table, `totp_secret` column
**Domain:** Auth / Vault
TOTP secrets stored unencrypted. If the database is compromised (C-8 makes this plausible), attacker can generate valid TOTP codes for every user with 2FA enabled, completely defeating the second factor.
**Fix:** Encrypt TOTP secrets using the vault's `EncryptionService` (Argon2id v2) before storage. Decrypt only when validating a TOTP code.
### H-4. SSH private key material logged in cleartext
**Location:** `ssh-connection.service.ts:126-129`
**Domain:** SSH / Logging
First 40 characters of decrypted private key, key length, and passphrase existence boolean logged to stdout. Docker routes stdout to `docker logs`, which may be shipped to external log aggregation.
**Fix:** Remove lines 126-129 entirely. Log only key type and fingerprint.
### H-5. Terminal keystroke data logged (passwords in sudo prompts)
**Location:** `terminal.gateway.ts:31`
**Domain:** WebSocket / Logging
`JSON.stringify(msg).substring(0, 200)` logs raw terminal keystrokes including passwords typed at `sudo` prompts. 200-char truncation still captures most passwords.
**Fix:** For `msg.type === 'data'`, log only `{ type: 'data', sessionId, bytes: msg.data?.length }`.
### H-6. No rate limiting on authentication endpoints
**Location:** Entire backend — no throttler installed
**Domain:** Auth / Infrastructure
No `@nestjs/throttler`, no `express-rate-limit`. Login endpoint accepts unlimited attempts. Combined with 6-character minimum password = viable online brute-force.
**Fix:** Install `@nestjs/throttler`. Apply tight limit on auth endpoints (10 req/min/IP). Add account lockout after repeated failures.
### H-7. Container runs as root
**Location:** `Dockerfile:19-28`
**Domain:** Infrastructure
Final Docker stage runs as `root`. Any code execution vulnerability (path traversal, injection) gives root access inside the container.
**Fix:** Add `RUN addgroup -S wraith && adduser -S wraith -G wraith` and `USER wraith` before `CMD`.
### H-8. Timing attack on login (bcrypt comparison)
**Location:** `auth.service.ts` login handler
**Domain:** Auth
Failed login for non-existent user returns faster than for existing user (skips bcrypt comparison). Enables username enumeration via timing analysis.
**Fix:** Always run `bcrypt.compare()` against a dummy hash when user not found, ensuring constant-time response.
### H-9. bcrypt cost factor is 10 (below modern recommendations)
**Location:** `auth.service.ts`
**Domain:** Auth
bcrypt cost 10 = ~100ms on modern hardware. OWASP recommends 12+ for password hashing.
**Fix:** Increase to `bcrypt.hash(password, 12)`. Existing hashes auto-upgrade on next login.
### H-10. `findAll` credentials endpoint leaks encrypted blobs
**Location:** `credentials.service.ts` / `credentials.controller.ts`
**Domain:** Vault
The `GET /api/credentials` response includes `encryptedValue` fields. While encrypted, exposing ciphertext over the API gives attackers material for offline analysis and is unnecessary — the frontend never needs the encrypted blob.
**Fix:** Add `select` clause to exclude `encryptedValue` from list responses.
### H-11. No upload size limit on SFTP
**Location:** `sftp.gateway.ts:130-138`
**Domain:** SFTP
`upload` handler does `Buffer.from(msg.data, 'base64')` with no size check. An authenticated user can send multi-gigabyte payloads, exhausting server memory.
**Fix:** Check `msg.data.length` before `Buffer.from()`. Enforce max (e.g., 50MB base64 = ~37MB file). Set `maxPayload` on WebSocket server config.
### H-12. No write size limit on SFTP file editor
**Location:** `sftp.gateway.ts:122-128`
**Domain:** SFTP
`write` handler (save from Monaco editor) has no size check. `MAX_EDIT_SIZE` exists but is only applied to `read`.
**Fix:** Apply `MAX_EDIT_SIZE` check on the write path.
### H-13. Shell integration injected into remote sessions without consent
**Location:** `ssh-connection.service.ts:59-65`
**Domain:** SSH
`PROMPT_COMMAND` / `precmd_functions` modification injected into every SSH shell for CWD tracking. Users are not informed. If this injection were modified (supply chain, code change), it would execute on every connected host.
**Fix:** Make opt-in. Document the behavior. Scope injection to minimum needed.
### H-14. Password auth credentials logged with username and host
**Location:** `ssh-connection.service.ts:146`
**Domain:** SSH / Logging
Logs `username@host:port` for every password-authenticated connection. Creates a persistent record correlating users to targets.
**Fix:** Log at DEBUG only. Use `hostId` instead of hostname.
### H-15. guacd routing via `host.docker.internal` bypasses container isolation
**Location:** `docker-compose.yml:9`
**Domain:** Infrastructure
App-to-guacd traffic routes out of the container network, through the host, and back. Unnecessary external routing path.
**Fix:** After fixing C-1, both services on the same Docker network. Use service name `guacd` as hostname.
### H-16. Client-side-only admin guard
**Location:** `frontend/pages/admin/users.vue:4-6`
**Domain:** Frontend
`if (!auth.isAdmin) navigateTo('/')` is a UI redirect, not access control. Can be bypassed during hydration gaps.
**Fix:** Backend `AdminGuard` handles the real enforcement. Add proper route middleware (`definePageMeta({ middleware: 'admin' })`) for consistent frontend behavior.
---
## MEDIUM Findings (18)
| # | Finding | Location | Domain |
|---|---------|----------|--------|
| M-1 | Terminal gateway no session ownership check on `data`/`resize`/`disconnect` | `terminal.gateway.ts:76-79` | WebSocket |
| M-2 | TOTP replay possible (no used-code tracking) | `auth.service.ts` | Auth |
| M-3 | Email change has no verification step | `users.controller.ts` | Auth |
| M-4 | Email uniqueness not enforced at DB level | `users` table | Auth |
| M-5 | Password minimum length is 6 chars (NIST says 8+, OWASP says 12+) | Frontend + backend DTOs | Auth |
| M-6 | JWT_SECRET has no startup validation | `auth.module.ts` | Auth |
| M-7 | TOTP secret returned in setup response (exposure window) | `auth.controller.ts` | Auth |
| M-8 | Mass assignment via object spread in update endpoints | Multiple controllers | API |
| M-9 | CORS config may not behave as expected in production | `main.ts:24-27` | Infrastructure |
| M-10 | Weak `.env.example` defaults (`DB_PASSWORD=changeme`) | `.env.example` | Infrastructure |
| M-11 | Seed script runs on every container start | `Dockerfile:28` | Infrastructure |
| M-12 | File paths logged for every SFTP operation | `sftp.gateway.ts:27` | Logging |
| M-13 | SFTP `delete` falls through from `unlink` to `rmdir` silently | `sftp.gateway.ts:154-165` | SFTP |
| M-14 | Unbounded TCP buffer for guacd stream (no max size) | `rdp.gateway.ts:100-101` | RDP |
| M-15 | Connection log `updateMany` closes sibling sessions | `ssh-connection.service.ts:178-181` | SSH |
| M-16 | RDP `security`/`width`/`height`/`dpi` params not validated | `rdp.gateway.ts:85-89` | RDP |
| M-17 | Frontend file upload has no client-side size validation | `SftpSidebar.vue:64-73` | Frontend |
| M-18 | Error messages from server reflected to UI verbatim | `login.vue:64` | Frontend |
---
## LOW Findings (12)
| # | Finding | Location | Domain |
|---|---------|----------|--------|
| L-1 | No Content Security Policy header | `main.ts` | Frontend |
| L-2 | No WebSocket connection limit per user | `terminal.gateway.ts:8` | WebSocket |
| L-3 | Internal error messages forwarded to WS clients | `terminal.gateway.ts:34-35`, `rdp.gateway.ts:51` | WebSocket |
| L-4 | Server timezone leaked in Guacamole CONNECT | `guacamole.service.ts:81-85` | RDP |
| L-5 | SFTP event listeners re-registered on every message | `sftp.gateway.ts:53-58` | SFTP |
| L-6 | Default SSH username falls back to `root` | `ssh-connection.service.ts:92` | SSH |
| L-7 | Weak seed password for default admin | `seed.js` | Infrastructure |
| L-8 | SSH fingerprint derived from private key (should use public) | `ssh-keys.service.ts` | Vault |
| L-9 | `console.error` used instead of structured logger | Multiple files | Logging |
| L-10 | `confirm()` used for SFTP delete | `SftpSidebar.vue:210` | Frontend |
| L-11 | Settings mirrored to localStorage unnecessarily | `default.vue:25-27` | Frontend |
| L-12 | No DTO validation on admin password reset | `auth.controller.ts` | Auth |
---
## ISO 27001:2022 Gap Assessment
| Control | Status | Gap |
|---------|--------|-----|
| **A.5 — Security Policies** | MISSING | No security policies, incident response plan, or vulnerability disclosure process |
| **A.6 — Security Roles** | MISSING | No defined security responsibilities or RACI for incidents |
| **A.8.1 — Asset Management** | MISSING | No data classification scheme (SSH keys, TOTP secrets, credentials treated uniformly) |
| **A.8.5 — Access Control** | PARTIAL | Auth exists but: no brute-force protection, no account lockout, no session revocation, only 2 roles (admin/user) with no least-privilege granularity |
| **A.8.9 — Configuration Mgmt** | FAIL | guacd on host network, DB port exposed, no security headers |
| **A.8.15 — Logging** | FAIL | No structured audit log. Sensitive data IN logs. No failed login tracking |
| **A.8.16 — Monitoring** | MISSING | No anomaly detection, no alerting on repeated auth failures |
| **A.8.24 — Cryptography** | PARTIAL | Vault encryption is excellent (Argon2id). But: no TLS, tokens in URLs, TOTP unencrypted, keys in logs |
| **A.8.25 — Secure Development** | MISSING | No SAST, no dependency scanning, no security testing |
| **A.8.28 — Secure Coding** | MISSING | No documented coding standard, no input validation framework |
---
## Prioritized Remediation Roadmap
### Phase 1 — Stop the Bleeding (do this week)
| Priority | Finding | Effort | Impact |
|----------|---------|--------|--------|
| 1 | **C-1:** Fix guacd `network_mode: host` | 30 min | Closes unauthenticated backdoor to entire infrastructure |
| 2 | **C-8:** Remove PostgreSQL port exposure | 5 min | Closes direct DB access from network |
| 3 | **C-6:** Add session ownership to SFTP gateway | 1 hr | Blocks cross-user file access |
| 4 | **H-4:** Remove private key logging | 15 min | Stop bleeding secrets to logs |
| 5 | **H-5:** Stop logging terminal keystroke data | 15 min | Stop logging passwords |
| 6 | **H-11:** Add upload size limit | 15 min | Block memory exhaustion DoS |
### Phase 2 — Auth Hardening (next sprint)
| Priority | Finding | Effort | Impact |
|----------|---------|--------|--------|
| 7 | **C-2 + C-3:** Move JWT to httpOnly cookie + WS ticket auth | 4 hr | Eliminates primary token theft vectors |
| 8 | **C-4:** Add TLS termination (nginx + Let's Encrypt) | 2 hr | Encrypts all traffic |
| 9 | **H-1:** Short-lived access + refresh token | 3 hr | Limits exposure window of stolen tokens |
| 10 | **H-6:** Rate limiting on auth endpoints | 1 hr | Blocks brute-force |
| 11 | **H-3:** Encrypt TOTP secrets in DB | 1 hr | Protects 2FA if DB compromised |
| 12 | **M-5:** Increase password minimum to 12 chars | 15 min | NIST/OWASP compliance |
### Phase 3 — Channel Hardening (following sprint)
| Priority | Finding | Effort | Impact |
|----------|---------|--------|--------|
| 13 | **C-5:** SSH host key verification (block changed fingerprints) | 3 hr | Blocks MITM on SSH |
| 14 | **C-7:** Guacamole instruction validation + opcode whitelist | 2 hr | Blocks protocol injection |
| 15 | **H-2:** RDP cert validation (per-host configurable) | 2 hr | Blocks MITM on RDP |
| 16 | **M-1:** Terminal gateway session ownership check | 30 min | Blocks cross-user terminal access |
| 17 | **H-7:** Run container as non-root | 30 min | Limits blast radius of any RCE |
### Phase 4 — Hardening & Compliance (ongoing)
Everything in MEDIUM and LOW, plus ISO 27001 documentation gaps. Most are incremental improvements that can be addressed as part of normal development.
---
## What's Actually Good
Credit where due — these areas are solid:
- **Vault encryption (Argon2id v2)** — OWASP-recommended parameters, per-record salts, backwards-compatible versioning, migration endpoint. This is production-grade.
- **Credential isolation**`decryptForConnection()` is internal-only, never exposed over API. Correct pattern.
- **Per-user data isolation** — Users can only see their own credentials and SSH keys (ownership checks in vault services).
- **TOTP 2FA implementation** — Correct TOTP flow with QR code generation (aside from the plaintext storage issue).
- **Password hashing** — bcrypt is correct choice (cost factor should increase, but the algorithm is right).
- **Admin guards on backend**`AdminGuard` properly enforces server-side. Not just frontend checks.
---
*Report generated by 4 parallel audit agents covering Auth/JWT/Session, Vault/Encryption/DB, WebSocket/SSH/SFTP/RDP, and Frontend/Infrastructure/ISO 27001. Deduplicated from 70+ raw findings to 54 unique issues.*

View File

@ -0,0 +1,221 @@
# Wraith Desktop — Fired XO Audit
> **Date:** 2026-03-17
> **Auditor:** VHQ XO (Claude Opus 4.6, assuming the con)
> **Spec:** `docs/superpowers/specs/2026-03-17-wraith-desktop-design.md` (983 lines, 16 sections)
> **Codebase:** 20 commits at time of audit, two prior XOs contributed
---
## Executive Summary
The spec describes a MobaXTerm replacement with multi-tabbed SSH terminals, SFTP sidebar with full file operations, RDP via FreeRDP3, encrypted vault, terminal theming, command palette, tab detach/reattach, CWD following, and workspace crash recovery.
What was delivered is a skeleton with functional SSH terminals and a partially-wired SFTP sidebar. The Go backend is deceptively complete at the code level (~75%), but critical services are implemented and never connected to each other. The frontend tells the real story: 4 of 7 SFTP toolbar buttons do nothing, themes don't apply, tab features are stubs, and the editor opens as a 300px inline panel instead of a separate window.
**User-facing functionality: ~40% of spec. Code-level completeness: ~65%.**
---
## Bugs Fixed During This Audit
### BUG-001: UTF-8 Terminal Rendering (v0.7.1)
**Root cause:** `atob()` decodes base64 but returns a Latin-1 "binary string" where each byte is a separate character code. Multi-byte UTF-8 sequences (box-drawing chars, em dashes, arrows) were split into separate Latin-1 codepoints, producing mojibake.
**Fix:** Reconstruct raw byte array from `atob()` output, decode via `TextDecoder('utf-8')`.
**File:** `frontend/src/composables/useTerminal.ts` line 117
### BUG-002: Split UTF-8 Across Chunk Boundaries (v0.7.2)
**Root cause:** The v0.7.1 fix created a `new TextDecoder()` on every Wails event. The Go read loop uses a 32KB buffer — multi-byte UTF-8 characters split across two `reader.Read()` calls produced two separate events. Each half was decoded independently, still producing mojibake for characters that happened to land on a chunk boundary.
**Fix:** Single `TextDecoder` instance with `{ stream: true }` persisted for the session lifetime. Buffers incomplete multi-byte sequences between events.
**File:** `frontend/src/composables/useTerminal.ts`
### BUG-003: Typewriter Lag (v0.7.2)
**Root cause:** Every Go `reader.Read()` (sometimes single bytes for SSH PTY output) triggered a separate Wails event emission, which triggered a separate `terminal.write()`. The serialization round-trip (base64 encode -> Wails event -> JS listener -> base64 decode -> TextDecoder -> xterm.js write) for every few bytes produced visible line-by-line lag.
**Fix:** `requestAnimationFrame` write batching. Incoming data accumulates in a string buffer and flushes to xterm.js once per animation frame (~16ms). All chunks within a frame render in a single write.
**File:** `frontend/src/composables/useTerminal.ts`
### BUG-004: PTY Baud Rate (v0.7.2)
**Root cause:** `TTY_OP_ISPEED` and `TTY_OP_OSPEED` set to 14400 (1995 modem speed). Some remote PTYs throttle output to match the declared baud rate.
**Fix:** Bumped to 115200.
**File:** `internal/ssh/service.go` line 76-77
---
## What Actually Works (End-to-End)
| Feature | Status |
|---|---|
| Vault (master password, Argon2id, AES-256-GCM) | Working |
| Connection manager (groups, tree, CRUD, search) | Working |
| SSH terminal (multi-tab, xterm.js, resize) | Working (fixed in v0.7.1/v0.7.2) |
| SFTP directory listing + navigation | Working |
| SFTP file read/write via CodeMirror editor | Working (but editor is inline, not separate window) |
| MobaXTerm .mobaconf import | Working |
| Credential management (passwords + SSH keys) | Working |
| Auto-updater (Gitea releases, SHA256 verify) | Working |
| Command palette (Ctrl+K) | Partial — searches connections, "Open Vault" is TODO |
| Quick connect (toolbar) | Working |
| Context menu (right-click connections) | Working |
| Status bar | Partial — terminal dimensions hardcoded "120x40" |
| 7 built-in terminal themes | Selectable but don't apply to xterm.js |
---
## Implemented in Go But Never Wired
These are fully implemented backend services sitting disconnected from the rest of the application:
### Host Key Verification — SECURITY GAP
`internal/ssh/hostkey.go``HostKeyStore` with `Verify()`, `Store()`, `Delete()`, `GetFingerprint()`. All real logic.
**Problem:** SSH service hardcodes `ssh.InsecureIgnoreHostKey()` at `service.go:58`. The `HostKeyStore` is never instantiated or called. Every connection silently accepts any server key. MITM attacks would succeed without warning.
**Frontend:** `HostKeyDialog.vue` exists with accept/reject UI and MITM warning text. Also never wired.
### CWD Tracking (SFTP "Follow Terminal Folder")
`internal/ssh/cwd.go` — Full OSC 7 escape sequence parser. `ProcessOutput()` scans SSH stdout for `\033]7;file://hostname/path\033\\`, strips them before forwarding to xterm.js, and emits CWD change events. Shell hook generator for bash/zsh/fish.
**Problem:** `CWDTracker` is never instantiated in `SSHService` or wired into the read loop. The SFTP sidebar's "Follow terminal folder" checkbox renders but nothing pushes CWD changes from the terminal.
### Session Manager
`internal/session/manager.go``Create()`, `Detach()`, `Reattach()`, `SetState()`, max 32 sessions enforcement. All real logic.
**Problem:** Instantiated in `app.go` but `Create()` is never called when SSH/RDP sessions open. The SSH service has its own internal session map. These two tracking systems are parallel and unconnected. Tab detach/reattach (which depends on the Session Manager) cannot work.
### Workspace Restore (Crash Recovery)
`internal/app/workspace.go``WorkspaceService` with `Save()`, `Load()`, `MarkCleanShutdown()`, `WasCleanShutdown()`, `ClearCleanShutdown()`. All real logic via settings persistence.
**Problem:** `WorkspaceService` is never instantiated in `app.go`. Dead code. Crash recovery does not exist.
### Plugin Registry
`internal/plugin/registry.go``RegisterProtocol()`, `RegisterImporter()`, `GetProtocol()`, `GetImporter()`. All real logic.
**Problem:** Instantiated in `app.go`, nothing is ever registered. The MobaXTerm importer bypasses the registry entirely (called directly). Empty scaffolding.
---
## Missing Features (Spec vs. Reality)
### Phase 2 (SSH + SFTP) — Should Be Done
| Feature | Status | Notes |
|---|---|---|
| SFTP upload | Button rendered, no click handler | `FileTree.vue` toolbar |
| SFTP download | Button rendered, no click handler | `FileTree.vue` toolbar |
| SFTP delete | Button rendered, no click handler | `FileTree.vue` toolbar |
| SFTP new folder | Button rendered, no click handler | `FileTree.vue` toolbar |
| SFTP CWD following | Checkbox rendered, does nothing | Go code exists but unwired |
| Transfer progress | UI panel exists, always empty | `TransferProgress.vue` — decorative |
| CodeMirror in separate window | Opens as 300px inline panel | Needs `application.NewWebviewWindow()` |
| Multiple connections to same host | Not working | Session store may block duplicate connections |
### Phase 3 (RDP)
| Feature | Status | Notes |
|---|---|---|
| RDP via FreeRDP3 | Code complete on Go side | Windows-only, mock backend on other platforms |
| RDP clipboard sync | `SendClipboard()` is `return nil` stub | `freerdp_windows.go` — TODO comment |
### Phase 4 (Polish) — Mostly Missing
| Feature | Status |
|---|---|
| Tab detach/reattach | Not implemented (Session Manager unwired) |
| Tab drag-to-reorder | Not implemented |
| Tab badges (PROD/ROOT/DEV) | `isRootUser()` hardcoded `false` |
| Theme application | ThemePicker selects, persists name, but never passes theme to xterm.js |
| Per-connection theme | Not implemented |
| Custom theme creation | Not implemented |
| Vault management UI | No standalone UI; keys/passwords created inline in ConnectionEditDialog |
| Host key management UI | `HostKeyDialog.vue` exists, not wired |
| Keyboard shortcuts | Only Ctrl+K works. No Ctrl+T/W/Tab/1-9/B/Shift+D/F11/F |
| Connection history | `connection_history` table not in migrations |
| First-run .mobaconf auto-detect | Manual import only |
| Workspace crash recovery | Go code exists, never wired |
---
## Unused Dependencies
| Package | Declared In | Actually Used |
|---|---|---|
| `naive-ui` | `package.json` | Zero components imported anywhere — entire UI is hand-rolled Tailwind |
| `@xterm/addon-webgl` | `package.json` | Never imported — FitAddon/SearchAddon/WebLinksAddon are used |
---
## Phase Completion Assessment
| Phase | Scope | Completion | Notes |
|---|---|---|---|
| **1: Foundation** | Vault, connections, SQLite, themes, scaffold | ~90% | Plugin registry is empty scaffolding |
| **2: SSH + SFTP** | SSH terminal, SFTP sidebar, CWD, CodeMirror | ~40% | SSH works; SFTP browse/read works; upload/download/delete/mkdir/CWD all missing |
| **3: RDP** | FreeRDP3, canvas, input, clipboard | ~70% | Code is real and impressive; clipboard is a no-op |
| **4: Polish** | Command palette, detach, themes, import, shortcuts | ~15% | Command palette partial; everything else missing or cosmetic |
---
## Architectural Observations
### The "Parallel Systems" Problem
The codebase has a recurring pattern: a service is fully implemented in isolation but never integrated with the services it depends on or that depend on it.
- `session.Manager` and `ssh.SSHService` both track sessions independently
- `HostKeyStore` and `SSHService.Connect()` both exist but aren't connected
- `CWDTracker` and the SFTP sidebar both exist but aren't connected
- `WorkspaceService` exists but nothing calls it
- `plugin.Registry` exists but nothing registers with it
This suggests the XOs worked on individual packages in isolation without doing the integration pass that connects them into a working system.
### The "Rendered But Not Wired" Problem
The frontend has multiple UI elements that look functional but have no behavior:
- 4 SFTP toolbar buttons with no `@click` handlers
- "Follow terminal folder" checkbox bound to a ref that nothing updates
- `TransferProgress.vue` that never receives transfer data
- `HostKeyDialog.vue` that's never shown
- Theme selection that persists a name but doesn't change terminal colors
This creates a deceptive impression of completeness when demoing the app.
### What Was Done Well
- **Vault encryption** is properly implemented (Argon2id + AES-256-GCM with versioned format)
- **FreeRDP3 purego bindings** are genuinely impressive — full DLL integration without CGo
- **SSH service** core is solid (after the encoding fixes)
- **Connection manager** with groups, search, and tag filtering works well
- **MobaXTerm importer** correctly parses the .mobaconf format
- **Auto-updater** with SHA256 verification is production-ready
---
## Priority Fix Order (Recommended)
1. **SFTP toolbar buttons** — wire upload/download/delete/mkdir. The Go `SFTPService` already has all these methods. Just needs `@click` handlers in `FileTree.vue`.
2. **Host key verification** — wire `HostKeyStore` into `SSHService.Connect()`. Security gap.
3. **CWD following** — wire `CWDTracker` into the SSH read loop. This is MobaXTerm's killer differentiator.
4. **Theme application** — pass selected theme to `terminal.options.theme`. One line of code.
5. **Session Manager integration** — call `sessionMgr.Create()` on connect. Prerequisite for tab detach.
6. **Editor in separate window** — requires Go-side `application.NewWebviewWindow()` + dedicated route.
7. **Keyboard shortcuts** — straightforward `handleKeydown` additions in `MainLayout.vue`.
8. **Workspace restore** — wire `WorkspaceService` into app lifecycle.

304
docs/config-export.mobaconf Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 632 KiB

BIN
docs/moba1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

BIN
docs/moba2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,160 @@
# Mission Brief: Wraith AI Copilot Integration
Full boot sequence first — CLAUDE.md, AGENTS.md, Memory MCP. Read the spec at `docs/superpowers/specs/2026-03-17-wraith-desktop-design.md` and both phase plans before you start.
---
## The Mission
Design and build a first-class AI copilot integration into Wraith. Not a chatbot sidebar. Not a prompt window. A co-pilot seat where any XO (Claude instance) can:
1. **See what the Commander sees** — in any RDP session, receive the screen as a live visual feed (FreeRDP3 bitmap frames → vision input). No Playwright needed. The RDP session IS the browser.
2. **Type what the Commander types** — in any SSH/terminal session, read stdout in real-time and write to stdin. Full bidirectional terminal I/O. The XO can run commands, read output, navigate filesystems, edit files, run builds — everything a human can do in a terminal.
3. **Click what the Commander clicks** — in any RDP session, emulate mouse movements, clicks, scrolls, and keyboard input via FreeRDP3's input channel. The XO can navigate a Windows desktop, open applications, click buttons, fill forms, interact with any GUI application.
4. **Do development work** — an XO can open an SSH session to a dev machine, cd to a repo, run a build, open an RDP session to the same machine, navigate to `localhost:3000` in a browser, and visually verify the output — all without Playwright, all through Wraith's native protocol channels.
5. **Collaborate in real-time** — the Commander and the XO see the same sessions. The Commander can watch the XO work, take over at any time, or let the XO drive. Shared context, shared view, shared control.
---
## Design Requirements
### SSH/Terminal Integration
The XO needs these capabilities on any active SSH session:
- **Read terminal output** — subscribe to the `ssh:data:{sessionId}` event stream. Receive raw terminal output as it happens.
- **Write terminal input** — call `SSHService.Write(sessionId, data)` to type commands.
- **Read CWD** — use the OSC 7 CWD tracker (already built in Phase 2) to know the current directory.
- **Resize terminal** — call `SSHService.Resize(sessionId, cols, rows)` if needed.
- **SFTP operations** — use `SFTPService` methods to read/write files, upload/download, navigate the remote filesystem.
This means the XO can: ssh into a Linux box, `cd /var/log`, `tail -f syslog`, read the output, identify an issue, `vim /etc/nginx/nginx.conf`, make an edit via stdin keystrokes, save, `systemctl restart nginx`, verify the fix — all autonomously.
### RDP Vision Integration
The XO needs to see the remote desktop:
- **Frame capture** — FreeRDP3 already decodes RDP bitmap updates. Capture the current screen state as an image (JPEG/PNG) at a configurable interval or on-demand.
- **Frame → AI vision** — send the captured frame to the Claude API as an image input. The XO receives it as visual context — it can read text on screen, identify UI elements, understand application state.
- **Configurable capture rate** — the Commander controls how often frames are sent (e.g., on-demand, every 5 seconds, or continuous for active work). Token cost matters — don't stream 30fps to the API.
- **Region-of-interest** — optionally crop to a specific region of the screen for focused analysis (e.g., "watch this log window").
### RDP Input Emulation
The XO needs to interact with the remote desktop:
- **Mouse** — move to coordinates, left/right click, double-click, scroll, drag. FreeRDP3 has input channels for all of these.
- **Keyboard** — send keystrokes, key combinations (Ctrl+C, Alt+Tab, Win+R), and text strings. Support both individual key events and bulk text entry.
- **Coordinate mapping** — the XO specifies actions in terms of what it sees in the frame ("click the OK button at approximately x=450, y=320"). The integration layer maps pixel coordinates to RDP input coordinates.
This means the XO can: connect to a Windows server via RDP, see the desktop, open a browser (Win+R → "chrome" → Enter), navigate to a URL (click address bar → type URL → Enter), read the page content via vision, interact with web applications — all without Playwright or any browser automation tool.
### The AI Service Layer
Build a Go service (`internal/ai/`) that:
```
AIService
├── Connect to Claude API (Anthropic SDK or raw HTTP)
├── Manage conversation context (system prompt + message history)
├── Tool definitions for SSH, SFTP, RDP input, RDP vision
├── Process tool calls → dispatch to Wraith services
├── Stream responses to the frontend (chat panel)
└── Handle multi-session awareness (which sessions exist, which is active)
```
**Tool definitions the AI should have access to:**
```
Terminal Tools:
- terminal_write(sessionId, text) — type into a terminal
- terminal_read(sessionId) — get recent terminal output
- terminal_cwd(sessionId) — get current working directory
File Tools:
- sftp_list(sessionId, path) — list directory
- sftp_read(sessionId, path) — read file content
- sftp_write(sessionId, path, content) — write file
- sftp_upload(sessionId, local, remote)
- sftp_download(sessionId, remote)
RDP Tools:
- rdp_screenshot(sessionId) — capture current screen
- rdp_click(sessionId, x, y, button) — mouse click
- rdp_doubleclick(sessionId, x, y)
- rdp_type(sessionId, text) — type text string
- rdp_keypress(sessionId, key) — single key or combo (ctrl+c, alt+tab)
- rdp_scroll(sessionId, x, y, delta) — scroll wheel
- rdp_move(sessionId, x, y) — move mouse
Session Tools:
- list_sessions() — what's currently open
- connect_ssh(connectionId) — open a new SSH session
- connect_rdp(connectionId) — open a new RDP session
- disconnect(sessionId) — close a session
```
### Frontend: The Copilot Panel
A collapsible panel (right side or bottom) that shows the AI interaction:
- **Chat messages** — the conversation between Commander and XO
- **Tool call visualization** — when the XO executes a tool, show what it did (e.g., "Typed `ls -la` in Terminal 1", "Clicked at (450, 320) in RDP 2", "Read /etc/nginx/nginx.conf")
- **Screen capture preview** — when the XO takes an RDP screenshot, show a thumbnail in the chat
- **Session awareness indicator** — show which session the XO is currently focused on
- **Take control / Release control** — the Commander can let the XO drive a session or take it back
- **Quick commands** — "Watch this session", "Fix this error", "Deploy this", "What's on screen?"
### Interaction Model
The Commander and XO interact through natural language in the chat panel. The XO has access to all tools and uses them autonomously based on the conversation:
```
Commander: "SSH into asgard and check if the nginx service is running"
XO: [calls connect_ssh(asgardConnectionId)]
[calls terminal_write(sessionId, "systemctl status nginx")]
[calls terminal_read(sessionId)]
"Nginx is active (running) since March 15. PID 1234, 3 worker processes.
Memory usage is 45MB. No errors in the last 50 journal lines."
Commander: "Open RDP to dc01 and check the Event Viewer for any critical errors"
XO: [calls connect_rdp(dc01ConnectionId)]
[calls rdp_screenshot(sessionId)]
"I can see the Windows Server desktop. Opening Event Viewer..."
[calls rdp_keypress(sessionId, "win+r")]
[calls rdp_type(sessionId, "eventvwr.msc")]
[calls rdp_keypress(sessionId, "enter")]
[waits 2 seconds]
[calls rdp_screenshot(sessionId)]
"Event Viewer is open. I can see 3 critical errors in the System log from today.
Let me click into the first one..."
[calls rdp_click(sessionId, 320, 280, "left")]
[calls rdp_screenshot(sessionId)]
"Critical error: The Kerberos client received a KRB_AP_ERR_MODIFIED error
from the server dc02$. This usually indicates a DNS or SPN misconfiguration..."
```
---
## Architecture Constraints
- **Claude API key** stored in the encrypted vault (same Argon2id + AES-256-GCM as credentials)
- **Token budget awareness** — track token usage per conversation, warn at thresholds
- **Conversation persistence** — save conversations to SQLite, resume across sessions
- **No external dependencies** — the AI service is a Go package using the Claude API directly (HTTP + SSE streaming), not a Python sidecar
- **Model selection** — configurable in settings (claude-sonnet-4-5-20250514, claude-opus-4-5-20250414, etc.)
- **Streaming responses** — SSE from Claude API → Wails events → Vue frontend, token by token
---
## What to Build
Design this system fully (spec it out), then implement it. Phase it if needed — terminal integration first (lower complexity, immediate value), then RDP vision, then RDP input. But design the whole thing upfront so the architecture supports all three from day one.
The end state: a single Wraith window where a human and an AI work side by side on remote systems, sharing vision, sharing control, sharing context. The AI sees what you see. The AI types what you'd type. And you can take the wheel whenever you want.
Build it.

View File

@ -0,0 +1,129 @@
# Spike: Multi-Window Support in Wails v3
**Status:** Research-based (not yet validated on Windows)
**Date:** 2026-03-17
**Target platform:** Windows (developing on macOS)
**Wails version:** v3.0.0-alpha.74
---
## Context
Wraith needs to support detached sessions — users should be able to pop out
an SSH or RDP session into its own window while the main connection manager
remains open. This spike evaluates three approaches, ranked by preference.
---
## Plan A: Wails v3 Native Multi-Window
**Status: LIKELY WORKS** based on API documentation.
### How it works
- `app.Window.NewWithOptions()` creates a new OS-level window at runtime.
- Each window can load a different URL or frontend route (e.g.,
`/session/rdp/3` in one window, `/` in the main window).
- All windows share the same Go backend services — no IPC or inter-process
marshalling required. Bindings registered on the application are callable
from any window.
- Window lifecycle events (`OnClose`, `OnFocus`, etc.) are available for
cleanup.
### Example (pseudocode)
```go
win, err := app.Window.NewWithOptions(application.WindowOptions{
Title: "RDP — server-01",
Width: 1280,
Height: 720,
URL: "/session/rdp/3",
})
```
### Risks
| Risk | Severity | Mitigation |
|------|----------|------------|
| Alpha API — method signatures may change before v3 stable | Medium | Pin to a known-good alpha tag; wrap calls behind an internal interface so migration is a single-file change. |
| Platform-specific quirks on Windows (DPI, focus, taskbar grouping) | Low | Test on Windows during Phase 2. Wails uses webview2 on Windows which is mature. |
| Window count limits or resource leaks | Low | Cap concurrent detached windows (e.g., 8). Ensure `OnClose` releases resources. |
---
## Plan B: Floating Panels (CSS-based)
**Status: FALLBACK** — no external dependency, purely frontend.
### How it works
- Detached sessions render as draggable, resizable `position: fixed` panels
within the main Wails window.
- Each panel contains its own Vue component instance (terminal emulator or
RDP canvas).
- Panels can be minimised, maximised within the viewport, or snapped to
edges.
### Pros
- Zero dependency on Wails multi-window API.
- Works on any platform without additional testing.
- Simpler state management — everything lives in one window context.
### Cons
- Sessions share the same viewport — limited screen real estate.
- Cannot span multiple monitors.
- Feels less native than real OS windows.
### Implementation cost
Small. Requires a `<FloatingPanel>` wrapper component with drag/resize
handlers. Libraries like `vue3-draggable-resizable` exist but a lightweight
custom implementation (~150 LOC) is preferable to avoid dependency churn.
---
## Plan C: Browser Mode
**Status: EMERGENCY** — last resort if both Plan A and Plan B are inadequate.
### How it works
- Wails v3 supports a server mode where the frontend is served over HTTP on
`localhost`.
- Detached sessions open in the user's default browser via
`open(url, '_blank')` or `runtime.BrowserOpenURL()`.
- The browser tab communicates with Go services through the same HTTP
endpoint.
### Pros
- Guaranteed to work — it is just a web page.
- Users can arrange tabs freely across monitors.
### Cons
- Breaks the desktop-app experience.
- Browser tabs lack access to Wails runtime bindings; all communication must
go through HTTP/WebSocket, requiring a parallel transport layer.
- Security surface increases — localhost HTTP server is accessible to other
local processes.
---
## Recommendation
**Start with Plan A.** The Wails v3 `NewWithOptions` API is documented and
consistent with how other multi-window desktop frameworks (Electron,
Tauri v2) work. The alpha stability risk is mitigated by wrapping calls
behind an internal interface.
If Plan A fails during Windows validation, **Plan B requires only frontend
CSS changes** — no backend work is wasted. Plan C is reserved for scenarios
where neither A nor B is viable.
## Next Step
Validate Plan A on Windows during Phase 2 when SSH sessions exist and there
is a real payload to render in a second window.

View File

@ -0,0 +1,171 @@
# Spike: RDP Frame Transport Mechanisms
**Status:** Research-based (not yet benchmarked)
**Date:** 2026-03-17
**Target platform:** Windows (developing on macOS)
**Wails version:** v3.0.0-alpha.74
---
## Context
When Wraith connects to a remote desktop via FreeRDP, the Go backend
receives raw bitmap frames that must be delivered to the frontend for
rendering on an HTML `<canvas>`. This spike evaluates three transport
approaches, estimating throughput for a 1920x1080 session at 30 fps.
---
## Approach 1: Local HTTP Endpoint
### How it works
1. Go spins up a local HTTP server on a random high port
(`net.Listen("tcp", "127.0.0.1:0")`).
2. Each frame is JPEG-encoded and served at a predictable URL
(e.g., `http://127.0.0.1:{port}/frame?session=3`).
3. The frontend fetches frames via `fetch()`, `<img>` tag, or
`ReadableStream` for chunked delivery.
### Throughput estimate
| Metric | Value |
|--------|-------|
| 1080p RGBA raw | ~8 MB/frame |
| 1080p JPEG (quality 80) | ~100-200 KB/frame |
| At 30 fps (JPEG) | ~3-6 MB/s |
| Loopback bandwidth | >1 GB/s |
Loopback HTTP can handle this with headroom to spare.
### Pros
- No base64 overhead — binary JPEG bytes transfer directly.
- Standard HTTP semantics; easy to debug with browser DevTools.
- Can use `Transfer-Encoding: chunked` or Server-Sent Events for
push-based delivery.
- Can serve multiple sessions on the same server with different paths.
### Cons
- Requires an extra listening port on localhost.
- Potential firewall or endpoint-security issues on locked-down Windows
enterprise machines.
- Slightly more complex setup (port allocation, CORS headers for Wails
webview origin).
---
## Approach 2: Wails Bindings (Base64)
### How it works
1. Go encodes each frame as a JPEG, then base64-encodes the result.
2. A Wails-bound method (`SessionService.GetFrame(sessionID)`) returns the
base64 string.
3. The frontend decodes the string, creates an `ImageBitmap` or sets it as a
data URI, and draws it on a `<canvas>`.
### Throughput estimate
| Metric | Value |
|--------|-------|
| 1080p JPEG (quality 80) | ~100-200 KB/frame |
| Base64 of JPEG (+33%) | ~133-270 KB/frame |
| At 30 fps | ~4-8 MB/s of string data |
| Wails IPC overhead | Negligible for this payload size |
This is feasible. Modern JavaScript engines handle base64 decoding at
several hundred MB/s.
### Pros
- No extra ports — everything flows through the existing Wails IPC channel.
- Works out of the box with Wails bindings; no additional infrastructure.
- No firewall concerns.
### Cons
- 33% base64 size overhead on every frame.
- CPU cost of `base64.StdEncoding.EncodeToString()` in Go and `atob()` in
JS on every frame (though both are fast).
- Polling-based unless combined with Wails events to signal frame
availability.
- May bottleneck at very high resolutions (4K) or high FPS (60+).
---
## Approach 3: Wails Events (Streaming)
### How it works
1. Go emits each frame as a Wails event:
`app.EmitEvent("frame:3", base64JpegString)`.
2. The frontend subscribes: `wails.Events.On("frame:3", handler)`.
3. The handler decodes and renders on canvas.
### Throughput estimate
Same as Approach 2 — the payload is identical (base64 JPEG). The difference
is delivery mechanism (push vs. pull).
### Pros
- Push-based — the frontend receives frames as soon as they are available
with no polling delay.
- Natural Wails pattern; aligns with how other real-time data (connection
status, notifications) already flows.
### Cons
- Same 33% base64 overhead as Approach 2.
- Wails event bus may not be optimised for high-frequency, large-payload
events. This is unvalidated.
- Harder to apply backpressure — if the frontend cannot keep up, events
queue without flow control.
---
## Throughput Summary
| Approach | Payload/frame | 30 fps throughput | Extra infra |
|----------|--------------|-------------------|-------------|
| 1 — Local HTTP | ~150 KB (binary JPEG) | ~4.5 MB/s | Localhost port |
| 2 — Wails bindings | ~200 KB (base64 JPEG) | ~6 MB/s | None |
| 3 — Wails events | ~200 KB (base64 JPEG) | ~6 MB/s | None |
All three approaches are within comfortable limits for 1080p at 30 fps.
The differentiator is operational simplicity, not raw throughput.
---
## Recommendation
**Start with Approach 2 (base64 JPEG via Wails bindings).**
Rationale:
1. JPEG compression brings 1080p frames down to ~200 KB, making the 33%
base64 overhead manageable (~6 MB/s at 30 fps).
2. No extra ports or firewall concerns — important for enterprise Windows
environments where Wraith will be deployed.
3. Simple implementation: one Go method, one frontend call per frame.
4. If polling latency is a problem, upgrade to Approach 3 (events) with
minimal code change — the payload encoding is identical.
**If benchmarking reveals issues** (dropped frames, high CPU from
encoding), fall back to Approach 1 (local HTTP) which eliminates base64
overhead entirely. The migration path is straightforward: replace the
`fetch(dataUri)` call with `fetch(httpUrl)`.
---
## Next Step
Benchmark during Phase 3 when FreeRDP integration is in progress and real
frame data is available. Key metrics to capture:
- End-to-end frame latency (Go encode to canvas paint)
- CPU utilisation on both Go and browser sides
- Frame drop rate at 30 fps and 60 fps
- Memory pressure from base64 string allocation/GC

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,784 @@
# Wraith Desktop — Tauri v2 Rewrite 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:** Rewrite Wraith Desktop from Go/Wails v3 to Rust/Tauri v2, keeping the Vue 3 frontend, to eliminate WebView terminal rendering issues and deliver a production-quality MobaXTerm replacement.
**Architecture:** Rust backend (Tauri v2) handles SSH, SFTP, RDP, vault encryption, and host key verification. Vue 3 frontend (Vite + Pinia + Tailwind CSS) runs in WebView2 for all UI chrome. xterm.js renders terminals with proper font loading. SSH data flows via Tauri events (same pattern as current Wails events but with Tauri's more mature IPC). CWD following uses a separate SSH exec channel that polls `pwd` — never touches the terminal data stream. RDP frames served via Tauri asset protocol (`asset://localhost/frame/{id}`) to avoid base64 IPC overhead.
**Tech Stack:**
- **Runtime:** Tauri v2 (stable, not alpha like Wails v3)
- **Backend:** Rust with `russh` (SSH/SFTP), `rusqlite` (SQLite), `aes-gcm` + `argon2` (vault), `ironrdp` or FreeRDP FFI (RDP), `dashmap` (concurrent session registry)
- **Frontend:** Vue 3 (Composition API), TypeScript, Vite, Pinia, Tailwind CSS, xterm.js 6
- **Distribution:** Tauri bundler (NSIS installer for Windows, .dmg for macOS), built-in auto-updater
**Key Rust Design Decisions (from Gemini review):**
- **`DashMap` for session registry** — lock-free concurrent hashmap instead of `RwLock<HashMap>`. No deadlock risk during multi-window tab detach/reattach transitions.
- **`Drop` trait for session cleanup** — when a session struct is dropped (window closes, disconnect, panic), SSH/SFTP/RDP connections close automatically via Rust's ownership model. No zombie sessions. No "forgot to call disconnect." This is something Go fundamentally cannot do as cleanly.
- **Tauri asset protocol for RDP frames** — instead of base64-encoding every frame over IPC (current Go approach), register a custom asset protocol handler that serves raw RGBA frames at `asset://localhost/frame/{session_id}`. The `<canvas>` fetches frames as images. Eliminates serialization overhead for 1080p bitmap data.
- **No OSC 7 terminal stream parsing** — CWD following uses a separate SSH exec channel that polls `pwd`. The terminal data stream is NEVER processed, scanned, or modified. This avoids the ANSI escape sequence corruption that broke the Go version.
---
## Why Tauri v2 Over Wails v3
| Factor | Wails v3 (current) | Tauri v2 (target) |
|---|---|---|
| Stability | Alpha — breaking API changes between releases | Stable 2.x — production-ready |
| Multi-window | Experimental, barely documented | First-class API, well-documented |
| Auto-updater | None — we built our own (broken) | Built-in with signing, delta updates |
| IPC | Events + bindings, JSON serialization | Commands + events, serde serialization |
| Community | Small, Go-centric | Large, active, extensive plugin ecosystem |
| Binary size | ~15MB (Go runtime) | ~3-5MB (Rust, no runtime) |
| Startup time | ~500ms (Go GC warmup) | ~100ms |
---
## Scoped Feature Set (What Gets Built)
### In Scope (Launch-Blocking)
| Feature | Notes |
|---|---|
| SSH terminal (multi-tab) | xterm.js + russh, proper font loading, rAF batching |
| SFTP sidebar | File tree, upload, download, delete, mkdir, rename |
| SFTP CWD following | Separate SSH exec channel polls `pwd`, no terminal stream touching |
| RDP in tabs | ironrdp or FreeRDP FFI, canvas rendering |
| Encrypted vault | Argon2id + AES-256-GCM, master password, same v1: format |
| Connection manager | Groups, tree, CRUD, search, tags |
| Credential management | Passwords + SSH keys, encrypted storage |
| Host key verification | TOFU model — accept new, block changed |
| Terminal theming | 7+ built-in themes, per-connection override |
| Tab detach/reattach | Tauri multi-window — session lives in Rust, view is the window |
| Syntax highlighting editor | CodeMirror 6 in separate Tauri window |
| Command palette (Ctrl+K) | Fuzzy search connections + actions |
| Quick connect | user@host:port in toolbar |
| Tab badges | Protocol icon, ROOT warning, environment tags |
| Keyboard shortcuts | Full set per original spec |
| Auto-updater | Tauri built-in updater with code signing |
| Status bar | Live terminal dims, connection info, theme name |
### Not In Scope
| Feature | Reason |
|---|---|
| MobaXTerm import | 6 connections — set up by hand |
| Claude Code plugin | Separate project, post-launch |
| Plugin system | Not needed without community plugins |
| Split panes | Post-launch |
| Session recording | Post-launch |
| Jump host / bastion | Post-launch |
---
## Repository Strategy
**New repo:** `wraith-v2` (or rename current to `wraith-legacy`, new one becomes `wraith`)
The current Go codebase stays intact as reference. No interleaving old and new code. Clean start, clean history.
**Go code cleanup** happens in Phase 7 after the Tauri version ships — archive the Go repo, update CI to build from the new repo, remove old Gitea packages.
---
## File Structure
```
wraith-v2/
src-tauri/ # Rust backend (Tauri v2)
src/
main.rs # Tauri app setup, command registration
lib.rs # Module declarations
ssh/
mod.rs # SSH service — connect, disconnect, I/O
session.rs # SSHSession struct, PTY management
host_key.rs # Host key store — TOFU verification
cwd.rs # CWD tracker via separate exec channel
sftp/
mod.rs # SFTP operations — list, read, write, delete, mkdir
rdp/
mod.rs # RDP service — connect, frame delivery, input
input.rs # Scancode mapping (JS key → RDP scancode)
vault/
mod.rs # Encrypt/decrypt, master password, Argon2id key derivation
db/
mod.rs # SQLite connection, migrations
migrations/ # SQL migration files
connections/
mod.rs # Connection CRUD, group management, search
credentials/
mod.rs # Credential CRUD, encrypted storage
settings/
mod.rs # Key-value settings CRUD
theme/
mod.rs # Theme CRUD, built-in theme definitions
session/
mod.rs # Session manager — tracks active sessions, tab state
workspace/
mod.rs # Workspace snapshot save/load, crash recovery
commands/
mod.rs # All #[tauri::command] functions — thin wrappers over services
ssh_commands.rs # SSH-related commands
sftp_commands.rs # SFTP-related commands
rdp_commands.rs # RDP-related commands
vault_commands.rs # Vault/credential commands
connection_commands.rs # Connection CRUD commands
settings_commands.rs # Settings commands
session_commands.rs # Session manager commands
Cargo.toml
tauri.conf.json # Tauri config — windows, permissions, updater
capabilities/ # Tauri v2 permission capabilities
icons/ # App icons
src/ # Vue 3 frontend (migrated from current)
App.vue
main.ts
layouts/
MainLayout.vue
UnlockLayout.vue
components/ # Largely portable from current codebase
sidebar/
ConnectionTree.vue
SidebarToggle.vue
session/
SessionContainer.vue
TabBar.vue
TabBadge.vue
terminal/
TerminalView.vue
rdp/
RdpView.vue
RdpToolbar.vue
sftp/
FileTree.vue
TransferProgress.vue
editor/
EditorWindow.vue
vault/
VaultManager.vue
CredentialForm.vue
common/
CommandPalette.vue
QuickConnect.vue
StatusBar.vue
HostKeyDialog.vue
ThemePicker.vue
ImportDialog.vue # Kept for future use, not in MVP
SettingsModal.vue
ContextMenu.vue
composables/
useTerminal.ts # Rewritten — Tauri events instead of Wails
useSftp.ts # Rewritten — Tauri invoke instead of Wails Call
useRdp.ts # Rewritten — Tauri invoke/events
useTransfers.ts # Portable as-is
stores/
app.store.ts # Rewritten — Tauri invoke
connection.store.ts # Rewritten — Tauri invoke
session.store.ts # Rewritten — Tauri invoke
assets/
main.css # Tailwind + custom CSS
css/
terminal.css # xterm.js styling
router/
index.ts # If needed, otherwise App.vue handles layout switching
types/
index.ts # TypeScript interfaces matching Rust structs
package.json
vite.config.ts
tsconfig.json
tailwind.config.ts
```
---
## Phase 1: Foundation (Tauri Scaffold + SQLite + Vault)
### Task 1.1: Tauri v2 Project Scaffold
**Files:**
- Create: `wraith-v2/` entire project via `npm create tauri-app`
- Create: `src-tauri/tauri.conf.json`
- Create: `src-tauri/Cargo.toml`
- [ ] **Step 1:** Create new project with Tauri v2 CLI
```bash
npm create tauri-app@latest wraith-v2 -- --template vue-ts
cd wraith-v2
```
- [ ] **Step 2:** Configure `tauri.conf.json` — app name "Wraith", window title, default size 1200x800, dark theme, custom icon
- [ ] **Step 3:** Add Rust dependencies to `Cargo.toml`:
```toml
[dependencies]
tauri = { version = "2", features = ["protocol-asset"] }
tauri-plugin-shell = "2"
tauri-plugin-updater = "2"
russh = "0.46"
russh-keys = "0.46"
russh-sftp = "2"
rusqlite = { version = "0.32", features = ["bundled"] }
aes-gcm = "0.10"
argon2 = "0.5"
rand = "0.8"
hex = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
base64 = "0.22"
dashmap = "6"
tokio = { version = "1", features = ["full"] }
log = "0.4"
env_logger = "0.11"
```
- [ ] **Step 4:** Verify `cargo tauri dev` launches an empty window
- [ ] **Step 5:** Port the Vue 3 frontend shell — copy `App.vue`, `main.ts`, Tailwind config, CSS variables from current wraith repo. Install frontend deps:
```bash
npm install pinia vue-router @xterm/xterm @xterm/addon-fit @xterm/addon-search @xterm/addon-web-links
npm install -D tailwindcss @tailwindcss/vite
```
- [ ] **Step 6:** Verify `cargo tauri dev` shows the Vue 3 shell with Tailwind styling
- [ ] **Step 7:** Commit: `feat: Tauri v2 scaffold with Vue 3 + Tailwind`
---
### Task 1.2: SQLite Database Layer
**Files:**
- Create: `src-tauri/src/db/mod.rs`
- Create: `src-tauri/src/db/migrations/001_initial.sql`
- [ ] **Step 1:** Write `001_initial.sql` — same schema as current Go version:
- `groups` (hierarchical folders)
- `connections` (hostname, port, protocol, credential_id, tags JSON, options JSON)
- `credentials` (name, username, type, encrypted_value)
- `ssh_keys` (name, encrypted_private_key, fingerprint, public_key)
- `host_keys` (hostname, port, key_type, fingerprint, raw_key — PK on hostname+port+key_type)
- `settings` (key-value)
- `themes` (all 20+ color fields)
- `connection_history` (connection_id, connected_at, disconnected_at, duration_secs)
- [ ] **Step 2:** Write `db/mod.rs`:
- `pub fn open(path: &Path) -> Result<Connection>` — opens SQLite with WAL mode, busy timeout 5000ms, foreign keys ON
- `pub fn migrate(conn: &Connection) -> Result<()>` — runs embedded SQL migrations
- Use `rusqlite::Connection` wrapped in `Arc<Mutex<Connection>>` for thread-safe access
- [ ] **Step 3:** Write tests — open temp DB, run migrations, verify tables exist
- [ ] **Step 4:** Commit: `feat: SQLite database layer with migrations`
---
### Task 1.3: Vault Service (Encryption)
**Files:**
- Create: `src-tauri/src/vault/mod.rs`
- [ ] **Step 1:** Implement vault service matching current Go encryption exactly:
- `derive_key(password: &str, salt: &[u8]) -> [u8; 32]` — Argon2id with t=3, m=65536, p=4
- `generate_salt() -> [u8; 32]` — 32 random bytes via `rand`
- `encrypt(key: &[u8; 32], plaintext: &str) -> String` — AES-256-GCM, random 12-byte IV, returns `v1:{iv_hex}:{sealed_hex}`
- `decrypt(key: &[u8; 32], blob: &str) -> Result<String>` — parses v1 format, decrypts
- `VaultService` struct holds the derived key in memory
- [ ] **Step 2:** Write tests — encrypt/decrypt round-trip, wrong key fails, format compatibility with Go-generated blobs
- [ ] **Step 3:** Commit: `feat: vault encryption — Argon2id + AES-256-GCM`
---
### Task 1.4: Settings + Connections + Credentials Services
**Files:**
- Create: `src-tauri/src/settings/mod.rs`
- Create: `src-tauri/src/connections/mod.rs`
- Create: `src-tauri/src/credentials/mod.rs`
- [ ] **Step 1:** Settings service — `get(key)`, `set(key, value)`, `delete(key)`. UPSERT pattern.
- [ ] **Step 2:** Connections service — `create`, `get`, `list`, `update`, `delete`, `create_group`, `list_groups`, `delete_group`, `rename_group`, `search(query)`. Tags via `json_each()`.
- [ ] **Step 3:** Credentials service — `create_password`, `create_ssh_key`, `list`, `decrypt_password`, `decrypt_ssh_key`, `delete`. Requires VaultService reference.
- [ ] **Step 4:** Write tests for each service — CRUD operations, search, encrypted credential round-trip
- [ ] **Step 5:** Commit: `feat: settings, connections, credentials services`
---
### Task 1.5: Tauri Commands + Frontend Wiring
**Files:**
- Create: `src-tauri/src/commands/mod.rs`
- Create: `src-tauri/src/commands/vault_commands.rs`
- Create: `src-tauri/src/commands/connection_commands.rs`
- Create: `src-tauri/src/commands/settings_commands.rs`
- Create: `src-tauri/src/main.rs` (full setup)
- Migrate: `src/layouts/UnlockLayout.vue` — Wails Call → Tauri invoke
- Migrate: `src/stores/app.store.ts` — Wails Call → Tauri invoke
- Migrate: `src/stores/connection.store.ts` — Wails Call → Tauri invoke
- [ ] **Step 1:** Write Tauri command wrappers:
```rust
#[tauri::command]
async fn is_first_run(state: State<'_, AppState>) -> Result<bool, String> {
// ...
}
#[tauri::command]
async fn create_vault(password: String, state: State<'_, AppState>) -> Result<(), String> {
// ...
}
#[tauri::command]
async fn unlock(password: String, state: State<'_, AppState>) -> Result<(), String> {
// ...
}
```
- [ ] **Step 2:** Register all commands in `main.rs`:
```rust
tauri::Builder::default()
.manage(AppState::new()?)
.invoke_handler(tauri::generate_handler![
is_first_run, create_vault, unlock, is_unlocked,
list_connections, create_connection, update_connection, delete_connection,
list_groups, create_group, delete_group, rename_group,
search_connections,
list_credentials, create_password, create_ssh_key, delete_credential,
get_setting, set_setting,
])
.run(tauri::generate_context!())
```
- [ ] **Step 3:** Migrate `UnlockLayout.vue` — replace `Call.ByName("...WraithApp.Unlock", password)` with `invoke("unlock", { password })`
- [ ] **Step 4:** Migrate `app.store.ts`, `connection.store.ts` — same pattern
- [ ] **Step 5:** Verify: app launches, vault creation works, connections CRUD works
- [ ] **Step 6:** Commit: `feat: Tauri commands + frontend vault/connection wiring`
---
### Task 1.6: Port Remaining UI Components
**Files:**
- Migrate: All Vue components from current wraith repo
- Focus: Components with zero Wails dependencies (direct copy)
- Then: Components with Wails dependencies (Call → invoke, Events → listen)
- [ ] **Step 1:** Direct-copy components (no Wails deps): `SidebarToggle.vue`, `SessionContainer.vue`, `TabBar.vue`, `CommandPalette.vue`, `ContextMenu.vue`, `TransferProgress.vue`, `HostKeyDialog.vue`
- [ ] **Step 2:** Migrate `ConnectionTree.vue``Call``invoke`
- [ ] **Step 3:** Migrate `ConnectionEditDialog.vue``Call``invoke`
- [ ] **Step 4:** Migrate `SettingsModal.vue``Call``invoke`, `Browser.OpenURL``open` from `@tauri-apps/plugin-shell`
- [ ] **Step 5:** Migrate `ThemePicker.vue``Call``invoke`
- [ ] **Step 6:** Migrate `MainLayout.vue``Call``invoke`, `Application.Quit()``getCurrentWindow().close()`
- [ ] **Step 7:** Migrate `StatusBar.vue``Call``invoke`
- [ ] **Step 8:** Verify: full UI renders, sidebar works, connection CRUD works, settings persist
- [ ] **Step 9:** Commit: `feat: port all UI components to Tauri v2`
---
## Phase 2: SSH Terminal
### Task 2.1: Rust SSH Service
**Files:**
- Create: `src-tauri/src/ssh/mod.rs`
- Create: `src-tauri/src/ssh/session.rs`
- Create: `src-tauri/src/ssh/host_key.rs`
- [ ] **Step 1:** Implement `SshService`:
- `connect(hostname, port, username, auth_methods, cols, rows) -> session_id`
- `write(session_id, data: &[u8])` — write to PTY stdin
- `resize(session_id, cols, rows)` — window change request
- `disconnect(session_id)`
- Uses `russh` async client with `tokio` runtime
- PTY request with `xterm-256color`, 115200 baud
- Stdout read loop emits `Tauri::Event("ssh:data:{session_id}", base64_data)`
- [ ] **Step 2:** Implement host key verification (TOFU):
- `HostKeyStore` backed by SQLite `host_keys` table
- New keys → store and accept
- Matching keys → accept silently
- Changed keys → reject with MITM warning
- Emit event to frontend for UI confirmation on new keys (optional enhancement)
- [ ] **Step 3:** Implement SFTP client creation on same SSH connection:
- After SSH connect, open SFTP subsystem channel
- Store SFTP client alongside SSH session
- [ ] **Step 4:** Write Tauri commands: `connect_ssh`, `connect_ssh_with_password`, `disconnect_session`, `ssh_write`, `ssh_resize`
- [ ] **Step 5:** Write tests — connect to localhost (if available), mock SSH server for unit tests
- [ ] **Step 6:** Commit: `feat: Rust SSH service with TOFU host key verification`
---
### Task 2.2: Terminal Frontend (xterm.js + Tauri Events)
**Files:**
- Rewrite: `src/composables/useTerminal.ts`
- Migrate: `src/components/terminal/TerminalView.vue`
- Rewrite: `src/stores/session.store.ts`
- [ ] **Step 1:** Rewrite `useTerminal.ts`:
- Replace `Events.On("ssh:data:...")` with `listen("ssh:data:{sessionId}", ...)`
- Replace `Call.ByName("...SSHService.Write", ...)` with `invoke("ssh_write", { sessionId, data })`
- Keep: streaming TextDecoder with `{ stream: true }`
- Keep: rAF write batching
- Keep: select-to-copy, right-click-to-paste
- **Fix:** `document.fonts.ready.then(() => fitAddon.fit())` — wait for fonts before measuring
- **Fix:** Font stack prioritizes platform-native fonts: `'Cascadia Mono', Consolas, 'SF Mono', Menlo, 'Courier New', monospace`
- **Fix:** After any `fitAddon.fit()`, call `terminal.scrollToBottom()` to prevent scroll jump
- [ ] **Step 2:** Rewrite `session.store.ts`:
- Replace `Call.ByName("...WraithApp.ConnectSSH", ...)` with `invoke("connect_ssh", { connectionId, cols, rows })`
- Keep: multi-session support, disambiguated names, theme propagation
- [ ] **Step 3:** Migrate `TerminalView.vue` — should work with only composable changes
- [ ] **Step 4:** End-to-end test: connect to a real SSH server, type commands, see output, resize terminal
- [ ] **Step 5:** Commit: `feat: xterm.js terminal with Tauri event bridge`
---
### Task 2.3: Terminal Theming
**Files:**
- Create: `src-tauri/src/theme/mod.rs`
- Migrate: `src/components/common/ThemePicker.vue`
- [ ] **Step 1:** Implement theme service in Rust — same 7 built-in themes (Dracula, Nord, Monokai, One Dark, Solarized Dark, Gruvbox Dark, MobaXTerm Classic), seed on startup
- [ ] **Step 2:** Write Tauri commands: `list_themes`, `get_theme_by_name`
- [ ] **Step 3:** Wire `ThemePicker.vue``session.store.setTheme()``TerminalView.vue` watcher applies to `terminal.options.theme`
- [ ] **Step 4:** Commit: `feat: terminal theming with 7 built-in themes`
---
## Phase 3: SFTP
### Task 3.1: Rust SFTP Service
**Files:**
- Create: `src-tauri/src/sftp/mod.rs`
- [ ] **Step 1:** Implement SFTP operations using `russh-sftp`:
- `list(session_id, path) -> Vec<FileEntry>`
- `read_file(session_id, path) -> String` (5MB guard)
- `write_file(session_id, path, content)`
- `mkdir(session_id, path)`
- `delete(session_id, path)` — handles file vs directory
- `rename(session_id, old_path, new_path)`
- `stat(session_id, path) -> FileEntry`
- [ ] **Step 2:** Write Tauri commands for each operation
- [ ] **Step 3:** Commit: `feat: SFTP operations service`
---
### Task 3.2: SFTP Frontend
**Files:**
- Rewrite: `src/composables/useSftp.ts`
- Migrate: `src/components/sftp/FileTree.vue`
- [ ] **Step 1:** Rewrite `useSftp.ts``invoke()` instead of `Call.ByName()`
- [ ] **Step 2:** Ensure FileTree toolbar buttons work: upload, download, delete, mkdir, refresh
- [ ] **Step 3:** Commit: `feat: SFTP sidebar with full file operations`
---
### Task 3.3: CWD Following (Separate Exec Channel)
**Files:**
- Create: `src-tauri/src/ssh/cwd.rs`
- [ ] **Step 1:** Implement CWD tracker via **separate SSH exec channel** — NOT in the terminal data stream:
- On SSH connect, open a second channel via `session.channel_open_session()`
- Start a background task that runs `pwd` on this channel every 2 seconds
- When the output changes, emit `Tauri::Event("ssh:cwd:{session_id}", new_path)`
- The terminal data stream is NEVER touched
- [ ] **Step 2:** In `useSftp.ts`, listen for `ssh:cwd:{sessionId}` events. When `followTerminal` is enabled and path changes, call `navigateTo(newPath)`.
- [ ] **Step 3:** Test: `cd /etc` in terminal → SFTP sidebar navigates to `/etc` within 2 seconds
- [ ] **Step 4:** Commit: `feat: CWD following via separate SSH exec channel`
---
## Phase 4: RDP
### Task 4.1: Rust RDP Service
**Files:**
- Create: `src-tauri/src/rdp/mod.rs`
- Create: `src-tauri/src/rdp/input.rs`
- [ ] **Step 1:** Evaluate RDP options:
- **Option A:** `ironrdp` (pure Rust, by Devolutions) — preferred if mature enough
- **Option B:** FreeRDP3 FFI via `libloading` — same approach as current Go purego, but Rust FFI
- Pick based on which can deliver 1080p @ 30fps bitmap updates
- [ ] **Step 2:** Implement RDP service:
- `connect(config) -> session_id`
- `send_mouse(session_id, x, y, flags)`
- `send_key(session_id, scancode, pressed)`
- `send_clipboard(session_id, text)`
- `disconnect(session_id)`
- **Frame delivery via Tauri asset protocol** (not base64 IPC):
Register a custom protocol handler in `main.rs` that serves raw RGBA frames:
```rust
.register_assetprotocol("rdpframe", move |_app, request| {
// Parse session_id from URL path
// Return frame bytes as image/raw with correct dimensions header
})
```
Frontend fetches `asset://localhost/rdpframe/{session_id}` via `requestAnimationFrame` loop.
Eliminates base64 encode/decode overhead for 1080p frames (~8MB/frame raw).
- [ ] **Step 3:** Port scancode mapping table from current `internal/rdp/input.go`
- [ ] **Step 4:** Write Tauri commands for all RDP operations
- [ ] **Step 5:** Commit: `feat: RDP service`
---
### Task 4.2: RDP Frontend
**Files:**
- Rewrite: `src/composables/useRdp.ts`
- Migrate: `src/components/rdp/RdpView.vue`
- [ ] **Step 1:** Rewrite `useRdp.ts` — canvas rendering of RGBA frames from `invoke("rdp_get_frame")`
- [ ] **Step 2:** Wire mouse/keyboard capture → `invoke("rdp_send_mouse")` / `invoke("rdp_send_key")`
- [ ] **Step 3:** Commit: `feat: RDP canvas rendering in tabs`
---
## Phase 5: Polish
### Task 5.1: Tab Detach/Reattach
**Files:**
- Create: `src-tauri/src/session/mod.rs`
- Modify: `src/components/session/TabBar.vue`
- [ ] **Step 1:** Implement session manager in Rust using `DashMap` for lock-free concurrent access:
```rust
use dashmap::DashMap;
pub struct SessionManager {
sessions: DashMap<String, Session>,
}
// Session implements Drop — SSH/SFTP connections close automatically
// when the session is removed from the map or the manager is dropped.
impl Drop for Session {
fn drop(&mut self) {
// Close SSH client, SFTP client, RDP backend
// No zombie connections possible — Rust ownership guarantees it
}
}
```
- `detach(session_id)` — creates new Tauri `WebviewWindow` pointing at same session
- `reattach(session_id)` — destroys detached window, re-renders in main tab bar
- [ ] **Step 2:** Use Tauri's `WebviewWindow::new()` for detached windows
- [ ] **Step 3:** Add detach icon (pop-out arrow) to TabBar, "Session detached — Reattach" placeholder in original tab
- [ ] **Step 4:** Commit: `feat: tab detach/reattach via Tauri multi-window`
---
### Task 5.2: CodeMirror Editor (Separate Window)
**Files:**
- Modify: `src/components/editor/EditorWindow.vue`
- Create: `src/editor.html` — dedicated entry point for editor windows
- [ ] **Step 1:** Create a separate Tauri window for the editor:
- Rust side: `WebviewWindow::new()` with URL pointing to `/editor?session={id}&path={path}`
- Vue side: `editor.html` entry point that renders only the CodeMirror component
- Window title: `filename — host — Wraith Editor`
- [ ] **Step 2:** Editor reads file via `invoke("sftp_read_file")`, saves via `invoke("sftp_write_file")`
- [ ] **Step 3:** File size guard: refuse files >5MB, offer download instead
- [ ] **Step 4:** Commit: `feat: CodeMirror editor in separate window`
---
### Task 5.3: Keyboard Shortcuts
**Files:**
- Modify: `src/layouts/MainLayout.vue`
- [ ] **Step 1:** Implement full shortcut set from spec:
| Shortcut | Action |
|---|---|
| Ctrl+K | Command palette |
| Ctrl+T | New SSH session (quick connect) |
| Ctrl+W | Close current tab |
| Ctrl+Tab | Next tab |
| Ctrl+Shift+Tab | Previous tab |
| Ctrl+1-9 | Switch to tab N |
| Ctrl+B | Toggle sidebar |
| Ctrl+Shift+D | Detach current tab |
| F11 | Fullscreen |
| Ctrl+F | Search in terminal scrollback |
- [ ] **Step 2:** Commit: `feat: keyboard shortcuts`
---
### Task 5.4: Tab Badges + Status Bar
**Files:**
- Create: `src/components/session/TabBadge.vue`
- Modify: `src/components/common/StatusBar.vue`
- [ ] **Step 1:** Tab badges: protocol icon (SSH green / RDP blue), ROOT warning (check username), environment pills from connection tags (PROD/DEV/STAGING)
- [ ] **Step 2:** Status bar: live terminal dimensions from `onResize`, protocol + user@host:port, theme name, encoding
- [ ] **Step 3:** Commit: `feat: tab badges + live status bar`
---
### Task 5.5: Auto-Updater
**Files:**
- Modify: `src-tauri/tauri.conf.json`
- Modify: `src/components/common/SettingsModal.vue`
- [ ] **Step 1:** Configure Tauri built-in updater in `tauri.conf.json`:
```json
{
"plugins": {
"updater": {
"endpoints": ["https://git.command.vigilcyber.com/api/v1/repos/vstockwell/wraith-v2/releases/latest"],
"pubkey": "..."
}
}
}
```
- [ ] **Step 2:** Wire Settings "Check for Updates" button to `@tauri-apps/plugin-updater` API
- [ ] **Step 3:** Update CI workflow for Tauri bundler + code signing
- [ ] **Step 4:** Commit: `feat: Tauri auto-updater with code signing`
---
### Task 5.6: Workspace Restore (Crash Recovery)
**Files:**
- Create: `src-tauri/src/workspace/mod.rs`
- [ ] **Step 1:** Port workspace service from Go:
- Save tab layout to settings as JSON every 30 seconds + on session open/close
- Mark clean shutdown on exit
- On startup, detect dirty shutdown → offer to restore tab layout
- [ ] **Step 2:** Commit: `feat: workspace crash recovery`
---
## Phase 6: CI/CD + Distribution
### Task 6.1: Gitea Actions Workflow
**Files:**
- Create: `.gitea/workflows/build-release.yml`
- [ ] **Step 1:** Build workflow:
- Trigger on `v*` tags
- `cargo tauri build` for Windows (cross-compile from Linux or native Windows runner)
- Code signing via Azure Key Vault (jsign, same as current)
- Create Gitea Release with `tag_name: "v${VERSION}"` (include the v prefix!)
- Upload installer to Gitea packages
- [ ] **Step 2:** Test: push a tag, verify installer downloads and auto-updater finds it
- [ ] **Step 3:** Commit: `feat: CI/CD pipeline for Tauri builds`
---
## Phase 7: Go Codebase Cleanup
### Task 7.1: Archive Go Repository
- [ ] **Step 1:** Verify Tauri version is stable and deployed to production
- [ ] **Step 2:** Rename `wraith` repo to `wraith-go-legacy` on Gitea
- [ ] **Step 3:** Rename `wraith-v2` to `wraith`
- [ ] **Step 4:** Update auto-updater endpoint to new repo
- [ ] **Step 5:** Delete old Gitea packages (Go-built versions)
- [ ] **Step 6:** Archive the legacy repo (read-only)
- [ ] **Step 7:** Update any documentation, CLAUDE.md references
---
## Migration Notes
### Database — Fresh Start
No database migration from the Go version. The Commander has 6 connections — faster to re-enter credentials than to engineer format compatibility. Fresh vault, fresh wraith.db.
### What Gets Deleted (Copilot/AI)
The Go codebase has an AI copilot integration (8 files in `internal/ai/`). This is NOT being ported. The Commander will use Claude Code over SSH instead. Delete these from scope:
- `internal/ai/` — entire package
- `frontend/src/composables/useCopilot.ts`
- `frontend/src/stores/copilot.store.ts`
- `frontend/src/components/copilot/` — entire directory
### Wails → Tauri Migration Cheatsheet
| Wails v3 | Tauri v2 |
|---|---|
| `Call.ByName("full.go.path.Method", args)` | `invoke("method_name", { args })` |
| `Events.On("event:name", callback)` | `listen("event:name", callback)` |
| `Events.Emit("event:name", data)` | `emit("event:name", data)` (Rust side) |
| `Application.Quit()` | `getCurrentWindow().close()` |
| `Browser.OpenURL(url)` | `open(url)` from `@tauri-apps/plugin-shell` |
| Go struct → JSON → JS object | Rust struct (serde) → JSON → TS interface |
| `application.NewWebviewWindow()` | `WebviewWindow::new()` |
---
## Risk Register
| Risk | Mitigation |
|---|---|
| `russh` async complexity | Use `tokio` throughout; russh is well-documented with examples |
| ironrdp maturity | Fallback to FreeRDP FFI if ironrdp can't deliver 1080p@30fps |
| Tauri v2 multi-window edge cases | Spike tab detach early in Phase 5; fall back to floating panels |
| Vault encryption | Fresh vault — no Go compatibility needed. 6 credentials re-entered by hand. |
| Windows code signing in Tauri | Same jsign + Azure Key Vault approach; Tauri bundler produces .exe |
| Cross-platform SSH crate differences | russh is pure Rust, no platform-specific code; test on Windows early |

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -0,0 +1,614 @@
# Wraith — Lean Build Spec
> **Date:** 2026-03-12
> **Purpose:** Self-hosted MobaXterm replacement — SSH + SFTP + RDP in a browser
> **Stack:** Nuxt 3 (Vue 3 SPA) + NestJS 10 + PostgreSQL 16 + guacd
> **Target:** Single-user personal tool with bolt-on multi-user path
> **Reference:** `Remote-Spec.md` (full feature spec — this is the lean cut)
---
## 1. What This Is
A self-hosted web application that replaces MobaXterm. SSH terminal with SFTP sidebar (MobaXterm's killer feature), RDP via Guacamole, connection manager with hierarchical groups, and an encrypted vault for SSH keys and passwords. Runs in any browser, deployed as a Docker stack.
**What this is NOT:** An MSP product, a SaaS platform, a team collaboration tool. It's a personal remote access workstation that happens to be web-based. Multi-user is a future bolt-on, not a design constraint.
**Name:** Wraith — exists everywhere, all at once.
---
## 2. Five Modules
### 2.1 SSH Terminal
**Frontend:** xterm.js 5.x with addons:
- `@xterm/addon-fit` — auto-resize to container
- `@xterm/addon-search` — Ctrl+F scrollback search
- `@xterm/addon-web-links` — clickable URLs
- `@xterm/addon-webgl` — GPU-accelerated rendering
**Backend:** NestJS WebSocket gateway + `ssh2` (npm). Browser opens WebSocket to NestJS, NestJS opens SSH connection to target using credentials from the vault. Bidirectional data pipe: terminal input → ssh2 stdin, ssh2 stdout → terminal output.
**Features:**
- Multi-tab sessions with host name labels and color-coding by group
- Horizontal and vertical split panes within a single tab (multiple xterm.js instances in a flex grid)
- Terminal theming: dark/light modes, custom color schemes, font selection, font size
- Configurable scrollback buffer size (default 10,000 lines, configurable in settings)
- Copy/paste: Ctrl+Shift+C/V, right-click context menu
- Search in scrollback: Ctrl+F via xterm.js SearchAddon
- Auto-reconnect on connection drop with configurable retry
**Authentication flow:**
1. User clicks host in connection manager
2. Backend looks up host → finds associated credential (key or password)
3. If SSH key: decrypt private key from vault, optionally decrypt passphrase, pass to ssh2
4. If password: decrypt from vault, pass to ssh2
5. ssh2 performs host key verification (see Section 8: Host Key Verification)
6. ssh2 connects, WebSocket bridge established
### 2.2 SFTP Sidebar
The MobaXterm feature. When an SSH session connects, a sidebar automatically opens showing the remote filesystem.
**Layout:** Resizable left sidebar panel (tree view) + main terminal panel. Sidebar can be collapsed/hidden per session.
**Backend:** Uses the same ssh2 connection as the terminal (ssh2's SFTP subsystem). No separate connection needed — SFTP rides the existing SSH channel. All SFTP commands include a `sessionId` to target the correct ssh2 connection when multiple tabs are open.
**File operations:**
- Browse remote filesystem as a tree (lazy-loaded — fetch children on expand)
- Upload: drag-and-drop from desktop onto sidebar, or click upload button. Chunked transfer with progress bar.
- Download: click file → browser download, or right-click → Download
- Rename, delete, chmod, mkdir via right-click context menu
- File size, permissions, modified date shown in tree or detail view
**File editing:**
- Click a text file → opens in embedded Monaco Editor (VS Code's editor component)
- File size guard: files over 5MB are refused for inline editing (download instead)
- Syntax highlighting based on file extension
- Save button pushes content back to remote via SFTP
- Unsaved changes warning on close
**Transfer status:** Bottom status bar showing active transfers with progress, speed, ETA. Queue-based — multiple uploads/downloads run sequentially with status indicators.
### 2.3 RDP (Remote Desktop)
**Architecture:** Browser → WebSocket → NestJS Guacamole tunnel → guacd (Docker) → RDP target
**Frontend:** `guacamole-common-js` — renders remote desktop on HTML5 Canvas. Keyboard, mouse, and touch input forwarded to remote.
**Backend:** NestJS WebSocket gateway that speaks Guacamole wire protocol to the `guacd` daemon over TCP. The gateway translates between the browser's WebSocket and guacd's TCP socket.
**guacd:** Apache Guacamole daemon running as `guacamole/guacd` Docker image. Handles the actual RDP protocol translation. Battle-tested, Apache-licensed.
**Features:**
- Clipboard sync: bidirectional between browser and remote desktop
- Auto-resolution: detect browser window/tab size, send to RDP server
- Connection settings: color depth (16/24/32-bit), security mode (NLA/TLS/RDP), console session, admin mode
- Audio: remote audio playback in browser (Guacamole native)
- Full-screen mode: F11 or toolbar button
**Authentication:** RDP credentials (username + password + domain) stored encrypted in vault, associated with host. Decrypted at connect time and passed to guacd.
### 2.4 Connection Manager
The home screen. A searchable, organized view of all saved hosts.
**Host properties:**
```
name — display name (e.g., "RSM File Server")
hostname — IP or FQDN
port — default 22 (SSH) or 3389 (RDP)
protocol — ssh | rdp
group_id — FK to host_groups (nullable for ungrouped)
credential_id — FK to credentials (nullable for quick-connect-style)
tags — text[] array for categorization
notes — free text (markdown rendered)
color — hex color for visual grouping
lastConnectedAt — timestamp of most recent connection
```
**Host groups:** Hierarchical folders with `parent_id` self-reference. E.g., "RSM > Servers", "Home Lab > VMs". Collapsible tree in the sidebar.
**Quick connect:** Top bar input — type `user@hostname:port` and hit Enter to connect without saving. Protocol auto-detected (or toggle SSH/RDP).
**Search:** Full-text across host name, hostname, tags, notes, group name. Instant filter as you type.
**Recent connections:** Hosts sorted by `lastConnectedAt` shown as a quick-access section above the full host tree.
**UI pattern:** Left sidebar = group tree + host list. Main area = active sessions rendered as persistent tab components within the layout (NOT separate routes — terminal/RDP instances persist across tab switches). Double-click host or press Enter to connect. Drag hosts between groups.
### 2.5 Key Vault
Encrypted storage for SSH private keys and passwords.
**SSH keys:**
```
name — display name (e.g., "RSM Production Key")
public_key — plaintext (safe to store)
encrypted_private_key — AES-256-GCM encrypted blob
passphrase_encrypted — AES-256-GCM encrypted (nullable — not all keys have passphrases)
fingerprint — SHA-256 fingerprint for display
key_type — rsa | ed25519 | ecdsa (detected on import)
```
**Import flow:**
1. Click "Import Key" in vault management
2. Paste key content or upload `.pem`/`.pub`/id_rsa file
3. If key has passphrase, prompt for it (stored encrypted)
4. Key encrypted with AES-256-GCM using `ENCRYPTION_KEY` env var
5. Public key extracted and stored separately (for display/export)
**Credentials (passwords and key references):**
```
name — display name (e.g., "RSM root cred")
username — plaintext username (not sensitive)
domain — for RDP (e.g., "CONTOSO")
type — password | ssh_key (enum CredentialType)
encrypted_value — AES-256-GCM encrypted password (for type=password)
ssh_key_id — FK to ssh_keys (for type=ssh_key)
```
Credentials are shared entities — hosts reference credentials via `credential_id` FK on the host. Multiple hosts can share the same credential. The relationship is Host → Credential (many-to-one), not Credential → Host.
**Encryption pattern:** Same as Vigilance HQ — `ENCRYPTION_KEY` env var (32+ byte hex), AES-256-GCM, random IV per encryption, `v1:` version prefix on ciphertext for future key rotation.
---
## 3. Technology Stack
### Frontend
| Component | Technology | Purpose |
|---|---|---|
| Framework | Nuxt 3 (Vue 3, SPA mode `ssr: false`) | App shell, routing, auto-imports |
| Terminal | xterm.js 5.x + addons | SSH terminal emulator |
| RDP client | guacamole-common-js | RDP canvas rendering |
| Code editor | Monaco Editor | SFTP file editing |
| UI library | PrimeVue 4 | DataTable, Dialog, Tree, Toolbar, etc. |
| State | Pinia | Connection state, session management |
| CSS | Tailwind CSS | Utility-first styling |
| Icons | Lucide Vue | Consistent iconography |
> **Why SPA, not SSR:** xterm.js, Monaco, and guacamole-common-js are all browser-only. Every session page would need `<ClientOnly>` wrappers. No SEO benefit for a self-hosted tool behind auth. SPA mode avoids hydration mismatches entirely while keeping Nuxt's routing, auto-imports, and module ecosystem.
### Backend
| Component | Technology | Purpose |
|---|---|---|
| Framework | NestJS 10 | REST API + WebSocket gateways |
| SSH proxy | ssh2 (npm) | SSH + SFTP connections |
| RDP proxy | Custom Guacamole tunnel | NestJS ↔ guacd TCP bridge |
| Database | PostgreSQL 16 | Hosts, credentials, keys, settings |
| ORM | Prisma | Schema-as-code, type-safe queries |
| Encryption | Node.js crypto (AES-256-GCM) | Vault encryption at rest |
| Auth | JWT + bcrypt | Single-user local login |
| WebSocket | @nestjs/websockets (ws) | Terminal and RDP data channels |
### Infrastructure (Docker Compose)
```yaml
services:
app:
build: .
ports: ["3000:3000"]
environment:
DATABASE_URL: postgresql://wraith:${DB_PASSWORD}@postgres:5432/wraith
JWT_SECRET: ${JWT_SECRET}
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
GUACD_HOST: guacd
GUACD_PORT: "4822"
depends_on: [postgres, guacd]
guacd:
image: guacamole/guacd
restart: always
# Internal only — app connects via Docker DNS hostname "guacd" on port 4822
postgres:
image: postgres:16-alpine
volumes: [pgdata:/var/lib/postgresql/data]
environment:
POSTGRES_DB: wraith
POSTGRES_USER: wraith
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
pgdata:
```
> **No Redis:** JWT auth is stateless. Single NestJS process means no pub/sub fanout needed. If horizontal scaling becomes relevant later, Redis is a straightforward add. Not burning ops complexity on it now.
**Required `.env` vars:**
```
DB_PASSWORD=<strong-random-password>
JWT_SECRET=<random-256-bit-hex>
ENCRYPTION_KEY=<random-256-bit-hex>
```
Production deployment: Nginx reverse proxy on the Docker host with SSL termination and WebSocket upgrade support (`proxy_set_header Upgrade $http_upgrade`).
---
## 4. Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Browser (Any device) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ xterm.js │ │ SFTP Sidebar │ │ guac-client │ │
│ │ (SSH term) │ │ (file tree) │ │ (RDP canvas) │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ WebSocket │ WebSocket │ WebSocket │
└─────────┼──────────────────┼─────────────────┼──────────────┘
│ │ │
┌─────────┼──────────────────┼─────────────────┼──────────────┐
│ NestJS Backend (Docker: app) │
│ ┌──────▼───────┐ ┌──────▼───────┐ ┌──────▼───────┐ │
│ │ SSH Gateway │ │ SFTP Gateway │ │ Guac Tunnel │ │
│ │ (ssh2) │ │ (ssh2 sftp) │ │ (TCP→guacd) │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ SSH │ SFTP │ Guac Protocol │
│ ┌──────▼────────────────────────┐ ┌──────▼───────┐ │
│ │ Vault Service │ │ guacd │ │
│ │ (decrypt keys/passwords) │ │ (Docker) │ │
│ └──────┬────────────────────────┘ └──────┬───────┘ │
│ │ Prisma │ RDP │
│ ┌──────▼───────┐ │ │
│ │ PostgreSQL │ │ │
│ │ (Docker) │ │ │
│ └──────────────┘ │ │
└──────────────────────────────────────────────┼──────────────┘
┌─────────────────┐ ┌──────▼───────┐
│ SSH Targets │ │ RDP Targets │
│ (Linux/Unix) │ │ (Windows) │
└─────────────────┘ └──────────────┘
```
---
## 5. Database Schema (Prisma)
```prisma
model User {
id Int @id @default(autoincrement())
email String @unique
passwordHash String @map("password_hash")
displayName String? @map("display_name")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("users")
}
model HostGroup {
id Int @id @default(autoincrement())
name String
parentId Int? @map("parent_id")
sortOrder Int @default(0) @map("sort_order")
parent HostGroup? @relation("GroupTree", fields: [parentId], references: [id], onDelete: SetNull)
children HostGroup[] @relation("GroupTree")
hosts Host[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("host_groups")
}
model Host {
id Int @id @default(autoincrement())
name String
hostname String
port Int @default(22)
protocol Protocol @default(ssh)
groupId Int? @map("group_id")
credentialId Int? @map("credential_id")
tags String[] @default([])
notes String?
color String? @db.VarChar(7)
sortOrder Int @default(0) @map("sort_order")
hostFingerprint String? @map("host_fingerprint")
lastConnectedAt DateTime? @map("last_connected_at")
group HostGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull)
credential Credential? @relation(fields: [credentialId], references: [id], onDelete: SetNull)
connectionLogs ConnectionLog[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("hosts")
}
model Credential {
id Int @id @default(autoincrement())
name String
username String?
domain String?
type CredentialType
encryptedValue String? @map("encrypted_value")
sshKeyId Int? @map("ssh_key_id")
sshKey SshKey? @relation(fields: [sshKeyId], references: [id], onDelete: SetNull)
hosts Host[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("credentials")
}
model SshKey {
id Int @id @default(autoincrement())
name String
keyType String @map("key_type") @db.VarChar(20)
fingerprint String?
publicKey String? @map("public_key")
encryptedPrivateKey String @map("encrypted_private_key")
passphraseEncrypted String? @map("passphrase_encrypted")
credentials Credential[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("ssh_keys")
}
model ConnectionLog {
id Int @id @default(autoincrement())
hostId Int @map("host_id")
protocol Protocol
connectedAt DateTime @default(now()) @map("connected_at")
disconnectedAt DateTime? @map("disconnected_at")
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
@@map("connection_logs")
}
model Setting {
key String @id
value String
@@map("settings")
}
enum Protocol {
ssh
rdp
}
enum CredentialType {
password
ssh_key
}
```
---
## 6. Frontend Structure
```
frontend/
nuxt.config.ts # ssr: false (SPA mode)
layouts/
default.vue # Main layout: sidebar + persistent tab container
auth.vue # Login page layout
pages/
index.vue # Connection manager (home screen) + active session tabs
login.vue # Single-user login
vault/
index.vue # Key vault management
keys.vue # SSH key list + import
credentials.vue # Password credentials
settings.vue # App settings (theme, terminal defaults, scrollback)
components/
connections/
HostTree.vue # Sidebar host group tree
HostCard.vue # Host entry in list
HostEditDialog.vue # Add/edit host modal
GroupEditDialog.vue # Add/edit group modal
QuickConnect.vue # Top bar quick connect input
session/
SessionContainer.vue # Persistent container — holds all active sessions, manages tab switching
SessionTab.vue # Single session (SSH terminal + SFTP sidebar, or RDP canvas)
terminal/
TerminalInstance.vue # Single xterm.js instance
TerminalTabs.vue # Tab bar for multiple sessions
SplitPane.vue # Split pane container
sftp/
SftpSidebar.vue # SFTP file tree sidebar
FileTree.vue # Remote filesystem tree
FileEditor.vue # Monaco editor for text files
TransferStatus.vue # Upload/download progress
rdp/
RdpCanvas.vue # Guacamole client wrapper
RdpToolbar.vue # Clipboard, fullscreen, settings
vault/
KeyImportDialog.vue # SSH key import modal
CredentialForm.vue # Password credential form
composables/
useTerminal.ts # xterm.js lifecycle + WebSocket
useSftp.ts # SFTP operations via WebSocket
useRdp.ts # Guacamole client lifecycle
useVault.ts # Key/credential CRUD
useConnections.ts # Host CRUD + search
stores/
auth.store.ts # Login state, JWT (stored in memory/localStorage, sent via Authorization header)
session.store.ts # Active sessions, tabs — sessions persist across tab switches
connection.store.ts # Hosts, groups, search
```
> **Session architecture:** Active sessions are NOT page routes. They render as persistent tab components inside `SessionContainer.vue` within the main `index.vue` layout. Switching tabs toggles `v-show` visibility (not `v-if` destruction), so xterm.js and guacamole-common-js instances stay alive. The vault and settings pages are separate routes — navigating away from the main page does NOT destroy active sessions (the SessionContainer lives in the `default.vue` layout).
---
## 7. Backend Structure
```
backend/src/
main.ts # Bootstrap, global prefix, validation pipe
app.module.ts # Root module
prisma/
prisma.service.ts # Prisma client lifecycle
prisma.module.ts # Global Prisma module
auth/
auth.module.ts
auth.service.ts # Login, JWT issue/verify
auth.controller.ts # POST /login, GET /profile
jwt.strategy.ts # Passport JWT strategy
jwt-auth.guard.ts # Route guard (REST)
ws-auth.guard.ts # WebSocket auth guard (validates JWT from handshake)
connections/
connections.module.ts
hosts.service.ts # Host CRUD + lastConnectedAt updates
hosts.controller.ts # REST: /hosts
groups.service.ts # Group CRUD (hierarchical)
groups.controller.ts # REST: /groups
vault/
vault.module.ts
encryption.service.ts # AES-256-GCM encrypt/decrypt
credentials.service.ts # Credential CRUD + decrypt-on-demand
credentials.controller.ts # REST: /credentials
ssh-keys.service.ts # SSH key import/CRUD
ssh-keys.controller.ts # REST: /ssh-keys
terminal/
terminal.module.ts
terminal.gateway.ts # WebSocket gateway: SSH proxy via ssh2
sftp.gateway.ts # WebSocket gateway: SFTP operations
ssh-connection.service.ts # ssh2 connection management + pooling
rdp/
rdp.module.ts
rdp.gateway.ts # WebSocket gateway: Guacamole tunnel
guacamole.service.ts # TCP connection to guacd, protocol translation
settings/
settings.module.ts
settings.service.ts # Key/value settings CRUD
settings.controller.ts # REST: /settings
```
---
## 8. Key Implementation Details
### WebSocket Authentication
All WebSocket gateways validate JWT before processing any commands. The token is sent in the WebSocket handshake:
```typescript
// Client: connect with JWT
const ws = new WebSocket(`wss://host/terminal?token=${jwt}`)
// Server: ws-auth.guard.ts validates in handleConnection
// Rejects connection if token is invalid/expired
```
JWT is stored in Pinia state (memory) and localStorage for persistence. Sent via `Authorization: Bearer` header for REST, query parameter for WebSocket handshake. No cookies used for auth — CSRF protection not required.
### WebSocket Protocol (SSH)
```
Client → Server:
{ type: 'connect', hostId: 123 } # Initiate SSH connection
{ type: 'data', data: '...' } # Terminal input (keystrokes)
{ type: 'resize', cols: 120, rows: 40 } # Terminal resize
Server → Client:
{ type: 'connected', sessionId: 'uuid' } # SSH connection established
{ type: 'data', data: '...' } # Terminal output
{ type: 'host-key-verify', fingerprint: 'SHA256:...', isNew: true } # First connection — needs approval
{ type: 'error', message: '...' } # Connection error
{ type: 'disconnected', reason: '...' } # Connection closed
Client → Server (host key response):
{ type: 'host-key-accept' } # User approved — save fingerprint to host record
{ type: 'host-key-reject' } # User rejected — abort connection
```
### WebSocket Protocol (SFTP)
All SFTP commands include `sessionId` to target the correct ssh2 connection:
```
Client → Server:
{ type: 'list', sessionId: 'uuid', path: '/home/user' } # List directory
{ type: 'read', sessionId: 'uuid', path: '/etc/nginx/nginx.conf' } # Read file (max 5MB)
{ type: 'write', sessionId: 'uuid', path: '/etc/nginx/nginx.conf', data } # Write file
{ type: 'upload', sessionId: 'uuid', path: '/tmp/file.tar.gz', chunk } # Upload chunk
{ type: 'download', sessionId: 'uuid', path: '/var/log/syslog' } # Start download
{ type: 'mkdir', sessionId: 'uuid', path: '/home/user/newdir' } # Create directory
{ type: 'rename', sessionId: 'uuid', oldPath, newPath } # Rename/move
{ type: 'delete', sessionId: 'uuid', path: '/tmp/junk.log' } # Delete file
{ type: 'chmod', sessionId: 'uuid', path, mode: '755' } # Change permissions
{ type: 'stat', sessionId: 'uuid', path: '/home/user' } # Get file info
Server → Client:
{ type: 'list', path, entries: [...] } # Directory listing
{ type: 'fileContent', path, content, encoding } # File content
{ type: 'progress', transferId, bytes, total } # Transfer progress
{ type: 'error', message } # Operation error
```
### Host Key Verification
SSH host key verification follows standard `known_hosts` behavior:
1. **First connection:** ssh2 receives server's public key fingerprint. Gateway sends `host-key-verify` message to browser with `isNew: true`. User sees a dialog showing the fingerprint and chooses to accept or reject.
2. **Accept:** Fingerprint saved to `Host.hostFingerprint` in database. Connection proceeds.
3. **Subsequent connections:** ssh2 receives fingerprint, compared against stored `Host.hostFingerprint`. If match, connect silently. If mismatch, gateway sends `host-key-verify` with `isNew: false` and `previousFingerprint` — user warned of possible MITM.
4. **Reject:** Connection aborted, no fingerprint stored.
### Guacamole Tunnel (RDP)
NestJS acts as a tunnel between the browser's WebSocket and guacd's TCP socket:
1. Browser sends `{ type: 'connect', hostId: 456 }`
2. NestJS looks up host → decrypts RDP credentials
3. NestJS opens TCP socket to guacd at `${GUACD_HOST}:${GUACD_PORT}` (default: `guacd:4822`)
4. NestJS sends Guacamole handshake: `select`, `size`, `audio`, `video`, `image` instructions
5. NestJS sends `connect` instruction with RDP params (hostname, port, username, password, security, color-depth)
6. Bidirectional pipe: browser WebSocket ↔ NestJS ↔ guacd TCP
7. guacd handles actual RDP protocol to target Windows machine
The `guacamole-common-js` client library handles rendering the Guacamole instruction stream to Canvas in the browser.
### Encryption Service
Identical pattern to Vigilance HQ:
```typescript
encrypt(plaintext: string): string
→ random 16-byte IV
→ AES-256-GCM cipher with ENCRYPTION_KEY
→ return `v1:${iv.hex}:${authTag.hex}:${ciphertext.hex}`
decrypt(encrypted: string): string
→ parse version prefix, IV, authTag, ciphertext
→ AES-256-GCM decipher
→ return plaintext
```
`ENCRYPTION_KEY` is a 32-byte hex string from environment. `v1:` prefix allows future key rotation without re-encrypting all stored values.
---
## 9. Multi-User Bolt-On Path
When the time comes to add JT or Victor:
1. Add rows to `users` table
2. Add `userId` FK to `hosts`, `host_groups`, `credentials`, and `ssh_keys` tables (nullable — null = shared with all users)
3. Add `shared_with` field or a `host_permissions` join table
4. Add basic role: `admin` | `user` on `users` table
5. Filter host list by ownership/sharing in queries
6. Optional: Entra ID SSO (same pattern as HQ and RSM)
**Zero architectural changes.** The connection manager, vault, terminal, SFTP, and RDP modules don't change. You just add a filter layer on who can see what.
---
## 10. Build Phases
| Phase | Deliverables |
|---|---|
| **1: Foundation** | Docker Compose, NestJS scaffold, Prisma schema, encryption service, Nuxt 3 SPA shell, auth (single-user login), connection manager CRUD, host groups |
| **2: SSH + SFTP** | xterm.js terminal, ssh2 WebSocket proxy, host key verification, multi-tab, split panes, SFTP sidebar with file tree, upload/download, Monaco editor |
| **3: RDP** | guacd integration, Guacamole tunnel, RDP canvas rendering, clipboard sync, connection settings |
| **4: Polish** | SSH key import UI, vault management page, theming, quick connect, search, settings page, connection history/recent hosts |
> **Note on encryption timing:** The encryption service and credential CRUD (encrypted) are in Phase 1, not Phase 4. SSH connections in Phase 2 need to decrypt credentials — plaintext storage is never acceptable, even temporarily. Phase 4's vault work is the management UI (import dialogs, key list view), not the encryption layer itself.

View File

@ -0,0 +1,519 @@
# Wraith AI Copilot — Design Spec
> **Date:** 2026-03-17
> **Purpose:** First-class AI copilot integration — Claude as an XO (Executive Officer) with full terminal, filesystem, and RDP desktop access through Wraith's native protocol channels
> **Depends on:** Wraith Desktop v0.1.0 (all 4 phases complete)
> **License:** MIT (same as Wraith)
---
## 1. What This Is
An AI co-pilot that shares the Commander's view and control of remote systems. The XO (Claude) can:
- **See** RDP desktops via FreeRDP3 bitmap frames → Claude Vision API
- **Type** in SSH terminals via bidirectional stdin/stdout pipes
- **Click** in RDP sessions via FreeRDP3 mouse/keyboard input channels
- **Read/write files** via SFTP — the same connection the terminal uses
- **Open/close sessions** — autonomously connect to hosts from the connection manager
This is NOT a chatbot sidebar. It's a second operator with the same access as the human, working through the same protocol channels Wraith already provides.
**Why this is unique:** No other tool does this. Existing AI coding assistants work on local files. Wraith's XO works on remote servers — SSH terminals, Windows desktops, remote filesystems — all through native protocols. No Playwright, no browser automation, no screen recording. The RDP session IS the viewport. The SSH session IS the shell.
---
## 2. Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ Wraith Application │
│ │
│ ┌─ AI Service (internal/ai/) ─────────────────────────────────┐ │
│ │ │ │
│ │ ┌──────────────┐ ┌───────────────┐ ┌─────────────────┐ │ │
│ │ │ Claude API │ │ Tool Dispatch │ │ Conversation │ │ │
│ │ │ Client │ │ Router │ │ Manager │ │ │
│ │ │ (HTTP + SSE) │ │ │ │ (SQLite) │ │ │
│ │ └──────┬───────┘ └───────┬───────┘ └─────────────────┘ │ │
│ │ │ │ │ │
│ │ │ ┌─────────────▼──────────────┐ │ │
│ │ │ │ Tool Definitions │ │ │
│ │ │ │ │ │ │
│ │ │ │ Terminal: write, read, cwd │ │ │
│ │ │ │ SFTP: list, read, write │ │ │
│ │ │ │ RDP: screenshot, click, │ │ │
│ │ │ │ type, keypress, move │ │ │
│ │ │ │ Session: list, connect, │ │ │
│ │ │ │ disconnect │ │ │
│ │ │ └─────────────┬──────────────┘ │ │
│ │ │ │ │ │
│ └─────────┼──────────────────┼─────────────────────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌──────────────────────────────────────┐ │
│ │ Claude API │ │ Existing Wraith Services │ │
│ │ (Anthropic) │ │ │ │
│ │ │ │ SSHService.Write/Read │ │
│ │ Messages API │ │ SFTPService.List/Read/Write │ │
│ │ + Tool Use │ │ RDPService.SendMouse/SendKey │ │
│ │ + Vision │ │ RDPService.GetFrame → JPEG encode │ │
│ │ + Streaming │ │ SessionManager.Create/List │ │
│ └─────────────────┘ └──────────────────────────────────────┘ │
│ │
│ ┌─ Frontend ─────────────────────────────────────────────────┐ │
│ │ CopilotPanel.vue — right-side collapsible panel │ │
│ │ ├── Chat messages (streaming, markdown rendered) │ │
│ │ ├── Tool call visualization (what the XO did) │ │
│ │ ├── RDP screenshot thumbnails inline │ │
│ │ ├── Session awareness (which session XO is focused on) │ │
│ │ ├── Control toggle (XO driving / Commander driving) │ │
│ │ └── Quick commands bar │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
```
---
## 3. AI Service Layer (`internal/ai/`)
### 3.1 Authentication — OAuth PKCE (Max Subscription)
Wraith authenticates against the user's Claude Max subscription via OAuth Authorization Code Flow with PKCE. No API key needed. No per-token billing. Same auth path as Claude Code, but with Wraith's own independent token set (no shared credential file, no race conditions).
**OAuth Parameters:**
| Parameter | Value |
|---|---|
| Authorize URL | `https://claude.ai/oauth/authorize` |
| Token URL | `https://platform.claude.com/v1/oauth/token` |
| Client ID | `9d1c250a-e61b-44d9-88ed-5944d1962f5e` |
| PKCE Method | S256 |
| Code Verifier | 32 random bytes, base64url (no padding) |
| Code Challenge | SHA-256(verifier), base64url (no padding) |
| Redirect URI | `http://localhost:{dynamic_port}/callback` |
| Scopes | `user:inference user:profile` |
| State | 32 random bytes, base64url |
**Auth Flow:**
```
1. User clicks "Connect to Claude" in Wraith copilot settings
2. Wraith generates PKCE code_verifier + code_challenge
3. Wraith starts a local HTTP server on a random port
4. Wraith opens browser to:
https://claude.ai/oauth/authorize
?code=true
&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e
&response_type=code
&redirect_uri=http://localhost:{port}/callback
&scope=user:inference user:profile
&code_challenge={challenge}
&code_challenge_method=S256
&state={state}
5. User logs in with their Anthropic/Claude account
6. Browser redirects to http://localhost:{port}/callback?code={auth_code}&state={state}
7. Wraith validates state, exchanges code for tokens:
POST https://platform.claude.com/v1/oauth/token
{
"grant_type": "authorization_code",
"code": "{auth_code}",
"redirect_uri": "http://localhost:{port}/callback",
"client_id": "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
"code_verifier": "{verifier}",
"state": "{state}"
}
8. Response: { access_token, refresh_token, expires_in, scope }
9. Wraith encrypts tokens with vault and stores in SQLite settings:
- ai_access_token (vault-encrypted)
- ai_refresh_token (vault-encrypted)
- ai_token_expires_at (unix timestamp)
10. Done — copilot is authenticated
```
**Token Refresh (automatic, silent):**
```
When access_token is expired (checked before each API call):
POST https://platform.claude.com/v1/oauth/token
{
"grant_type": "refresh_token",
"refresh_token": "{decrypted_refresh_token}",
"client_id": "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
"scope": "user:inference user:profile"
}
→ New access_token + refresh_token stored in vault
```
**Implementation:** `internal/ai/oauth.go` — Go HTTP server for callback, PKCE helpers, token exchange, token refresh. Uses `pkg/browser` to open the authorize URL.
**Fallback:** For users without a Max subscription, allow raw API key input (stored in vault). The client checks which auth method is configured and uses the appropriate header.
### 3.2 Claude API Client
Direct HTTP client — no Python sidecar, no external SDK. Pure Go.
```go
type ClaudeClient struct {
auth *OAuthManager // handles token refresh + auth header
model string // configurable: claude-sonnet-4-5-20250514, etc.
httpClient *http.Client
baseURL string // https://api.anthropic.com
}
// SendMessage sends a messages API request with tool use + vision support.
// Returns a streaming response channel for token-by-token delivery.
func (c *ClaudeClient) SendMessage(messages []Message, tools []Tool, systemPrompt string) (<-chan StreamEvent, error)
```
**Auth header:** `Authorization: Bearer {access_token}` (from OAuth). Falls back to `x-api-key: {api_key}` if using raw API key auth.
**Message format:** Anthropic Messages API v1 (`/v1/messages`).
**Streaming:** SSE (`stream: true`). Parse `event: content_block_delta`, `event: content_block_stop`, `event: message_delta`, `event: tool_use` events. Emit to frontend via Wails events.
**Vision:** RDP screenshots sent as base64-encoded JPEG in the `image` content block type. Resolution capped at 1280x720 for token efficiency (downscale from native resolution before encoding).
**Token tracking:** Parse `usage` from the API response. Track `input_tokens`, `output_tokens`, `cache_creation_input_tokens`, `cache_read_input_tokens` per conversation. Store totals in SQLite.
### 3.2 Tool Definitions
```go
var CopilotTools = []Tool{
// Terminal
{Name: "terminal_write", Description: "Type text into an active SSH terminal session",
InputSchema: {sessionId: string, text: string}},
{Name: "terminal_read", Description: "Get recent terminal output from an SSH session (last N lines)",
InputSchema: {sessionId: string, lines: int (default 50)}},
{Name: "terminal_cwd", Description: "Get the current working directory of an SSH session",
InputSchema: {sessionId: string}},
// SFTP
{Name: "sftp_list", Description: "List files and directories at a remote path",
InputSchema: {sessionId: string, path: string}},
{Name: "sftp_read", Description: "Read the contents of a remote file (max 5MB)",
InputSchema: {sessionId: string, path: string}},
{Name: "sftp_write", Description: "Write content to a remote file",
InputSchema: {sessionId: string, path: string, content: string}},
// RDP
{Name: "rdp_screenshot", Description: "Capture the current RDP desktop screen as an image",
InputSchema: {sessionId: string}},
{Name: "rdp_click", Description: "Click at screen coordinates in an RDP session",
InputSchema: {sessionId: string, x: int, y: int, button: string (default "left")}},
{Name: "rdp_doubleclick", Description: "Double-click at coordinates",
InputSchema: {sessionId: string, x: int, y: int}},
{Name: "rdp_type", Description: "Type a text string into the RDP session",
InputSchema: {sessionId: string, text: string}},
{Name: "rdp_keypress", Description: "Press a key or key combination (e.g. 'enter', 'ctrl+c', 'alt+tab', 'win+r')",
InputSchema: {sessionId: string, key: string}},
{Name: "rdp_scroll", Description: "Scroll the mouse wheel at coordinates",
InputSchema: {sessionId: string, x: int, y: int, delta: int}},
{Name: "rdp_move", Description: "Move the mouse cursor to coordinates",
InputSchema: {sessionId: string, x: int, y: int}},
// Session Management
{Name: "list_sessions", Description: "List all active SSH and RDP sessions",
InputSchema: {}},
{Name: "connect_ssh", Description: "Open a new SSH session to a saved connection",
InputSchema: {connectionId: int}},
{Name: "connect_rdp", Description: "Open a new RDP session to a saved connection",
InputSchema: {connectionId: int}},
{Name: "disconnect", Description: "Close an active session",
InputSchema: {sessionId: string}},
}
```
### 3.3 Tool Dispatch Router
```go
type ToolRouter struct {
ssh *ssh.SSHService
sftp *sftp.SFTPService
rdp *rdp.RDPService
sessions *session.Manager
connections *connections.ConnectionService
}
// Dispatch executes a tool call and returns the result
func (r *ToolRouter) Dispatch(toolName string, input json.RawMessage) (interface{}, error)
```
The router maps tool names to existing Wraith service methods. No new protocol code — everything routes through the services we already built.
**Terminal output buffering:** The `terminal_read` tool needs recent output. Add an output ring buffer to SSHService that stores the last N lines (configurable, default 200) of each session's stdout. The buffer is written to by the existing read goroutine and read by the tool dispatcher.
**RDP screenshot encoding:** The `rdp_screenshot` tool calls `RDPService.GetFrame()` to get the raw RGBA pixel buffer, downscales to 1280x720 if larger, encodes as JPEG (quality 85), and returns as base64. This is the image that gets sent to Claude's Vision API.
### 3.4 Conversation Manager
```go
type Conversation struct {
ID string
Messages []Message
Model string
CreatedAt time.Time
TokensIn int
TokensOut int
}
type ConversationManager struct {
db *sql.DB
active *Conversation
}
// Create starts a new conversation
// Load resumes a saved conversation
// AddMessage appends a message and persists to SQLite
// GetHistory returns the full message list for API calls
// GetTokenUsage returns cumulative token counts
```
Conversations are persisted to a `conversations` SQLite table with messages stored as JSON. This allows resuming a conversation across app restarts.
### 3.5 System Prompt
```
You are the XO (Executive Officer) aboard the Wraith command station. The Commander
(human operator) works alongside you managing remote servers and workstations.
You have direct access to all active sessions through your tools:
- SSH terminals: read output, type commands, navigate filesystems
- SFTP: read and write remote files
- RDP desktops: see the screen, click, type, interact with any GUI application
- Session management: open new connections, close sessions
When given a task:
1. Assess what sessions and access you need
2. Execute efficiently — don't ask for permission to use tools, just use them
3. Report what you found or did, with relevant details
4. If something fails, diagnose and try an alternative approach
You are not an assistant answering questions. You are an operator executing missions.
Act decisively. Use your tools. Report results.
```
---
## 4. Data Model Additions
```sql
-- AI conversations
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
title TEXT,
model TEXT NOT NULL,
messages TEXT NOT NULL DEFAULT '[]', -- JSON array of messages
tokens_in INTEGER DEFAULT 0,
tokens_out INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- AI settings (stored in existing settings table)
-- ai_api_key_encrypted — Claude API key (vault-encrypted)
-- ai_model — default model
-- ai_max_tokens — max response tokens (default 4096)
-- ai_rdp_capture_rate — screenshot interval in seconds (default: on-demand)
-- ai_token_budget — monthly token budget warning threshold
```
Add migration `002_ai_copilot.sql` for the conversations table.
---
## 5. Frontend: Copilot Panel
### Layout
```
┌──────────────────────────────────────────┬──────────────┐
│ │ │
│ Terminal / RDP │ COPILOT │
│ (existing) │ PANEL │
│ │ (320px) │
│ │ │
│ │ [Messages] │
│ │ [Tool viz] │
│ │ [Thumbs] │
│ │ │
│ │ [Input] │
├──────────────────────────────────────────┴──────────────┤
│ Status bar │
└──────────────────────────────────────────────────────────┘
```
The copilot panel is a **right-side collapsible panel** (320px default width, resizable). Toggle via toolbar button or Ctrl+Shift+K.
### Components
**`CopilotPanel.vue`** — main container:
- Header: "XO" label, model selector dropdown, token counter, close button
- Message list: scrollable, auto-scroll on new messages
- Tool call cards: collapsible, show tool name + input + result
- RDP screenshots: inline thumbnails (click to expand)
- Input area: textarea with send button, Shift+Enter for newlines, Enter to send
**`CopilotMessage.vue`** — single message:
- Commander messages: right-aligned, blue accent
- XO messages: left-aligned, markdown rendered (code blocks, lists, etc.)
- Tool use blocks: collapsible card showing tool name, input params, result
**`CopilotToolViz.vue`** — tool call visualization:
- Icon per tool type (terminal icon, folder icon, monitor icon, etc.)
- Summary line: "Typed `ls -la` in Asgard (SSH)", "Screenshot from DC01 (RDP)"
- Expandable detail showing raw input/output
**`CopilotSettings.vue`** — configuration modal:
- API key input (stored encrypted in vault)
- Model selector
- Token budget threshold
- RDP capture settings
- Conversation history management
### Streaming
Claude API responses stream token-by-token:
```
Go: Claude API (SSE) → parse events → Wails events
Frontend: listen for Wails events → append to message → re-render
```
Events:
- `ai:text:{conversationId}` — text delta (append to current message)
- `ai:tool_use:{conversationId}` — tool call started (show pending card)
- `ai:tool_result:{conversationId}` — tool call completed (update card)
- `ai:done:{conversationId}` — response complete
- `ai:error:{conversationId}` — error occurred
---
## 6. Go Backend Structure
```
internal/
ai/
service.go # AIService — orchestrates everything
service_test.go
oauth.go # OAuth PKCE flow — authorize, callback, token exchange, refresh
oauth_test.go
client.go # ClaudeClient — HTTP + SSE to Anthropic API
client_test.go
tools.go # Tool definitions (JSON schema)
router.go # ToolRouter — dispatches tool calls to Wraith services
router_test.go
conversation.go # ConversationManager — persistence + history
conversation_test.go
types.go # Message, Tool, StreamEvent types
screenshot.go # RDP frame → JPEG encode + downscale
screenshot_test.go
terminal_buffer.go # Ring buffer for terminal output history
terminal_buffer_test.go
db/
migrations/
002_ai_copilot.sql # conversations table
```
---
## 7. Frontend Structure
```
frontend/src/
components/
copilot/
CopilotPanel.vue # Main panel container
CopilotMessage.vue # Single message (commander or XO)
CopilotToolViz.vue # Tool call visualization card
CopilotSettings.vue # API key, model, budget configuration
composables/
useCopilot.ts # AI service wrappers, streaming, state
stores/
copilot.store.ts # Conversation state, messages, streaming
```
---
## 8. Implementation Phases
| Phase | Deliverables |
|---|---|
| **A: Core** | AI service, Claude API client (HTTP + SSE streaming), tool definitions, tool router, conversation manager, SQLite migration, terminal output buffer |
| **B: Terminal Tools** | Wire terminal_write/read/cwd + sftp_* tools to existing services, test with real SSH sessions |
| **C: RDP Vision** | Screenshot capture (RGBA → JPEG → base64), rdp_screenshot tool, vision in API calls |
| **D: RDP Input** | rdp_click/type/keypress/move/scroll tools, coordinate mapping, key combo parsing |
| **E: Frontend** | CopilotPanel, message streaming, tool visualization, settings, conversation persistence |
---
## 9. Key Implementation Details
### Terminal Output Buffer
```go
type TerminalBuffer struct {
lines []string
mu sync.RWMutex
max int // default 200 lines
}
func (b *TerminalBuffer) Write(data []byte) // append, split on newlines
func (b *TerminalBuffer) ReadLast(n int) []string // return last N lines
func (b *TerminalBuffer) ReadAll() []string
```
Added to SSHService — the existing read goroutine writes to both the Wails event (for xterm.js) AND the buffer (for AI reads).
### RDP Screenshot Pipeline
```
RDPService.GetFrame() → raw RGBA []byte (1920×1080×4 = ~8MB)
image.NewRGBA() + copy → Go image.Image
imaging.Resize(1280, 720) → downscaled for token efficiency
jpeg.Encode(quality=85) → JPEG []byte (~100-200KB)
base64.StdEncoding.Encode() → base64 string (~150-270KB)
Claude API image content block → Vision input
```
One screenshot ≈ ~1,500 tokens. At on-demand capture (not continuous), this is manageable.
### Key Combo Parsing
For `rdp_keypress`, parse key combo strings into FreeRDP input sequences:
```
"enter" → scancode 0x1C down, 0x1C up
"ctrl+c" → Ctrl down, C down, C up, Ctrl up
"alt+tab" → Alt down, Tab down, Tab up, Alt up
"win+r" → Win down, R down, R up, Win up
"ctrl+alt+delete" → special handling (Ctrl+Alt+Del)
```
Map key names to scancodes using the existing `input.go` scancode table.
### Token Budget
Track cumulative token usage per day/month. When approaching the configured budget threshold:
- Show warning in the copilot panel header
- Log a warning
- Don't hard-block — the Commander decides whether to continue
---
## 10. Security Considerations
- **OAuth tokens** stored in vault (same AES-256-GCM encryption as SSH keys). Access token + refresh token both encrypted at rest.
- **Tokens never logged** — mask in all log output. Only log token expiry times and auth status.
- **Token refresh is automatic and silent** — no user interaction needed after initial login. Refresh token rotation handled properly (new refresh token replaces old).
- **Independent from Claude Code** — Wraith has its own OAuth session. No shared credential files, no race conditions with other Anthropic apps.
- **Fallback API key** also stored in vault if used instead of OAuth.
- **Conversation content** may contain sensitive data (terminal output, file contents, screenshots of desktops). Stored in SQLite alongside other encrypted data. Consider encrypting the messages JSON blob with the vault key.
- **Tool access is unrestricted** — the XO has the same access as the Commander. This is by design. The human is always watching and can take control.
- **No autonomous session creation without Commander context** — the XO can open sessions, but the connections (with credentials) were set up by the Commander
- **PKCE prevents token interception** — authorization code flow with S256 challenge ensures the code can only be exchanged by the app that initiated the flow

View File

@ -0,0 +1,982 @@
# Wraith Desktop — Design Spec
> **Date:** 2026-03-17
> **Purpose:** Native Windows desktop replacement for MobaXTerm — SSH + SFTP + RDP in a single binary
> **Stack:** Go + Wails v3 (Vue 3 frontend, WebView2) + SQLite + FreeRDP3 (purego)
> **Target:** Personal tool for daily MSP/sysadmin work — Windows only
> **Name:** Wraith — exists everywhere, all at once.
---
## 1. What This Is
A Windows desktop application that replaces MobaXTerm. Multi-tabbed SSH terminal with SFTP sidebar (MobaXTerm's killer feature), RDP via FreeRDP3 dynamic linking, connection manager with hierarchical groups, and an encrypted vault for SSH keys and passwords. Ships as `wraith.exe` + `freerdp3.dll`. No Docker, no database server, no sidecar processes.
**What this is NOT:** A web app, a SaaS platform, a team tool. It's a personal remote access workstation built as a native desktop binary.
**Prior art:** This is a ground-up rebuild of Wraith, which was previously a self-hosted web application (Nuxt 3 + NestJS + guacd + PostgreSQL). The web version proved the feature set; this version delivers it as a proper desktop tool.
---
## 2. Technology Stack
### Backend (Go)
| Component | Technology | Purpose |
|---|---|---|
| Framework | Wails v3 (alpha) | Desktop app shell, multi-window, Go↔JS bindings |
| SSH | `golang.org/x/crypto/ssh` | SSH client connections, PTY, auth |
| SFTP | `github.com/pkg/sftp` | Remote filesystem operations over SSH |
| RDP | FreeRDP3 via `purego` / `syscall.NewLazyDLL` | RDP protocol, bitmap rendering |
| Database | SQLite via `modernc.org/sqlite` (pure Go) | Connections, credentials, settings |
| Encryption | `crypto/aes` + `crypto/cipher` (GCM) | Vault encryption at rest |
| Key derivation | `golang.org/x/crypto/argon2` | Master password → encryption key |
### Frontend (Vue 3 in WebView2)
| Component | Technology | Purpose |
|---|---|---|
| Framework | Vue 3 (Composition API) | UI framework |
| Terminal | xterm.js 5.x + WebGL addon | SSH terminal emulator |
| File editor | CodeMirror 6 | Remote file editing (separate window) |
| CSS | Tailwind CSS | Utility-first styling |
| Components | Naive UI | Tree, tabs, modals, dialogs, inputs |
| State | Pinia | Reactive stores for sessions, connections, app state |
| Build | Vite | Frontend build tooling |
### Distribution
| Artifact | Notes |
|---|---|
| `wraith.exe` | Single Go binary, ~8-10MB |
| `freerdp3.dll` | FreeRDP3 dynamic library, shipped alongside |
| Data | `%APPDATA%\Wraith\wraith.db` (SQLite) |
| Installer | NSIS via Wails build |
---
## 3. Architecture
```
┌─────────────────────────────────────────────────────────────────────┐
│ Wails v3 Application (wraith.exe) │
│ │
│ ┌─ Go Backend ──────────────────────────────────────────────────┐ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌───────────────────┐ │ │
│ │ │ SSH Service │ │ SFTP Service │ │ RDP Service │ │ │
│ │ │ x/crypto/ssh │ │ pkg/sftp │ │ purego→freerdp3 │ │ │
│ │ └──────┬───────┘ └──────┬───────┘ └────────┬──────────┘ │ │
│ │ │ │ │ │ │
│ │ ┌──────▼─────────────────▼────────────────────▼──────────┐ │ │
│ │ │ Session Manager │ │ │
│ │ │ • Tracks all active SSH/RDP sessions │ │ │
│ │ │ • Routes I/O between frontend and protocol backends │ │ │
│ │ │ • Supports tab detach/reattach (session ≠ window) │ │ │
│ │ └────────────────────────┬───────────────────────────────┘ │ │
│ │ │ │ │
│ │ ┌────────────────────────▼───────────────────────────────┐ │ │
│ │ │ Vault Service │ │ │
│ │ │ • Master password → Argon2id → AES-256-GCM key │ │ │
│ │ │ • SQLite storage (%APPDATA%\Wraith\wraith.db) │ │ │
│ │ │ • Encrypts: SSH keys, passwords, RDP credentials │ │ │
│ │ └────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ │
│ │ │ Connection │ │ Import │ │ Host Key │ │ │
│ │ │ Manager │ │ .mobaconf │ │ Store │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ ▲ │
│ Wails v3 Bindings (type-safe Go↔JS) │
│ ▼ │
│ ┌─ Vue 3 Frontend (WebView2) ───────────────────────────────────┐ │
│ │ │ │
│ │ ┌──────────┐ ┌───────────┐ ┌──────────┐ ┌───────────────┐ │ │
│ │ │ xterm.js │ │ SFTP Tree │ │ RDP │ │ CodeMirror 6 │ │ │
│ │ │ +WebGL │ │ Sidebar │ │ Canvas │ │ (sep window) │ │ │
│ │ └──────────┘ └───────────┘ └──────────┘ └───────────────┘ │ │
│ │ │ │
│ │ ┌───────────────────────────────────────────────────────┐ │ │
│ │ │ Tab Bar (detachable) + Connection Sidebar │ │ │
│ │ │ Command Palette (Ctrl+K) | Dark theme │ │ │
│ │ │ Tailwind CSS + Naive UI │ │ │
│ │ └───────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
│ │ │
SSH (port 22) SFTP (over SSH) RDP (port 3389)
│ │ │
▼ ▼ ▼
Linux/macOS hosts Remote filesystems Windows hosts
```
### Key Architectural Decisions
**Sessions ≠ Windows.** SSH and RDP sessions live as objects in the Go Session Manager. The frontend is a view. Detaching a tab spawns a new Wails window pointing at the same backend session. Re-attaching destroys the window and re-renders the session in the original tab. The session itself never drops.
**Wails v3 multi-window risk mitigation:** This is the project's biggest technical risk. The detach/reattach model depends on Wails v3's alpha `application.NewWebviewWindow()` API. Three fallback plans, validated in priority order during Phase 1:
- **Plan A (target):** Wails v3 `NewWebviewWindow()` — true native multi-window. Spike this in Phase 1 with a minimal two-window prototype before committing.
- **Plan B:** Single Wails window with internal "floating panel" detach — session renders in a draggable, resizable overlay within the main window. Not true OS windows, but close enough. No external dependency.
- **Plan C:** Wails v3 server mode — detached sessions open in the default browser at `localhost:{port}/session/{id}`. Functional but breaks the native feel.
If Plan A fails, we fall to Plan B (which is entirely within our control). Plan C is the emergency fallback. **This must be validated in Phase 1, not discovered in Phase 4.**
**Single binary + DLL.** No Docker, no sidecar processes. SQLite is embedded (pure Go driver). FreeRDP3 is the only external dependency, loaded dynamically via `purego`.
**SFTP rides SSH.** SFTP opens a separate SSH channel on the same `x/crypto/ssh` connection as the terminal. No separate TCP connection is needed. `pkg/sftp.NewClient()` takes an `*ssh.Client` (not the shell `*ssh.Session`) and opens its own subsystem channel internally. The terminal shell session and SFTP operate as independent channels multiplexed over the same connection.
**RDP via pixel buffer.** FreeRDP3 is loaded via `purego` (dynamic linking, no CGO). FreeRDP writes decoded bitmap frames into a shared Go pixel buffer. The Go backend serves frame data to the frontend via a local HTTP endpoint (`localhost:{random_port}/frame`) that returns raw RGBA data. The frontend renders frames on a `<canvas>` element using `requestAnimationFrame`. Performance target: 1080p @ 30fps using Bitmap Update callbacks. The local HTTP approach is the default; if benchmarking reveals issues, Wails binding with base64-encoded frames is the fallback.
---
## 4. Data Model (SQLite)
```sql
-- Connection groups (hierarchical folders)
CREATE TABLE groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
parent_id INTEGER REFERENCES groups(id) ON DELETE SET NULL,
sort_order INTEGER DEFAULT 0,
icon TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Saved connections
CREATE TABLE connections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
hostname TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 22,
protocol TEXT NOT NULL CHECK(protocol IN ('ssh','rdp')),
group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL,
credential_id INTEGER REFERENCES credentials(id) ON DELETE SET NULL,
color TEXT,
tags TEXT DEFAULT '[]', -- JSON array: ["Prod","Linux","Client-RSM"]
notes TEXT,
options TEXT DEFAULT '{}', -- JSON: protocol-specific settings
sort_order INTEGER DEFAULT 0,
last_connected DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Credentials (password or SSH key reference)
CREATE TABLE credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
username TEXT,
domain TEXT,
type TEXT NOT NULL CHECK(type IN ('password','ssh_key')),
encrypted_value TEXT,
ssh_key_id INTEGER REFERENCES ssh_keys(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- SSH private keys (encrypted at rest)
CREATE TABLE ssh_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
key_type TEXT,
fingerprint TEXT,
public_key TEXT,
encrypted_private_key TEXT NOT NULL,
passphrase_encrypted TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Terminal themes (16-color ANSI + fg/bg/cursor)
CREATE TABLE themes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
foreground TEXT NOT NULL,
background TEXT NOT NULL,
cursor TEXT NOT NULL,
black TEXT NOT NULL,
red TEXT NOT NULL,
green TEXT NOT NULL,
yellow TEXT NOT NULL,
blue TEXT NOT NULL,
magenta TEXT NOT NULL,
cyan TEXT NOT NULL,
white TEXT NOT NULL,
bright_black TEXT NOT NULL,
bright_red TEXT NOT NULL,
bright_green TEXT NOT NULL,
bright_yellow TEXT NOT NULL,
bright_blue TEXT NOT NULL,
bright_magenta TEXT NOT NULL,
bright_cyan TEXT NOT NULL,
bright_white TEXT NOT NULL,
selection_bg TEXT,
selection_fg TEXT,
is_builtin BOOLEAN DEFAULT 0
);
-- Connection history (for recent connections + frequency sorting)
CREATE TABLE connection_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
connection_id INTEGER NOT NULL REFERENCES connections(id) ON DELETE CASCADE,
protocol TEXT NOT NULL,
connected_at DATETIME DEFAULT CURRENT_TIMESTAMP,
disconnected_at DATETIME,
duration_secs INTEGER
);
-- Known SSH host keys
CREATE TABLE host_keys (
hostname TEXT NOT NULL,
port INTEGER NOT NULL,
key_type TEXT NOT NULL,
fingerprint TEXT NOT NULL,
raw_key TEXT,
first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (hostname, port, key_type)
);
-- App settings (key-value)
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
```
**`connections.options`** — JSON blob for protocol-specific settings. SSH: keepalive interval, preferred auth method, shell integration toggle. RDP: color depth, security mode (NLA/TLS/RDP), console session, audio redirection, display resolution. Keeps the schema clean and extensible as we discover edge cases without adding nullable columns.
**`connections.tags`** — JSON array searchable via SQLite's `json_each()`. Enables filtering across groups (type "Prod" in search, see only production hosts regardless of which group they're in).
**Connections → Credentials** is many-to-one. Multiple hosts can share the same credential.
**SQLite WAL mode:** Enable Write-Ahead Logging (`PRAGMA journal_mode=WAL`) on database open in `db/sqlite.go`. WAL mode allows concurrent reads during writes, preventing "database is locked" errors when the frontend queries connections while the backend is writing session history or updating `last_connected` timestamps. Also set `PRAGMA busy_timeout=5000` as a safety net.
**Host keys** are keyed by `(hostname, port, key_type)`. Supports multiple key types per host. Separated from connections so host key verification works independently of saved connections (e.g., quick connect).
---
## 5. UI Layout
### Visual Identity
Dark theme inspired by the Wraith brand: deep dark backgrounds (#0d1117), blue accent (#58a6ff), green for SSH indicators (#3fb950), blue for RDP indicators (#1f6feb). The aesthetic is "operator command center" — atmospheric, moody, professional. Reference: `docs/karens-wraith-layout.png` for the target mood.
Logo: `images/wraith-logo.png` — ghost with "$" symbol, used in the title bar and app icon.
**The "alive" feel:** Tabs use a 0.5s CSS `transition` on `background-color` and `border-color` when switching between active and backgrounded states. The active tab's background subtly brightens; backgrounded tabs dim. This creates a fluid, "breathing" quality as you switch between sessions — the Wraith is present without being loud. Same 0.5s transition applies to sidebar item hover states and toolbar button interactions. No animations on the terminal itself — that would be distracting.
### Main Window Layout
```
┌─────────────────────────────────────────────────────────────┐
│ [👻 WRAITH v1.0] File View Tools Settings Help │ ← Title/Menu bar
├─────────────────────────────────────────────────────────────┤
│ [⚡ Quick connect...] [+SSH] [+RDP] 4 sessions 🔒 ⚙ │ ← Toolbar
├────────────┬────────────────────────────────────────────────┤
│ │ [Asgard ●] [Docker ●] [Predator ●] [VM01 ●]+ │ ← Tab bar
│ SIDEBAR │────────────────────────────────────────────────│
│ │ │
│ Toggles: │ Terminal / RDP Canvas │
│ 📂 Conn │ │
│ 📁 SFTP │ (xterm.js or <canvas>) │
│ │ │
│ Search │ Primary workspace area │
│ Tags │ Takes dominant space │
│ Groups │ │
│ Tree │ │
│ │ │
├────────────┴────────────────────────────────────────────────┤
│ SSH · root@asgard:22 ⚠️ ↑1.2K ↓3.4K Dark+ UTF-8 120×40 │ ← Status bar
└─────────────────────────────────────────────────────────────┘
```
### Sidebar Behavior
The left sidebar is a **single panel that toggles context** between Connections and SFTP (same as MobaXTerm, not two panels):
- **Connections view:** Search bar, tag filter pills, recent connections, hierarchical group tree with connection entries. Green dots for SSH, blue dots for RDP. "Connected" indicator on active sessions. Right-click context menu for edit, delete, duplicate, move to group.
- **SFTP view:** Activates when an SSH session connects. Path bar showing current remote directory. Toolbar with upload, download, new file, new folder, refresh, delete. File tree with name, size, modified date. "Follow terminal folder" toggle at bottom.
The sidebar is resizable. Minimum width ~200px, collapsible to icon-only rail.
### Tab Bar
- Color-coded dots: green = SSH, blue = RDP
- Protocol icon on each tab
- Environment badges: optional colored pills (PROD, ROOT, DEV) derived from connection tags
- Root session warning: tabs connected as root get a subtle warm accent
- Close button (×) on each tab
- Pop-out icon (↗) on hover for tab detach
- Overflow: chevron dropdown for hidden tabs when 10+ are open (not multi-line rows)
- Drag to reorder tabs
- Drag out of tab bar to detach into new window
- "+" button to open new session
### Tab Detach/Reattach
- **Detach:** Drag tab out of bar OR click ↗ icon → spawns new Wails window with that session still alive. Original tab shows "Session detached — [Reattach]" placeholder.
- **Reattach:** Click "Reattach" button in placeholder OR close the detached window → session snaps back into the tab bar.
- Works for both SSH and RDP sessions.
- The session lives in the Go backend, not the window. Detaching is just moving the view.
### Command Palette (Ctrl+K)
Modal overlay with fuzzy search across:
- Connection names, hostnames, group names, tags
- Actions: "New SSH", "New RDP", "Open Vault", "Settings", "Import MobaXTerm"
- Active sessions: "Switch to Asgard", "Disconnect Docker"
- Keyboard-first — arrow keys to navigate, Enter to select, Esc to close
### Status Bar
- Left: Protocol, user@host:port, privilege warning (⚠️ when root), transfer speed
- Right: Active theme name, encoding (UTF-8), terminal dimensions (cols×rows)
- Active session count in toolbar area
### Terminal Theming
Built-in themes: Dracula, Nord, Monokai, Solarized Dark, One Dark, Gruvbox, plus a "MobaXTerm Classic" theme matching the colors from the user's `.mobaconf` export.
Custom theme creation via settings. Full 16-color ANSI palette + foreground/background/cursor maps directly to xterm.js `ITheme` objects.
Per-connection theme override via `connections.options` JSON field.
### Keyboard Shortcuts
| Shortcut | Action |
|---|---|
| Ctrl+K | Command palette |
| Ctrl+T | New SSH session (from quick connect) |
| Ctrl+W | Close current tab |
| Ctrl+Tab | Next tab |
| Ctrl+Shift+Tab | Previous tab |
| Ctrl+1-9 | Switch to tab N |
| Ctrl+B | Toggle sidebar |
| Ctrl+Shift+D | Detach current tab |
| F11 | Fullscreen |
| Ctrl+Shift+C | Copy (terminal) |
| Ctrl+Shift+V | Paste (terminal) |
| Ctrl+F | Search in terminal scrollback |
### CodeMirror 6 Editor (Separate Window)
- Opens as a new Wails window when clicking a text file in the SFTP sidebar
- File size guard: files over 5MB refused for inline editing (offered download instead)
- Syntax highlighting based on file extension
- Save button writes content back to remote via SFTP
- Unsaved changes warning on close
- Window title: `filename — host — Wraith Editor`
---
## 6. SSH + SFTP Flow
### SSH Connection
```
User double-clicks "Asgard" in connection sidebar
→ Go: ConnectionManager.Connect(connectionId)
→ Go: VaultService.DecryptCredential(credentialId) → auth method
→ Go: SSHService.Dial(hostname, port, authConfig)
→ x/crypto/ssh.Dial() with host key callback
→ If new host key: emit event to frontend, user accepts/rejects
→ If changed host key: BLOCK connection, warn user (no silent accept)
→ If accepted: store in host_keys table
→ Go: SessionManager.Create(sshClient, connectionId) → sessionId
→ Go: SSHService.RequestPTY(session, "xterm-256color", cols, rows)
→ Go: SSHService.Shell(session) → stdin/stdout pipes
→ Frontend: xterm.js instance created, bound to sessionId
→ Wails bindings: bidirectional data flow
→ xterm.js onData → Go SSHService.Write(sessionId, bytes)
→ Go SSHService.Read(sessionId) → Wails event → xterm.js write
```
### SSH Authentication
Supports three auth methods:
1. **SSH Key:** Decrypt private key from vault. If key has passphrase, decrypt that too. Pass to `ssh.PublicKeys()` signer.
2. **Password:** Decrypt password from vault. Pass to `ssh.Password()`.
3. **Keyboard-Interactive:** For servers with 2FA/MFA prompts. `ssh.KeyboardInteractive()` callback relays challenge prompts to the frontend, user responds in a dialog. Common in MSP environments with PAM-based MFA.
Auth methods are tried in order: key → password → keyboard-interactive. The credential type determines which are attempted first, but keyboard-interactive is always available as a fallback for servers that require it.
### Terminal Resize
```
Frontend: xterm.js fit addon detects container resize
→ Wails binding: SSHService.Resize(sessionId, cols, rows)
→ Go: session.WindowChange(rows, cols)
```
### SFTP Sidebar
```
SSH connection established:
→ Go: SFTPService.Open(sshClient) → pkg/sftp.NewClient(sshClient)
→ Go: SFTPService.List(sessionId, homeDir) → directory listing
→ Frontend: sidebar switches to SFTP view, renders file tree
```
SFTP uses the **same SSH connection** as the terminal (SFTP subsystem). No separate connection needed.
**File operations:** All SFTP commands route through Go via Wails bindings, targeting the correct `pkg/sftp` client by sessionId.
| Operation | Go function | Notes |
|---|---|---|
| List directory | `sftp.ReadDir(path)` | Lazy-loaded on tree expand |
| Upload | `sftp.Create(path)` + chunked write | Drag-and-drop from Windows Explorer |
| Download | `sftp.Open(path)` + read | Browser-style save dialog |
| Delete | `sftp.Remove(path)` / `sftp.RemoveAll(path)` | Confirmation prompt |
| Rename/Move | `sftp.Rename(old, new)` | |
| Mkdir | `sftp.Mkdir(path)` | |
| Chmod | `sftp.Chmod(path, mode)` | |
| Read file | `sftp.Open(path)` → content | Opens in CodeMirror window |
| Write file | `sftp.Create(path)` ← content | Save from CodeMirror |
### CWD Following
```
SSH session starts:
→ Go: injects shell hook after PTY is established:
PROMPT_COMMAND='printf "\033]7;file://%s%s\033\\" "$(hostname)" "$PWD"'
(or precmd for zsh)
→ Go: SSHService reads stdout, scans for OSC 7 escape sequences
→ Go: strips OSC 7 before forwarding to xterm.js (user never sees it)
→ Go: emits CWD change event with new path
→ Frontend: if "Follow terminal folder" is enabled, calls SFTPService.List(newPath)
→ Frontend: SFTP tree navigates to new directory
```
"Follow terminal folder" is a per-session toggle (checkbox at bottom of SFTP sidebar), enabled by default.
**Shell detection:** The OSC 7 injection assumes a bash-like shell (`PROMPT_COMMAND`) or zsh (`precmd`). For fish, the equivalent is `function fish_prompt; printf "\033]7;file://%s%s\033\\" (hostname) "$PWD"; end`. If shell detection fails (unknown shell, restricted shell, non-interactive session), CWD following is silently disabled — the SFTP sidebar stays at the initial home directory and requires manual navigation.
### Upload Flow
```
User drags file from Windows Explorer onto SFTP sidebar:
→ Frontend: reads file via File API, sends chunks to Go
→ Go: SFTPService.Upload(sessionId, remotePath, fileData)
→ Go: sftp.Create(remotePath) → write chunks → close
→ Progress events emitted back to frontend
→ SFTP tree refreshes on completion
```
---
## 7. RDP Flow
### Architecture
FreeRDP3 is loaded via `purego` (or `syscall.NewLazyDLL`) at runtime. No CGO, no C compiler needed. The Go binary loads `freerdp3.dll` from the application directory.
### Connection
```
User double-clicks "CLT-VMHOST01" in connection sidebar:
→ Go: ConnectionManager.Connect(connectionId)
→ Go: VaultService.DecryptCredential(credentialId) → username, password, domain
→ Go: RDPService.Connect(host, port, username, password, domain, options)
→ purego: freerdp_new() → configure settings → freerdp_connect()
→ Register BitmapUpdate callback
→ Go: allocate pixel buffer (width × height × 4 bytes RGBA)
→ FreeRDP: decoded bitmap frames written into pixel buffer
→ Go: frame data served to frontend
→ Frontend: <canvas> renders frames via requestAnimationFrame (30fps target)
```
### Frame Delivery
FreeRDP writes decoded frame data into a shared Go pixel buffer. The frontend retrieves frame data via one of:
- **Local HTTP endpoint:** `localhost:{random_port}/frame` returns raw RGBA or PNG
- **Blob URL:** Go encodes frame, passes via Wails binding as base64
- **Optimal approach TBD during implementation** — benchmark both
Performance target: **1080p @ 30fps**. Focus on Bitmap Update callbacks. No H.264 pipeline needed — raw bitmap updates with basic RLE compression is sufficient for remote management work.
### Input Handling
```
Frontend: mouse/keyboard events captured on <canvas> element
→ Wails binding → Go: RDPService.SendMouseEvent(sessionId, x, y, flags)
→ Wails binding → Go: RDPService.SendKeyEvent(sessionId, keycode, pressed)
→ Go: translate JS virtual keycodes to RDP scancodes via lookup table
→ Go: purego calls freerdp_input_send_mouse_event / freerdp_input_send_keyboard_event
```
**Scancode mapping:** JavaScript `KeyboardEvent.code` values (e.g., "KeyA", "ShiftLeft") must be translated to RDP hardware scancodes that FreeRDP expects. A static lookup table in `internal/rdp/input.go` maps JS key codes → RDP scancodes. This is a known complexity in web-based RDP — the table must handle extended keys (e.g., right Alt, numpad) and platform-specific quirks. Reference: FreeRDP's `scancode.h` for the canonical scancode list.
**System key pass-through:** The Windows key and Alt+Tab require special handling. By default, these keys are captured by the local OS. A per-connection toggle in `connections.options` (`"grabKeyboard": true`) controls whether system keys are forwarded to the remote host or stay local. When enabled, the RDP canvas captures all keyboard input including Win key, Alt+Tab, Ctrl+Alt+Del (via a toolbar button). Power users toggling between remote and local need this to be fast and obvious — surface it as an icon in the RDP toolbar.
### Clipboard Sync
```
Remote → Local:
→ Go: FreeRDP clipboard channel callback fires
→ Go: emits clipboard event to frontend
→ Frontend: writes to system clipboard via Wails API
Local → Remote:
→ Frontend: detects clipboard change (or user pastes)
→ Wails binding → Go: RDPService.SendClipboard(sessionId, data)
→ Go: writes to FreeRDP clipboard channel
```
### RDP Connection Options
Stored in `connections.options` JSON field:
```json
{
"colorDepth": 32,
"security": "nla",
"consoleSession": false,
"audioRedirect": false,
"width": 1920,
"height": 1080,
"scaleFactor": 100
}
```
**HiDPI / display scaling:** On Windows with display scaling (e.g., 150% on a 4K monitor), the RDP session resolution must account for the scale factor. `scaleFactor` in connection options controls whether to send the physical pixel resolution or the scaled logical resolution to FreeRDP. Default behavior: detect the current Windows DPI setting and scale the RDP resolution accordingly. Override via the `scaleFactor` option (100 = no scaling, 150 = 150%).
---
## 8. Vault + Encryption
### Master Password Flow
```
App launch:
→ Master password prompt (modal, cannot be bypassed)
→ If first launch:
→ Generate random 32-byte salt
→ Store salt in settings table (key: "vault_salt")
→ Derive key: Argon2id(password, salt, t=3, m=65536, p=4, keyLen=32)
→ Encrypt a known test value ("wraith-vault-check") with derived key
→ Store encrypted test value in settings (key: "vault_check")
→ If returning:
→ Read salt and encrypted test value from settings
→ Derive key with same parameters
→ Attempt to decrypt test value
→ If decryption succeeds → vault unlocked
→ If fails → wrong password, prompt again
→ Derived key held in memory only, never written to disk
→ Key zeroed from memory on app close
```
### Encryption Functions
```
Encrypt(plaintext string) → string:
→ Generate random 12-byte IV (crypto/rand)
→ AES-256-GCM Seal(): returns ciphertext with authTag appended (Go's native format)
→ Return "v1:{iv_hex}:{sealed_hex}"
→ (sealed = ciphertext || authTag, as produced by cipher.AEAD.Seal())
Decrypt(blob string) → string:
→ Parse version prefix, IV (12B), sealed data
→ AES-256-GCM Open(): decrypts and verifies authTag (Go's native format)
→ Return plaintext
```
The `v1:` version prefix enables future key rotation without re-encrypting all stored values.
### What Gets Encrypted
| Data | Encrypted | Reason |
|---|---|---|
| SSH private keys | Yes | Sensitive key material |
| SSH key passphrases | Yes | Passphrase is a secret |
| Password credentials | Yes | Passwords are secrets |
| RDP passwords | Yes | Via credential reference |
| Hostnames, ports, usernames | No | Not secrets, needed for display |
| Public keys, fingerprints | No | Public by definition |
| Group names, tags, notes | No | Not secrets |
| Settings, themes | No | User preferences |
### Argon2id Parameters
| Parameter | Value | Rationale |
|---|---|---|
| Time cost (t) | 3 | OWASP recommended minimum |
| Memory cost (m) | 65536 (64MB) | Resists GPU attacks |
| Parallelism (p) | 4 | Matches typical core count |
| Key length | 32 bytes (256-bit) | AES-256 key size |
| Salt | 32 bytes, random | Unique per installation |
### Future: Windows DPAPI Integration (Post-MVP)
The current vault is secure and portable (works on any Windows machine, backup the `.db` file and go). Post-MVP, an optional DPAPI layer could wrap the derived AES key with Windows Data Protection API, tying the vault to the current Windows user account. This would enable:
- Transparent unlock when logged into Windows (no master password prompt)
- Hardware-backed key protection on machines with TPM
- Enterprise trust (DPAPI is a known quantity for IT departments)
Implementation: the Argon2id-derived key gets wrapped with `CryptProtectData()` and stored. On unlock, DPAPI unwraps the key. Master password remains the fallback for portability (moving the database to another machine). This is designed-for but not built in MVP — the `v1:` encryption prefix enables adding a `v2:` scheme without re-encrypting existing data.
---
## 9. MobaXTerm Importer
### Config Format
MobaXTerm exports configuration as `.mobaconf` files — INI format with `%`-delimited session strings.
```ini
[Bookmarks_1]
SubRep=AAA Vantz's Stuff # Group name
ImgNum=41 # Icon index
*Asgard=#109#0%192.168.1.4%22%vstockwell%... # SSH session
CLT-VMHOST01=#91#4%100.64.1.204%3389%... # RDP session
[SSH_Hostkeys]
ssh-ed25519@22:192.168.1.4=0x29ac... # Known host keys
[Colors]
ForegroundColour=236,236,236 # Terminal colors
BackgroundColour=36,36,36
[Passwords]
vstockwell@192.168.1.214=_@9jajOXK... # Encrypted (can't import)
```
### Session String Parsing
| Protocol | Type code | Fields (%-delimited) |
|---|---|---|
| SSH | `#109#` | host, port, username, ..., SSH key path, ..., colors |
| RDP | `#91#` | host, port, username, ..., color depth, security |
### Import Flow
```
1. User: File → Import → Select .mobaconf file
2. Go: parse INI sections
3. Go: extract groups from [Bookmarks_N] SubRep values
4. Go: parse session strings → connections
5. Go: parse [SSH_Hostkeys] → host_keys table
6. Go: parse [Colors] + [Font] → create "MobaXTerm Import" theme
7. Frontend: show preview dialog:
"Found: 18 connections, 1 group, 4 host keys, 1 color theme"
8. User confirms import
9. Go: create groups, connections, host keys, theme in SQLite
10. Frontend: report results:
"Imported! 5 connections reference SSH keys — re-import key files.
3 connections had stored passwords — re-enter in Wraith vault."
```
### What Gets Imported
| Data | Imported | Notes |
|---|---|---|
| Connection names | Yes | |
| Groups (folder hierarchy) | Yes | From SubRep values |
| Hostnames, ports | Yes | |
| Usernames | Yes | |
| Protocol (SSH/RDP) | Yes | From type code #109# / #91# |
| SSH key file paths | As notes | User must re-import actual key files |
| Host keys | Yes | To host_keys table |
| Terminal colors | Yes | As a new theme |
| Font preferences | Yes | To settings |
| Encrypted passwords | No | MobaXTerm-encrypted, can't decrypt |
---
## 10. Frontend Structure
```
frontend/
src/
App.vue # Root: master password → main layout
layouts/
MainLayout.vue # Sidebar + tab container + status bar
UnlockLayout.vue # Master password prompt
components/
sidebar/
ConnectionTree.vue # Group tree with connection entries
SftpBrowser.vue # SFTP file tree + toolbar
SidebarToggle.vue # Connections ↔ SFTP toggle
session/
SessionContainer.vue # Holds all active sessions (v-show, not v-if)
TabBar.vue # Draggable, detachable tab bar
TabBadge.vue # PROD/ROOT/DEV environment pills
terminal/
TerminalView.vue # xterm.js instance wrapper
ThemePicker.vue # Terminal color scheme selector
rdp/
RdpView.vue # Canvas-based RDP renderer
RdpToolbar.vue # Clipboard, fullscreen controls
sftp/
FileTree.vue # Remote filesystem tree (lazy-loaded)
TransferProgress.vue # Upload/download progress indicator
vault/
VaultManager.vue # SSH keys + credentials management
KeyImportDialog.vue # SSH key import modal
CredentialForm.vue # Password/key credential form
common/
CommandPalette.vue # Ctrl+K fuzzy search overlay
QuickConnect.vue # Quick connect input
StatusBar.vue # Bottom status bar
composables/
useSession.ts # Session lifecycle + tab management
useTerminal.ts # xterm.js + Wails binding bridge
useSftp.ts # SFTP operations via Wails bindings
useRdp.ts # RDP canvas rendering + input capture
useVault.ts # Key/credential CRUD
useConnections.ts # Connection CRUD + search + tags
useTheme.ts # Terminal theme management
useCommandPalette.ts # Command palette search + actions
stores/
session.store.ts # Active sessions, tab order, detach state
connection.store.ts # Connections, groups, search state
app.store.ts # Global state: unlocked, settings, active theme
```
**Session architecture:** Active sessions render as persistent components inside `SessionContainer.vue`. Switching tabs toggles `v-show` visibility (not `v-if` destruction), so xterm.js and RDP canvas instances stay alive across tab switches. This is critical — destroying and recreating terminal instances would lose scrollback and session state.
---
## 11. Go Backend Structure
```
internal/
app/
app.go # Wails app setup, window management
menu.go # Application menu definitions
session/
manager.go # Session lifecycle, tab detach/reattach
session.go # Session struct (SSH or RDP, backend state)
ssh/
service.go # SSH dial, PTY, shell, I/O pipes
hostkey.go # Host key verification + storage
cwd.go # OSC 7 parsing for CWD tracking
sftp/
service.go # SFTP operations (list, upload, download, etc.)
rdp/
service.go # RDP session management
freerdp.go # purego bindings to freerdp3.dll
pixelbuffer.go # Shared frame buffer management
input.go # Mouse/keyboard event translation
vault/
service.go # Encrypt/decrypt, master password, key derivation
vault_test.go # Encryption round-trip tests
connections/
service.go # Connection CRUD, group management
search.go # Full-text search + tag filtering
importer/
mobaconf.go # MobaXTerm .mobaconf parser
mobaconf_test.go # Parser tests with real config samples
db/
sqlite.go # SQLite connection, migrations
migrations/ # SQL migration files
settings/
service.go # Key-value settings CRUD
theme/
service.go # Theme CRUD, built-in theme definitions
builtins.go # Dracula, Nord, Monokai, etc.
plugin/
interfaces.go # Plugin interfaces (ProtocolHandler, Importer, etc.)
registry.go # Plugin registration and lifecycle
```
### Plugin Interface
Wraith exposes Go interfaces that community developers can implement to extend functionality:
```go
// ProtocolHandler — add support for new protocols (VNC, Telnet, etc.)
type ProtocolHandler interface {
Name() string
Connect(config ConnectionConfig) (Session, error)
Disconnect(sessionId string) error
}
// Importer — add support for importing from other tools
type Importer interface {
Name() string
FileExtensions() []string
Parse(data []byte) (*ImportResult, error)
}
```
Plugins are compiled into the binary (not runtime-loaded). Community developers fork the repo, implement the interface, register it in `plugin/registry.go`, and build. This keeps distribution simple (single binary) while enabling extensibility.
---
## 12. MVP Scope
### In MVP (launch-blocking)
| Feature | Priority | Phase | Notes |
|---|---|---|---|
| Wails v3 scaffold + SQLite + vault | P0 | 1 | Foundation — nothing works without this |
| Connection manager sidebar | P0 | 1 | Groups, tree, search, tags |
| SSH terminal (xterm.js) | P0 | 2 | Multi-tab, 8+ concurrent sessions |
| SFTP sidebar | P0 | 2 | Auto-open, CWD following, file ops |
| Credential vault UI | P0 | 2 | SSH key import, credential management |
| Host key verification | P0 | 2 | Accept/reject new, block changed |
| RDP in tabs | P0 | 3 | FreeRDP3/purego, embedded canvas |
| MobaXTerm importer | P1 | 4 | Parse .mobaconf, first-run detection |
| Terminal theming | P1 | 4 | 6+ built-in themes, custom themes |
| Tab detach/reattach | P1 | 4 | Drag out, pop-out icon, reattach button |
| CodeMirror 6 editor | P1 | 4 | Separate window, syntax highlighting |
| Command palette (Ctrl+K) | P1 | 4 | Fuzzy search connections + actions |
| Session context awareness | P1 | 4 | Root warning, user@host in status bar |
| Tab badges | P1 | 4 | Protocol icon, environment tags |
| Quick connect | P1 | 4 | user@host:port in toolbar |
| Plugin interface | P1 | 1 | Define interfaces, implement in later phases |
| README.md | P1 | 1 | Developer docs, architecture, contribution guide |
### Post-MVP
| Feature | Notes |
|---|---|
| Split panes | Horizontal/vertical splits within a tab |
| Session recording/playback | asciinema-compatible |
| Jump host / bastion proxy | ProxyJump chain support |
| Port forwarding manager | Local, remote, dynamic SSH tunnels |
| Saved snippets/macros | Quick-execute command library |
| Tab grouping/stacking | Browser-style tab groups |
| Live latency monitoring | Ping/packet loss in status bar |
| Dual-pane SFTP | Server-to-server file operations |
| Auto-detect environment | Parse hostname for prod/dev/staging classification |
| Subtle glow effects | "Wraith" personality — energy on active sessions |
| Dynamic plugin loading | Drop-in plugins without recompilation (longer-term) |
| Windows DPAPI vault | Optional OS-backed encryption layer for transparent unlock |
| **Claude Code plugin** | **First official plugin — see below** |
### Post-MVP Plugin: Claude Code Integration
The first plugin built on the Wraith plugin interface. Embeds Claude Code directly into Wraith as a sidebar panel or tab, with full access to the active session's context.
**Authentication:** User authenticates with their Anthropic API key or Claude account (stored encrypted in the vault alongside SSH keys and passwords). Key is decrypted on demand, never persisted in plaintext.
**Core capabilities:**
- **Terminal integration:** Claude Code runs in a dedicated Wraith tab (xterm.js instance). It can see the active SSH session's terminal output and type commands into it — same as a human operator switching tabs.
- **SFTP-aware file access:** Claude Code can read and write files on the remote host via the active SFTP session. "Read `/etc/nginx/nginx.conf`" pulls the file through SFTP, Claude analyzes/modifies it, and writes it back. No need for Claude to SSH separately — it rides the existing Wraith session.
- **CodeMirror handoff:** Claude can open files in the CodeMirror editor window, make changes, and save back to the remote host. The user sees the edits happening in real-time.
- **Context awareness:** Claude sees which host you're connected to, the current working directory (via CWD tracking), and recent terminal output. "Fix the nginx config on this server" just works because Claude already knows where "this" is.
**UX flow:**
1. User opens Claude Code panel (sidebar tab or dedicated session tab)
2. Types a prompt: "Check why nginx is returning 502 on this server"
3. Claude reads recent terminal output, pulls nginx config via SFTP, analyzes logs
4. Claude proposes a fix, user approves, Claude writes the file via SFTP
5. Claude types `nginx -t && systemctl reload nginx` into the terminal
**Plugin interface usage:** This plugin implements `ProtocolHandler` (for the Claude Code tab) and extends the SFTP/terminal services to allow programmatic read/write. It proves the plugin architecture works and becomes the reference implementation for community plugin developers.
---
## 13. Build Phases
### Error Handling + Logging Strategy
**Structured logging:** Use `log/slog` (Go 1.21+ standard library) with JSON output. Log levels: DEBUG, INFO, WARN, ERROR. Log to `%APPDATA%\Wraith\wraith.log` with daily rotation (keep 7 days).
**Connection drops:** When an SSH/RDP connection drops unexpectedly:
1. Session Manager marks session as `disconnected`
2. Frontend tab shows "Connection lost — [Reconnect] [Close]"
3. Auto-reconnect is opt-in (configurable per connection via `options` JSON)
4. If auto-reconnect is enabled, retry 3 times with exponential backoff (1s, 2s, 4s)
**Error surfacing:** Errors from Go backend are emitted as Wails events with a severity level. Frontend shows:
- Transient errors (network timeout) → toast notification, auto-dismiss
- Actionable errors (auth failure) → modal with explanation and action button
- Fatal errors (vault corruption) → full-screen error with instructions
**Sensitive data in logs:** Never log passwords, private keys, or decrypted credentials. Log only: connection IDs, hostnames, session IDs, error types.
### Crash Recovery + Workspace Restore
When the app crashes, the system reboots, or Wails dies, SSH/RDP sessions are gone — there's no way to recover a dropped TCP connection. But the **workspace layout** can be restored.
**Workspace snapshots:** The Session Manager periodically writes a workspace snapshot to SQLite (every 30 seconds and on clean shutdown):
```json
{
"tabs": [
{"connectionId": 1, "protocol": "ssh", "position": 0, "detached": false},
{"connectionId": 5, "protocol": "rdp", "position": 1, "detached": false},
{"connectionId": 3, "protocol": "ssh", "position": 2, "detached": true, "windowBounds": {...}}
],
"sidebarWidth": 240,
"sidebarMode": "connections",
"activeTab": 0
}
```
**On restart after crash:**
1. Detect unclean shutdown (snapshot exists but no `clean_shutdown` flag)
2. Show: "Wraith closed unexpectedly. Restore previous workspace? [Restore] [Start Fresh]"
3. If Restore: recreate tab layout, attempt to reconnect each session
4. Tabs that fail to reconnect show "Connection lost — [Retry] [Close]"
Users care about continuity more than perfection. Even if every session dies, restoring the layout and offering one-click reconnect is a massive UX win.
### Resource Management
With 20+ SSH sessions and multiple RDP sessions, resource awareness is critical:
**Memory budget:** Each SSH session costs ~2-5MB (PTY buffer + SFTP client). Each RDP session costs ~8-12MB (pixel buffer at 1080p). Target: stable at 20 SSH + 3 RDP (~100-120MB total backend memory).
**Session limits:**
- Default max: 32 concurrent sessions (SSH + RDP combined)
- Configurable via settings
- When limit reached: "Maximum sessions reached. Close a session to open a new one."
**Inactive session handling:**
- Sessions idle for 30+ minutes get a subtle "idle" indicator on the tab (dimmed text)
- SSH keepalive (`ServerAliveInterval` equivalent) prevents server-side timeouts — configurable per connection via `options.keepAliveInterval` (default: 60 seconds)
- No automatic session suspension — users control their sessions explicitly
- SFTP idle connections are closed after 10 minutes of inactivity and silently reopened on next file operation
**Monitoring:** Expose a "Sessions" panel in Settings showing per-session memory usage, connection duration, and idle time. Simple table, not a dashboard.
---
## 14. Licensing + Open Source
**License:** MIT. All dependencies must be MIT, Apache 2.0, BSD, or ISC compatible. **No GPL/AGPL dependencies.**
Dependency license audit is part of Phase 1. Key libraries and their licenses:
- `golang.org/x/crypto` — BSD-3-Clause ✓
- `github.com/pkg/sftp` — BSD-2-Clause ✓
- `github.com/ebitengine/purego` — Apache 2.0 ✓
- `modernc.org/sqlite` — BSD-3-Clause ✓
- FreeRDP3 — Apache 2.0 ✓ (dynamically linked, no license contamination)
- xterm.js — MIT ✓
- Vue 3 — MIT ✓
- Naive UI — MIT ✓
- Tailwind CSS — MIT ✓
- CodeMirror 6 — MIT ✓
**Plugin architecture:** The Go backend exposes a plugin interface so community developers can extend Wraith with custom protocol handlers, importers, or sidebar panels. Plugins are Go packages that implement defined interfaces and are compiled into the binary (no runtime plugin loading — keeps the binary simple and portable).
**README.md:** Comprehensive developer-facing documentation covering: architecture overview, build instructions, project structure walkthrough, plugin development guide, contribution guidelines, and the design philosophy. Written as part of Phase 1.
---
## 15. First-Run Experience
On first launch:
1. Master password creation dialog (set + confirm)
2. Detect if `.mobaconf` files exist in common locations (`%APPDATA%\MobaXterm\`, user's Documents folder)
3. If found: prompt "We found a MobaXTerm configuration. Import your sessions?" with file path shown
4. If not found: offer "Import from MobaXTerm" button + "Start fresh"
5. After import (or skip): land on the empty connection manager with a "Create your first connection" prompt
---
## 16. Build Phases
| Phase | Deliverables |
|---|---|
| **1: Foundation** | Wails v3 scaffold (including multi-window spike — validate Plan A/B/C), SQLite schema + migrations (WAL mode), vault service (master password, Argon2id, AES-256-GCM), connection CRUD, group tree, Vue 3 shell with sidebar + tab container, dark theme, Naive UI integration, plugin interface definitions, README.md, license audit, **RDP frame transport spike** (benchmark HTTP vs base64 with a test canvas — don't wait until Phase 3) |
| **2: SSH + SFTP** | SSH service (x/crypto/ssh), PTY + shell, xterm.js terminal rendering, multi-tab sessions, SFTP sidebar (pkg/sftp), file tree, upload/download, CWD following (OSC 7), CodeMirror 6 editor in separate window, workspace snapshot persistence |
| **3: RDP** | FreeRDP3 purego bindings, pixel buffer, canvas rendering (using proven transport from Phase 1 spike), mouse/keyboard input mapping (including scancode table + system key pass-through), clipboard sync, connection options |
| **4: Polish** | Command palette, tab detach/reattach, terminal theming (built-in + custom), MobaXTerm importer (with first-run detection), tab badges, session context awareness, quick connect, host key management UI, settings page, crash recovery / workspace restore, resource management panel, NSIS installer |

108
docs/test-buildout-spec.md Normal file
View File

@ -0,0 +1,108 @@
# Wraith Remote — Test Suite Build-Out Spec
**Date:** 2026-03-14
**Scope:** Level B — Full service layer coverage (~80-100 tests)
**Status:** Pinned — awaiting green light to execute
---
## Backend (Jest)
Jest is already configured in `backend/package.json`. Zero spec files exist today.
### Infrastructure
- Tests co-located with source: `*.spec.ts` next to `*.ts`
- Shared mock factories in `backend/src/__mocks__/` for Prisma, JwtService, EncryptionService
- `beforeEach` resets all mocks to prevent test bleed
### Test Files
| File | Tests | Priority |
|------|-------|----------|
| `auth.service.spec.ts` | Login (valid, invalid, non-existent user timing), bcrypt→argon2 migration, TOTP setup/verify/disable, TOTP secret encryption/decryption, password hashing, profile update, admin CRUD | ~20 |
| `encryption.service.spec.ts` | v2 encrypt/decrypt round-trip, v1 backwards compat decrypt, v1→v2 upgrade, isV1 detection, invalid version handling, key derivation warmup | ~8 |
| `credentials.service.spec.ts` | findAll excludes encryptedValue, findOne with ownership check, create with encryption, update with password change, remove, decryptForConnection (password, SSH key, orphaned key, no auth) | ~10 |
| `ssh-keys.service.spec.ts` | Create with encryption, findAll (no private key leak), findOne ownership, update passphrase, remove, key type detection, fingerprint generation | ~8 |
| `jwt-auth.guard.spec.ts` | Passes valid JWT, rejects missing/expired/invalid JWT | ~3 |
| `admin.guard.spec.ts` | Allows admin role, blocks non-admin, blocks missing user | ~3 |
| `ws-auth.guard.spec.ts` | Cookie-based auth, WS ticket auth (valid, expired, reused), legacy URL token fallback, no token rejection | ~6 |
| `auth.controller.spec.ts` | Login sets cookie, logout clears cookie, ws-ticket issuance and consumption, TOTP endpoints wired correctly | ~8 |
**Backend total: ~66 tests**
### Mocking Strategy
- **PrismaService:** Jest manual mock returning controlled data per test
- **EncryptionService:** Mock encrypt returns `v2:mock:...`, mock decrypt returns plaintext
- **JwtService:** Mock sign returns `mock-jwt-token`, mock verify returns payload
- **Argon2:** Real library (fast enough for unit tests, tests actual hashing behavior)
- **bcrypt:** Real library (needed to test migration path)
---
## Frontend (Vitest)
No test infrastructure exists today. Needs full setup.
### Infrastructure
- Install: `vitest`, `@vue/test-utils`, `@pinia/testing`, `happy-dom`
- Config: `frontend/vitest.config.ts` with happy-dom environment
- Global mock for `$fetch` (Nuxt auto-import) via setup file
- Global mock for `navigateTo` (Nuxt auto-import)
### Test Files
| File | Tests | Priority |
|------|-------|----------|
| `stores/auth.store.spec.ts` | Login success (stores user, no token in state), login TOTP flow, logout clears state + calls API, fetchProfile success/failure, getWsTicket, isAuthenticated/isAdmin getters | ~10 |
| `stores/connection.store.spec.ts` | fetchHosts, fetchTree, createHost, updateHost, deleteHost, group CRUD, no Authorization headers in requests | ~8 |
| `composables/useVault.spec.ts` | listKeys, importKey, deleteKey, listCredentials, createCredential, updateCredential, deleteCredential — all without Authorization headers | ~7 |
| `middleware/admin.spec.ts` | Redirects non-admin to /, allows admin through | ~3 |
| `plugins/auth.client.spec.ts` | Calls fetchProfile on init when user is null, skips when user exists | ~2 |
**Frontend total: ~30 tests**
---
## NPM Scripts
```json
// backend/package.json
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage"
// frontend/package.json
"test": "vitest run",
"test:watch": "vitest",
"test:cov": "vitest run --coverage"
```
---
## Pre-commit Hook (Husky)
- Install `husky` + `lint-staged` at repo root
- Pre-commit runs: backend Jest (changed files) + frontend Vitest (changed files)
- Fast feedback — only tests related to changed files run on commit
---
## Execution Plan
1. **Agent 1:** Backend test infra (mock factories) + auth service tests + auth controller tests + guard tests (~37 tests)
2. **Agent 2:** Backend vault tests — encryption, credentials, SSH keys (~26 tests)
3. **Agent 3:** Frontend test infra (Vitest setup) + store tests + middleware + plugin tests (~30 tests)
4. **XO (me):** Wire npm scripts, verify all pass, add Husky pre-commit hook, final integration check
---
## Future (Level C — when ready)
- Component tests with Vue Test Utils (render + interaction)
- Integration tests against a real test PostgreSQL (Docker test container)
- Gateway tests (WebSocket mocking for terminal, SFTP, RDP)
- E2E with Playwright
- CI pipeline (Gitea Actions)

12
frontend/index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Wraith</title>
</head>
<body class="bg-[#0d1117] text-[#e0e0e0]">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -1,37 +1,33 @@
{
"name": "wraith-v2",
"version": "0.1.0",
"name": "wraith-frontend",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wraith-v2",
"version": "0.1.0",
"name": "wraith-frontend",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/lang-json": "^6.0.0",
"@codemirror/lang-markdown": "^6.0.0",
"@codemirror/lang-python": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/theme-one-dark": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.40.0",
"@wailsio/runtime": "latest",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"pinia": "^3.0.0",
"vue": "^3.5.0"
"codemirror": "^6.0.2",
"pinia": "^2.2.0",
"vue": "^3.5.0",
"vue-router": "^4.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@vitejs/plugin-vue": "^5.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.0",
"typescript": "^5.5.0",
"vite": "^6.0.0",
"vue-tsc": "^2.0.0"
}
@ -214,6 +210,17 @@
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.6.0.tgz",
"integrity": "sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
@ -1506,25 +1513,6 @@
"vite": "^5.2.0 || ^6 || ^7"
}
},
"node_modules/@tauri-apps/api": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
}
},
"node_modules/@tauri-apps/plugin-shell": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz",
"integrity": "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.10.1"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -1637,37 +1625,10 @@
}
},
"node_modules/@vue/devtools-api": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
"license": "MIT",
"dependencies": {
"@vue/devtools-kit": "^7.7.9"
}
},
"node_modules/@vue/devtools-kit": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
"license": "MIT",
"dependencies": {
"@vue/devtools-shared": "^7.7.9",
"birpc": "^2.3.0",
"hookable": "^5.5.3",
"mitt": "^3.0.1",
"perfect-debounce": "^1.0.0",
"speakingurl": "^14.0.1",
"superjson": "^2.2.2"
}
},
"node_modules/@vue/devtools-shared": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
"license": "MIT",
"dependencies": {
"rfdc": "^1.4.1"
}
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/@vue/language-core": {
"version": "2.2.12",
@ -1744,6 +1705,12 @@
"integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==",
"license": "MIT"
},
"node_modules/@wailsio/runtime": {
"version": "3.0.0-alpha.79",
"resolved": "https://registry.npmjs.org/@wailsio/runtime/-/runtime-3.0.0-alpha.79.tgz",
"integrity": "sha512-NITzxKmJsMEruc39L166lbPJVECxzcbdqpHVqOOF7Cu/7Zqk/e3B/gNpkUjhNyo5rVb3V1wpS8oEgLUmpu1cwA==",
"license": "MIT"
},
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
@ -1785,15 +1752,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/birpc": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
"integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
@ -1804,19 +1762,19 @@
"balanced-match": "^1.0.0"
}
},
"node_modules/copy-anything": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
"integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
"license": "MIT",
"dependencies": {
"is-what": "^5.2.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
},
"node_modules/crelt": {
@ -1972,24 +1930,6 @@
"he": "bin/he"
}
},
"node_modules/hookable": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
"license": "MIT"
},
"node_modules/is-what": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
"integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@ -2298,12 +2238,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/muggle-string": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
@ -2336,12 +2270,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"license": "MIT"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -2362,19 +2290,20 @@
}
},
"node_modules/pinia": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz",
"integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^7.7.7"
"@vue/devtools-api": "^6.6.3",
"vue-demi": "^0.14.10"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"typescript": ">=4.5.0",
"vue": "^3.5.11"
"typescript": ">=4.4.4",
"vue": "^2.7.0 || ^3.5.11"
},
"peerDependenciesMeta": {
"typescript": {
@ -2410,12 +2339,6 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/rollup": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
@ -2470,33 +2393,12 @@
"node": ">=0.10.0"
}
},
"node_modules/speakingurl": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/superjson": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
"integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
"license": "MIT",
"dependencies": {
"copy-anything": "^4"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tailwindcss": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
@ -2652,6 +2554,47 @@
}
}
},
"node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/vue-tsc": {
"version": "2.2.12",
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz",

37
frontend/package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "wraith-frontend",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"build:dev": "vue-tsc --noEmit && vite build --minify false --mode development",
"preview": "vite preview"
},
"dependencies": {
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.40.0",
"@wailsio/runtime": "latest",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"codemirror": "^6.0.2",
"pinia": "^2.2.0",
"vue": "^3.5.0",
"vue-router": "^4.4.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@vitejs/plugin-vue": "^5.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.5.0",
"vite": "^6.0.0",
"vue-tsc": "^2.0.0"
}
}

30
frontend/src/App.vue Normal file
View File

@ -0,0 +1,30 @@
<template>
<div class="h-screen w-screen bg-[var(--wraith-bg-primary)]">
<!-- Loading state -->
<div v-if="appStore.isLoading" class="h-full flex items-center justify-center">
<div class="text-center">
<h1 class="text-3xl font-bold text-[var(--wraith-accent-blue)]">WRAITH</h1>
<p class="text-[var(--wraith-text-secondary)] mt-2">Loading...</p>
</div>
</div>
<!-- Unlock screen -->
<UnlockLayout v-else-if="!appStore.isUnlocked" />
<!-- Main application -->
<MainLayout v-else />
</div>
</template>
<script setup lang="ts">
import { onMounted } from "vue";
import { useAppStore } from "@/stores/app.store";
import UnlockLayout from "@/layouts/UnlockLayout.vue";
import MainLayout from "@/layouts/MainLayout.vue";
const appStore = useAppStore();
onMounted(() => {
appStore.checkFirstRun();
});
</script>

View File

@ -0,0 +1,29 @@
@import "tailwindcss";
:root {
--wraith-bg-primary: #0d1117;
--wraith-bg-secondary: #161b22;
--wraith-bg-tertiary: #21262d;
--wraith-border: #30363d;
--wraith-text-primary: #e0e0e0;
--wraith-text-secondary: #8b949e;
--wraith-text-muted: #484f58;
--wraith-accent-blue: #58a6ff;
--wraith-accent-green: #3fb950;
--wraith-accent-red: #f85149;
--wraith-accent-yellow: #e3b341;
}
body {
margin: 0;
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
background: var(--wraith-bg-primary);
color: var(--wraith-text-primary);
overflow: hidden;
user-select: none;
}
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: var(--wraith-bg-primary); }
::-webkit-scrollbar-thumb { background: var(--wraith-border); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--wraith-text-muted); }

View File

@ -0,0 +1,97 @@
<template>
<Teleport to="body">
<div
v-if="visible"
class="fixed inset-0 z-50"
@click="close"
@contextmenu.prevent="close"
>
<div
ref="menuRef"
class="fixed bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl py-1 min-w-[160px] overflow-hidden"
:style="{ left: position.x + 'px', top: position.y + 'px' }"
@click.stop
>
<template v-for="(item, idx) in items" :key="idx">
<!-- Separator -->
<div v-if="item.separator" class="my-1 border-t border-[#30363d]" />
<!-- Menu item -->
<button
v-else
class="w-full flex items-center gap-2.5 px-3 py-1.5 text-xs text-left transition-colors cursor-pointer"
:class="item.danger
? 'text-[var(--wraith-accent-red)] hover:bg-[var(--wraith-accent-red)]/10'
: 'text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)]'
"
@click="handleClick(item)"
>
<span v-if="item.icon" class="w-4 h-4 flex items-center justify-center shrink-0" v-html="item.icon" />
<span class="flex-1">{{ item.label }}</span>
<span v-if="item.shortcut" class="text-[10px] text-[var(--wraith-text-muted)]">{{ item.shortcut }}</span>
</button>
</template>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref, nextTick } from "vue";
export interface ContextMenuItem {
label?: string;
icon?: string;
shortcut?: string;
danger?: boolean;
separator?: boolean;
action?: () => void;
}
const visible = ref(false);
const position = ref({ x: 0, y: 0 });
const items = ref<ContextMenuItem[]>([]);
const menuRef = ref<HTMLDivElement | null>(null);
function open(event: MouseEvent, menuItems: ContextMenuItem[]): void {
items.value = menuItems;
visible.value = true;
// Position at cursor, adjusting if near viewport edges
nextTick(() => {
const menu = menuRef.value;
if (!menu) {
position.value = { x: event.clientX, y: event.clientY };
return;
}
let x = event.clientX;
let y = event.clientY;
const menuWidth = menu.offsetWidth;
const menuHeight = menu.offsetHeight;
if (x + menuWidth > window.innerWidth) {
x = window.innerWidth - menuWidth - 4;
}
if (y + menuHeight > window.innerHeight) {
y = window.innerHeight - menuHeight - 4;
}
position.value = { x, y };
});
}
function close(): void {
visible.value = false;
}
function handleClick(item: ContextMenuItem): void {
if (item.action) {
item.action();
}
close();
}
defineExpose({ open, close, visible });
</script>

View File

@ -0,0 +1,277 @@
<template>
<Teleport to="body">
<div
v-if="visible"
class="fixed inset-0 z-50 flex items-center justify-center"
@click.self="close"
@keydown.esc="close"
>
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/50" @click="close" />
<!-- Dialog -->
<div class="relative w-full max-w-md bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between px-4 py-3 border-b border-[#30363d]">
<h3 class="text-sm font-semibold text-[var(--wraith-text-primary)]">Import Configuration</h3>
<button
class="text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@click="close"
>
<svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
<path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 1 1 1.06 1.06L9.06 8l3.22 3.22a.749.749 0 1 1-1.06 1.06L8 9.06l-3.22 3.22a.749.749 0 1 1-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z" />
</svg>
</button>
</div>
<!-- Body -->
<div class="px-4 py-4">
<!-- Step 1: File selection -->
<template v-if="step === 'select'">
<p class="text-sm text-[var(--wraith-text-secondary)] mb-4">
Select a MobaXTerm <code class="text-[var(--wraith-accent-blue)]">.mobaconf</code> file to import connections and settings.
</p>
<div
class="border-2 border-dashed border-[#30363d] rounded-lg p-8 text-center hover:border-[var(--wraith-accent-blue)] transition-colors cursor-pointer"
@click="selectFile"
@dragover.prevent
@drop.prevent="handleDrop"
>
<svg class="w-8 h-8 mx-auto text-[var(--wraith-text-muted)] mb-3" viewBox="0 0 16 16" fill="currentColor">
<path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14ZM11.78 4.72a.749.749 0 1 1-1.06 1.06L8.75 3.81V9.5a.75.75 0 0 1-1.5 0V3.81L5.28 5.78a.749.749 0 1 1-1.06-1.06l3.25-3.25a.749.749 0 0 1 1.06 0l3.25 3.25Z" />
</svg>
<p class="text-sm text-[var(--wraith-text-secondary)]">
Click to select or drag and drop
</p>
<p class="text-xs text-[var(--wraith-text-muted)] mt-1">
Supports .mobaconf files
</p>
</div>
<input
ref="fileInput"
type="file"
accept=".mobaconf"
class="hidden"
@change="handleFileSelect"
/>
</template>
<!-- Step 2: Preview -->
<template v-else-if="step === 'preview'">
<div class="space-y-3">
<p class="text-sm text-[var(--wraith-text-primary)] font-medium">
{{ fileName }}
</p>
<div class="grid grid-cols-3 gap-3">
<div class="bg-[#0d1117] rounded-lg p-3 text-center">
<div class="text-lg font-bold text-[var(--wraith-accent-blue)]">{{ preview.connections }}</div>
<div class="text-[10px] text-[var(--wraith-text-muted)] mt-0.5">Connections</div>
</div>
<div class="bg-[#0d1117] rounded-lg p-3 text-center">
<div class="text-lg font-bold text-[var(--wraith-accent-yellow)]">{{ preview.groups }}</div>
<div class="text-[10px] text-[var(--wraith-text-muted)] mt-0.5">Groups</div>
</div>
<div class="bg-[#0d1117] rounded-lg p-3 text-center">
<div class="text-lg font-bold text-[var(--wraith-accent-green)]">{{ preview.hostKeys }}</div>
<div class="text-[10px] text-[var(--wraith-text-muted)] mt-0.5">Host Keys</div>
</div>
</div>
<div v-if="preview.hasTheme" class="flex items-center gap-2 text-xs text-[var(--wraith-text-secondary)] bg-[#0d1117] rounded-lg px-3 py-2">
<svg class="w-3.5 h-3.5 text-[var(--wraith-accent-green)]" viewBox="0 0 16 16" fill="currentColor">
<path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" />
</svg>
Terminal theme included
</div>
<p class="text-xs text-[var(--wraith-text-muted)]">
Passwords are not imported (MobaXTerm uses proprietary encryption).
</p>
<div
v-if="importError"
class="text-sm text-[var(--wraith-accent-red)] bg-[var(--wraith-accent-red)]/10 border border-[var(--wraith-accent-red)]/20 rounded px-3 py-2"
>
{{ importError }}
</div>
</div>
</template>
<!-- Step 3: Complete -->
<template v-else-if="step === 'complete'">
<div class="text-center py-4">
<svg class="w-12 h-12 mx-auto text-[var(--wraith-accent-green)] mb-3" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 16A8 8 0 1 1 8 0a8 8 0 0 1 0 16Zm3.78-9.72a.751.751 0 0 0-.018-1.042.751.751 0 0 0-1.042-.018L6.75 9.19 5.28 7.72a.751.751 0 0 0-1.042.018.751.751 0 0 0-.018 1.042l2 2a.75.75 0 0 0 1.06 0l4.5-4.5Z" />
</svg>
<h4 class="text-sm font-semibold text-[var(--wraith-text-primary)] mb-1">Import Complete</h4>
<p class="text-xs text-[var(--wraith-text-secondary)]">
Successfully imported {{ preview.connections }} connections and {{ preview.groups }} groups.
</p>
</div>
</template>
</div>
<!-- Footer -->
<div class="flex items-center justify-end gap-2 px-4 py-3 border-t border-[#30363d]">
<button
v-if="step !== 'complete'"
class="px-3 py-1.5 text-xs text-[var(--wraith-text-secondary)] hover:text-[var(--wraith-text-primary)] rounded border border-[#30363d] hover:bg-[#30363d] transition-colors cursor-pointer"
@click="close"
>
Cancel
</button>
<button
v-if="step === 'preview'"
:disabled="importing"
class="px-3 py-1.5 text-xs text-white bg-[#238636] hover:bg-[#2ea043] rounded transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
@click="doImport"
>
{{ importing ? "Importing..." : "Import" }}
</button>
<button
v-if="step === 'complete'"
class="px-3 py-1.5 text-xs text-white bg-[#1f6feb] hover:bg-[#388bfd] rounded transition-colors cursor-pointer"
@click="close"
>
Done
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { Call } from "@wailsio/runtime";
import { useConnectionStore } from "@/stores/connection.store";
/** Fully qualified Go method name prefix for WraithApp bindings. */
const APP = "github.com/vstockwell/wraith/internal/app.WraithApp";
type Step = "select" | "preview" | "complete";
const connectionStore = useConnectionStore();
const visible = ref(false);
const step = ref<Step>("select");
const fileName = ref("");
const fileInput = ref<HTMLInputElement | null>(null);
const fileContent = ref("");
const importError = ref<string | null>(null);
const importing = ref(false);
const preview = ref({
connections: 0,
groups: 0,
hostKeys: 0,
hasTheme: false,
});
function open(): void {
visible.value = true;
step.value = "select";
fileName.value = "";
fileContent.value = "";
importError.value = null;
}
function close(): void {
visible.value = false;
}
function selectFile(): void {
fileInput.value?.click();
}
function handleFileSelect(event: Event): void {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
processFile(file);
}
}
function handleDrop(event: DragEvent): void {
const file = event.dataTransfer?.files?.[0];
if (file) {
processFile(file);
}
}
async function processFile(file: File): Promise<void> {
fileName.value = file.name;
const text = await file.text();
fileContent.value = text;
// Client-side preview parse to count items for display
const lines = text.split("\n");
let groups = 0;
let connections = 0;
let hostKeys = 0;
let hasTheme = false;
let inBookmarks = false;
let inHostKeys = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("[Bookmarks")) {
inBookmarks = true;
inHostKeys = false;
continue;
}
if (trimmed === "[SSH_Hostkeys]") {
inBookmarks = false;
inHostKeys = true;
continue;
}
if (trimmed === "[Colors]") {
hasTheme = true;
inBookmarks = false;
inHostKeys = false;
continue;
}
if (trimmed.startsWith("[")) {
inBookmarks = false;
inHostKeys = false;
continue;
}
if (inBookmarks) {
if (trimmed.startsWith("SubRep=") && trimmed !== "SubRep=") {
groups++;
} else if (trimmed.includes("#109#") || trimmed.includes("#91#")) {
connections++;
}
}
if (inHostKeys && trimmed.includes("@") && trimmed.includes("=")) {
hostKeys++;
}
}
preview.value = { connections, groups, hostKeys, hasTheme };
step.value = "preview";
}
async function doImport(): Promise<void> {
if (!fileContent.value) return;
importing.value = true;
importError.value = null;
try {
await Call.ByName(`${APP}.ImportMobaConf`, fileContent.value);
// Refresh connection store so sidebar shows imported connections
await connectionStore.loadAll();
step.value = "complete";
} catch (e) {
importError.value = e instanceof Error ? e.message : String(e) || "Import failed";
} finally {
importing.value = false;
}
}
defineExpose({ open, close, visible });
</script>

View File

@ -60,6 +60,7 @@
<option value="ssh">SSH</option>
<option value="rdp">RDP</option>
</select>
<!-- TODO: Persist via Wails binding SettingsService.SetDefaultProtocol(value) -->
</div>
<!-- Sidebar width -->
@ -75,6 +76,7 @@
step="10"
class="w-full accent-[var(--wraith-accent-blue)]"
/>
<!-- TODO: Persist via Wails binding SettingsService.SetSidebarWidth(value) -->
</div>
</template>
@ -91,6 +93,7 @@
>
<option v-for="theme in themeNames" :key="theme" :value="theme">{{ theme }}</option>
</select>
<!-- TODO: Persist via Wails binding SettingsService.SetTerminalTheme(value) -->
</div>
<!-- Font size -->
@ -103,6 +106,7 @@
max="32"
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
<!-- TODO: Persist via Wails binding SettingsService.SetFontSize(value) -->
</div>
<!-- Scrollback buffer -->
@ -116,6 +120,7 @@
step="100"
class="w-full px-3 py-2 text-sm rounded bg-[#0d1117] border border-[#30363d] text-[var(--wraith-text-primary)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
/>
<!-- TODO: Persist via Wails binding SettingsService.SetScrollbackBuffer(value) -->
</div>
</template>
@ -132,6 +137,7 @@
>
Change Master Password
</button>
<!-- TODO: Open a password change dialog via Wails binding VaultService.ChangeMasterPassword(old, new) -->
</div>
<!-- Export vault -->
@ -151,6 +157,7 @@
Import Vault
</button>
</div>
<!-- TODO: Wails bindings VaultService.Export() / VaultService.Import(data) -->
</div>
</template>
@ -175,7 +182,7 @@
</div>
<div class="flex justify-between py-1.5 border-b border-[#30363d]">
<span class="text-[var(--wraith-text-secondary)]">Runtime</span>
<span class="text-[var(--wraith-text-primary)]">Tauri v2</span>
<span class="text-[var(--wraith-text-primary)]">Wails v3</span>
</div>
<div class="flex justify-between py-1.5">
<span class="text-[var(--wraith-text-secondary)]">Frontend</span>
@ -183,6 +190,54 @@
</div>
</div>
<!-- Check for Updates -->
<div class="pt-2">
<button
class="w-full px-3 py-2 text-xs rounded border transition-colors cursor-pointer flex items-center justify-center gap-2"
:class="updateCheckState === 'found'
? 'border-[#3fb950] text-[#3fb950] hover:bg-[#3fb950]/10'
: 'border-[#30363d] text-[var(--wraith-text-primary)] hover:bg-[#30363d]'"
:disabled="updateCheckState === 'checking' || updateCheckState === 'downloading'"
@click="checkForUpdates"
>
<template v-if="updateCheckState === 'idle'">
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834ZM8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.002 7.002 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.5 5.5 0 0 0 8 2.5Z" />
</svg>
Check for Updates
</template>
<template v-else-if="updateCheckState === 'checking'">
<svg class="w-3.5 h-3.5 animate-spin" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0ZM1.5 8a6.5 6.5 0 1 1 13 0 6.5 6.5 0 0 1-13 0Z" opacity=".25" />
<path d="M8 0a8 8 0 0 1 8 8h-1.5A6.5 6.5 0 0 0 8 1.5V0Z" />
</svg>
Checking...
</template>
<template v-else-if="updateCheckState === 'found'">
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 2a6 6 0 1 0 0 12A6 6 0 0 0 8 2Zm.75 3.5v3.69l1.72-1.72a.75.75 0 1 1 1.06 1.06l-3 3a.75.75 0 0 1-1.06 0l-3-3a.75.75 0 1 1 1.06-1.06l1.72 1.72V5.5a.75.75 0 0 1 1.5 0Z" />
</svg>
v{{ latestVersion }} available click to download
</template>
<template v-else-if="updateCheckState === 'downloading'">
<svg class="w-3.5 h-3.5 animate-spin" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0ZM1.5 8a6.5 6.5 0 1 1 13 0 6.5 6.5 0 0 1-13 0Z" opacity=".25" />
<path d="M8 0a8 8 0 0 1 8 8h-1.5A6.5 6.5 0 0 0 8 1.5V0Z" />
</svg>
Downloading...
</template>
<template v-else-if="updateCheckState === 'up-to-date'">
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z" />
</svg>
You're up to date
</template>
<template v-else-if="updateCheckState === 'error'">
Could not check for updates
</template>
</button>
</div>
<div class="flex gap-2 pt-2">
<a
href="#"
@ -199,6 +254,7 @@
Source Code
</a>
</div>
<!-- TODO: Wails binding runtime.BrowserOpenURL(url) -->
</div>
</template>
</div>
@ -220,14 +276,15 @@
<script setup lang="ts">
import { ref, watch, onMounted } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { open as shellOpen } from "@tauri-apps/plugin-shell";
import { Call, Browser } from "@wailsio/runtime";
const SETTINGS = "github.com/vstockwell/wraith/internal/settings.SettingsService";
const UPDATER = "github.com/vstockwell/wraith/internal/updater.UpdateService";
type Section = "general" | "terminal" | "vault" | "about";
const visible = ref(false);
const activeSection = ref<Section>("general");
const currentVersion = ref("loading...");
const sections = [
{
@ -252,8 +309,7 @@ const sections = [
},
];
// Theme names loaded from backend (populated in loadThemeNames)
const themeNames = ref<string[]>([
const themeNames = [
"Dracula",
"Nord",
"Monokai",
@ -261,7 +317,7 @@ const themeNames = ref<string[]>([
"Solarized Dark",
"Gruvbox Dark",
"MobaXTerm Classic",
]);
];
const settings = ref({
defaultProtocol: "ssh" as "ssh" | "rdp",
@ -271,57 +327,53 @@ const settings = ref({
scrollbackBuffer: 5000,
});
/** Load saved settings from Rust backend on mount. */
/** Load saved settings from Go backend on mount. */
onMounted(async () => {
try {
const [protocol, sidebarW, theme, fontSize, scrollback] = await Promise.all([
invoke<string | null>("get_setting", { key: "default_protocol" }),
invoke<string | null>("get_setting", { key: "sidebar_width" }),
invoke<string | null>("get_setting", { key: "terminal_theme" }),
invoke<string | null>("get_setting", { key: "font_size" }),
invoke<string | null>("get_setting", { key: "scrollback_buffer" }),
Call.ByName(`${SETTINGS}.Get`, "default_protocol") as Promise<string>,
Call.ByName(`${SETTINGS}.Get`, "sidebar_width") as Promise<string>,
Call.ByName(`${SETTINGS}.Get`, "terminal_theme") as Promise<string>,
Call.ByName(`${SETTINGS}.Get`, "font_size") as Promise<string>,
Call.ByName(`${SETTINGS}.Get`, "scrollback_buffer") as Promise<string>,
]);
if (protocol) settings.value.defaultProtocol = protocol as "ssh" | "rdp";
if (sidebarW) settings.value.sidebarWidth = Number(sidebarW);
if (theme) settings.value.terminalTheme = theme;
if (fontSize) settings.value.fontSize = Number(fontSize);
if (scrollback) settings.value.scrollbackBuffer = Number(scrollback);
// Load version from Go backend
const ver = await Call.ByName(`${APP}.GetVersion`) as string;
if (ver) currentVersion.value = ver;
} catch (err) {
console.error("SettingsModal: failed to load settings:", err);
}
// Load theme names from backend for the terminal theme dropdown
try {
const themes = await invoke<Array<{ name: string }>>("list_themes");
if (themes && themes.length > 0) {
themeNames.value = themes.map((t) => t.name);
}
} catch {
// Keep the hardcoded fallback list
console.error("Failed to load settings:", err);
}
});
/** Persist settings changes to Rust backend as they change. */
watch(
() => settings.value.defaultProtocol,
(val) => invoke("set_setting", { key: "default_protocol", value: val }).catch(console.error),
);
watch(
() => settings.value.sidebarWidth,
(val) => invoke("set_setting", { key: "sidebar_width", value: String(val) }).catch(console.error),
);
watch(
() => settings.value.terminalTheme,
(val) => invoke("set_setting", { key: "terminal_theme", value: val }).catch(console.error),
);
watch(
() => settings.value.fontSize,
(val) => invoke("set_setting", { key: "font_size", value: String(val) }).catch(console.error),
);
watch(
() => settings.value.scrollbackBuffer,
(val) => invoke("set_setting", { key: "scrollback_buffer", value: String(val) }).catch(console.error),
);
/** Persist settings changes to Go backend as they change. */
watch(() => settings.value.defaultProtocol, (val) => {
Call.ByName(`${SETTINGS}.Set`, "default_protocol", val).catch(console.error);
});
watch(() => settings.value.sidebarWidth, (val) => {
Call.ByName(`${SETTINGS}.Set`, "sidebar_width", String(val)).catch(console.error);
});
watch(() => settings.value.terminalTheme, (val) => {
Call.ByName(`${SETTINGS}.Set`, "terminal_theme", val).catch(console.error);
});
watch(() => settings.value.fontSize, (val) => {
Call.ByName(`${SETTINGS}.Set`, "font_size", String(val)).catch(console.error);
});
watch(() => settings.value.scrollbackBuffer, (val) => {
Call.ByName(`${SETTINGS}.Set`, "scrollback_buffer", String(val)).catch(console.error);
});
// --- Update check state ---
type UpdateCheckState = "idle" | "checking" | "found" | "downloading" | "up-to-date" | "error";
const updateCheckState = ref<UpdateCheckState>("idle");
const APP = "github.com/vstockwell/wraith/internal/app.WraithApp";
const currentVersion = ref("loading...");
const latestVersion = ref("");
const updateInfo = ref<{ available: boolean; currentVersion: string; latestVersion: string; downloadUrl: string; sha256: string } | null>(null);
function open(): void {
visible.value = true;
@ -343,8 +395,10 @@ async function changeMasterPassword(): Promise<void> {
return;
}
try {
await invoke("unlock", { password: oldPw });
await invoke("create_vault", { password: newPw });
const APP = "github.com/vstockwell/wraith/internal/app.WraithApp";
// Verify old password by unlocking, then create new vault
await Call.ByName(`${APP}.Unlock`, oldPw);
await Call.ByName(`${APP}.CreateVault`, newPw);
alert("Master password changed successfully.");
} catch (err) {
alert(`Failed to change password: ${err}`);
@ -352,20 +406,59 @@ async function changeMasterPassword(): Promise<void> {
}
function exportVault(): void {
// Not implemented yet needs Go method to export encrypted DB
alert("Export vault is not yet available. Your data is stored in %APPDATA%\\Wraith\\wraith.db");
}
function importVault(): void {
// Not implemented yet needs Go method to import encrypted DB
alert("Import vault is not yet available. Copy wraith.db to %APPDATA%\\Wraith\\ to restore.");
}
async function checkForUpdates(): Promise<void> {
if (updateCheckState.value === "checking") return;
if (updateCheckState.value === "found" && updateInfo.value) {
updateCheckState.value = "downloading";
try {
const path = await Call.ByName(`${UPDATER}.DownloadUpdate`, updateInfo.value) as string;
await Call.ByName(`${UPDATER}.ApplyUpdate`, path);
} catch (err) {
console.error("Update failed:", err);
updateCheckState.value = "found";
}
return;
}
updateCheckState.value = "checking";
try {
const info = await Call.ByName(`${UPDATER}.CheckForUpdate`) as {
available: boolean;
currentVersion: string;
latestVersion: string;
downloadUrl: string;
sha256: string;
};
currentVersion.value = info.currentVersion || currentVersion.value;
if (info.available) {
latestVersion.value = info.latestVersion;
updateInfo.value = info;
updateCheckState.value = "found";
} else {
updateCheckState.value = "up-to-date";
}
} catch {
updateCheckState.value = "error";
}
}
function openLink(target: string): void {
const urls: Record<string, string> = {
docs: "https://github.com/wraith/docs",
repo: "https://github.com/wraith",
};
const url = urls[target] ?? target;
shellOpen(url).catch(console.error);
Browser.OpenURL(url).catch(console.error);
}
defineExpose({ open, close, visible });

View File

@ -0,0 +1,140 @@
<template>
<div class="h-6 flex items-center justify-between px-4 bg-[var(--wraith-bg-secondary)] border-t border-[var(--wraith-border)] text-[10px] text-[var(--wraith-text-muted)] shrink-0">
<!-- Left: connection info -->
<div class="flex items-center gap-3">
<template v-if="sessionStore.activeSession">
<span class="flex items-center gap-1">
<span
class="w-1.5 h-1.5 rounded-full"
:class="sessionStore.activeSession.protocol === 'ssh' ? 'bg-[#3fb950]' : 'bg-[#1f6feb]'"
/>
{{ sessionStore.activeSession.protocol.toUpperCase() }}
</span>
<span class="text-[var(--wraith-text-secondary)]">&middot;</span>
<span>{{ connectionInfo }}</span>
</template>
<template v-else>
<span>Ready</span>
</template>
</div>
<!-- Right: terminal info + update notification -->
<div class="flex items-center gap-3">
<!-- Update notification pill -->
<button
v-if="updateAvailable && updateInfo"
class="flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium transition-colors cursor-pointer"
:class="updateState === 'downloading'
? 'bg-[#1f6feb]/30 text-[#58a6ff]'
: 'bg-[#1f6feb]/20 text-[#58a6ff] hover:bg-[#1f6feb]/30'"
:disabled="updateState === 'downloading'"
@click="handleUpdate"
>
<template v-if="updateState === 'idle'">
<svg class="w-3 h-3" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 2a6 6 0 1 0 0 12A6 6 0 0 0 8 2Zm.75 3.5v3.69l1.72-1.72a.75.75 0 1 1 1.06 1.06l-3 3a.75.75 0 0 1-1.06 0l-3-3a.75.75 0 1 1 1.06-1.06l1.72 1.72V5.5a.75.75 0 0 1 1.5 0Z" />
</svg>
v{{ updateInfo.latestVersion }} available &mdash; Update
</template>
<template v-else-if="updateState === 'downloading'">
<svg class="w-3 h-3 animate-spin" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0ZM1.5 8a6.5 6.5 0 1 1 13 0 6.5 6.5 0 0 1-13 0Z" opacity=".25" />
<path d="M8 0a8 8 0 0 1 8 8h-1.5A6.5 6.5 0 0 0 8 1.5V0Z" />
</svg>
Downloading...
</template>
</button>
<button
class="hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
title="Change terminal theme"
@click="emit('open-theme-picker')"
>
Theme: {{ activeThemeName }}
</button>
<span>UTF-8</span>
<span v-if="sessionStore.activeDimensions">
{{ sessionStore.activeDimensions.cols }}&times;{{ sessionStore.activeDimensions.rows }}
</span>
<span v-else>120&times;40</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted } from "vue";
import { Call } from "@wailsio/runtime";
import { useSessionStore } from "@/stores/session.store";
import { useConnectionStore } from "@/stores/connection.store";
const sessionStore = useSessionStore();
const connectionStore = useConnectionStore();
const activeThemeName = ref("Dracula");
const emit = defineEmits<{
(e: "open-theme-picker"): void;
}>();
interface UpdateInfoData {
available: boolean;
currentVersion: string;
latestVersion: string;
downloadUrl: string;
sha256: string;
}
const updateAvailable = ref(false);
const updateInfo = ref<UpdateInfoData | null>(null);
const updateState = ref<"idle" | "downloading">("idle");
const connectionInfo = computed(() => {
const session = sessionStore.activeSession;
if (!session) return "";
const conn = connectionStore.connections.find((c) => c.id === session.connectionId);
if (!conn) return session.name;
return `root@${conn.hostname}:${conn.port}`;
});
/** Check for updates on mount. */
onMounted(async () => {
try {
const info = await Call.ByName(
"github.com/vstockwell/wraith/internal/updater.UpdateService.CheckForUpdate"
) as UpdateInfoData | null;
if (info && info.available) {
updateAvailable.value = true;
updateInfo.value = info;
}
} catch {
// Silent fail update check is non-critical.
}
});
/** Download and apply an update. */
async function handleUpdate(): Promise<void> {
if (!updateInfo.value || updateState.value === "downloading") return;
updateState.value = "downloading";
try {
const path = await Call.ByName(
"github.com/vstockwell/wraith/internal/updater.UpdateService.DownloadUpdate",
updateInfo.value
) as string;
await Call.ByName(
"github.com/vstockwell/wraith/internal/updater.UpdateService.ApplyUpdate",
path
);
} catch (e) {
console.error("Update failed:", e);
updateState.value = "idle";
}
}
function setThemeName(name: string): void {
activeThemeName.value = name;
}
defineExpose({ setThemeName, activeThemeName, updateAvailable, updateInfo });
</script>

View File

@ -24,13 +24,8 @@
</button>
</div>
<!-- Loading state -->
<div v-if="loading" class="py-8 text-center text-sm text-[var(--wraith-text-muted)]">
Loading themes...
</div>
<!-- Theme list -->
<div v-else class="max-h-96 overflow-y-auto py-2">
<div class="max-h-96 overflow-y-auto py-2">
<button
v-for="theme in themes"
:key="theme.name"
@ -72,14 +67,6 @@
~/wraith $
</div>
</button>
<!-- Empty state (no themes loaded) -->
<div
v-if="themes.length === 0"
class="px-4 py-8 text-center text-sm text-[var(--wraith-text-muted)]"
>
No themes available
</div>
</div>
</div>
</div>
@ -87,11 +74,12 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { ref } from "vue";
import { Call } from "@wailsio/runtime";
const SETTINGS = "github.com/vstockwell/wraith/internal/settings.SettingsService";
export interface ThemeDefinition {
id?: number;
name: string;
foreground: string;
background: string;
@ -112,38 +100,77 @@ export interface ThemeDefinition {
brightMagenta: string;
brightCyan: string;
brightWhite: string;
isBuiltin?: boolean;
}
const visible = ref(false);
const activeTheme = ref("Dracula");
const themes = ref<ThemeDefinition[]>([]);
const loading = ref(false);
/** Load themes from the Rust backend. */
async function loadThemes(): Promise<void> {
loading.value = true;
try {
const result = await invoke<ThemeDefinition[]>("list_themes");
themes.value = result || [];
} catch (err) {
console.error("ThemePicker: failed to load themes from backend:", err);
themes.value = [];
} finally {
loading.value = false;
}
}
/** Load saved active theme name from settings on mount. */
onMounted(async () => {
await loadThemes();
try {
const saved = await invoke<string | null>("get_setting", { key: "active_theme" });
if (saved) activeTheme.value = saved;
} catch {
// No saved theme keep default
}
});
// Built-in themes matching the Go theme.BuiltinThemes
const themes: ThemeDefinition[] = [
{
name: "Dracula",
foreground: "#f8f8f2", background: "#282a36", cursor: "#f8f8f2",
black: "#21222c", red: "#ff5555", green: "#50fa7b", yellow: "#f1fa8c",
blue: "#bd93f9", magenta: "#ff79c6", cyan: "#8be9fd", white: "#f8f8f2",
brightBlack: "#6272a4", brightRed: "#ff6e6e", brightGreen: "#69ff94",
brightYellow: "#ffffa5", brightBlue: "#d6acff", brightMagenta: "#ff92df",
brightCyan: "#a4ffff", brightWhite: "#ffffff",
},
{
name: "Nord",
foreground: "#d8dee9", background: "#2e3440", cursor: "#d8dee9",
black: "#3b4252", red: "#bf616a", green: "#a3be8c", yellow: "#ebcb8b",
blue: "#81a1c1", magenta: "#b48ead", cyan: "#88c0d0", white: "#e5e9f0",
brightBlack: "#4c566a", brightRed: "#bf616a", brightGreen: "#a3be8c",
brightYellow: "#ebcb8b", brightBlue: "#81a1c1", brightMagenta: "#b48ead",
brightCyan: "#8fbcbb", brightWhite: "#eceff4",
},
{
name: "Monokai",
foreground: "#f8f8f2", background: "#272822", cursor: "#f8f8f0",
black: "#272822", red: "#f92672", green: "#a6e22e", yellow: "#f4bf75",
blue: "#66d9ef", magenta: "#ae81ff", cyan: "#a1efe4", white: "#f8f8f2",
brightBlack: "#75715e", brightRed: "#f92672", brightGreen: "#a6e22e",
brightYellow: "#f4bf75", brightBlue: "#66d9ef", brightMagenta: "#ae81ff",
brightCyan: "#a1efe4", brightWhite: "#f9f8f5",
},
{
name: "One Dark",
foreground: "#abb2bf", background: "#282c34", cursor: "#528bff",
black: "#282c34", red: "#e06c75", green: "#98c379", yellow: "#e5c07b",
blue: "#61afef", magenta: "#c678dd", cyan: "#56b6c2", white: "#abb2bf",
brightBlack: "#545862", brightRed: "#e06c75", brightGreen: "#98c379",
brightYellow: "#e5c07b", brightBlue: "#61afef", brightMagenta: "#c678dd",
brightCyan: "#56b6c2", brightWhite: "#c8ccd4",
},
{
name: "Solarized Dark",
foreground: "#839496", background: "#002b36", cursor: "#839496",
black: "#073642", red: "#dc322f", green: "#859900", yellow: "#b58900",
blue: "#268bd2", magenta: "#d33682", cyan: "#2aa198", white: "#eee8d5",
brightBlack: "#002b36", brightRed: "#cb4b16", brightGreen: "#586e75",
brightYellow: "#657b83", brightBlue: "#839496", brightMagenta: "#6c71c4",
brightCyan: "#93a1a1", brightWhite: "#fdf6e3",
},
{
name: "Gruvbox Dark",
foreground: "#ebdbb2", background: "#282828", cursor: "#ebdbb2",
black: "#282828", red: "#cc241d", green: "#98971a", yellow: "#d79921",
blue: "#458588", magenta: "#b16286", cyan: "#689d6a", white: "#a89984",
brightBlack: "#928374", brightRed: "#fb4934", brightGreen: "#b8bb26",
brightYellow: "#fabd2f", brightBlue: "#83a598", brightMagenta: "#d3869b",
brightCyan: "#8ec07c", brightWhite: "#ebdbb2",
},
{
name: "MobaXTerm Classic",
foreground: "#ececec", background: "#242424", cursor: "#b4b4c0",
black: "#000000", red: "#aa4244", green: "#7e8d53", yellow: "#e4b46d",
blue: "#6e9aba", magenta: "#9e5085", cyan: "#80d5cf", white: "#cccccc",
brightBlack: "#808080", brightRed: "#cc7b7d", brightGreen: "#a5b17c",
brightYellow: "#ecc995", brightBlue: "#96b6cd", brightMagenta: "#c083ac",
brightCyan: "#a9e2de", brightWhite: "#cccccc",
},
];
function themeColors(theme: ThemeDefinition): string[] {
return [
@ -161,7 +188,7 @@ const emit = defineEmits<{
function selectTheme(theme: ThemeDefinition): void {
activeTheme.value = theme.name;
emit("select", theme);
invoke("set_setting", { key: "active_theme", value: theme.name }).catch(console.error);
Call.ByName(`${SETTINGS}.Set`, "active_theme", theme.name).catch(console.error);
}
function open(): void {

View File

@ -157,7 +157,7 @@
:value="cred.id"
>
{{ cred.name }}
<template v-if="cred.credentialType === 'ssh_key'"> (SSH Key)</template>
<template v-if="cred.type === 'ssh_key'"> (SSH Key)</template>
<template v-else> (Password{{ cred.username ? ` ${cred.username}` : '' }})</template>
</option>
</select>
@ -332,15 +332,23 @@
<script setup lang="ts">
import { ref, computed } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { useConnectionStore, type Connection } from "@/stores/connection.store";
import { Call } from "@wailsio/runtime";
/** Fully qualified Go method name prefix for ConnectionService bindings. */
const CONN = "github.com/vstockwell/wraith/internal/connections.ConnectionService";
/**
* Credential methods are proxied through WraithApp (registered at startup)
* because CredentialService is nil until vault unlock and can't be pre-registered.
*/
const APP = "github.com/vstockwell/wraith/internal/app.WraithApp";
interface Credential {
id: number;
name: string;
username: string | null;
domain: string | null;
credentialType: "password" | "ssh_key";
username: string;
domain: string;
type: "password" | "ssh_key";
sshKeyId: number | null;
}
@ -435,11 +443,11 @@ function resetNewCredForm(): void {
async function deleteSelectedCredential(): Promise<void> {
if (!form.value.credentialId) return;
const cred = credentials.value.find((c) => c.id === form.value.credentialId);
const cred = credentials.value.find(c => c.id === form.value.credentialId);
const name = cred?.name ?? `ID ${form.value.credentialId}`;
if (!confirm(`Delete credential "${name}"? This cannot be undone.`)) return;
try {
await invoke("delete_credential", { id: form.value.credentialId });
await Call.ByName(`${APP}.DeleteCredential`, form.value.credentialId);
form.value.credentialId = null;
await loadCredentials();
} catch (err) {
@ -449,9 +457,9 @@ async function deleteSelectedCredential(): Promise<void> {
async function loadCredentials(): Promise<void> {
try {
const result = await invoke<Credential[]>("list_credentials");
const result = await Call.ByName(`${APP}.ListCredentials`) as Credential[];
credentials.value = result || [];
} catch {
} catch (err) {
credentials.value = [];
}
}
@ -465,20 +473,22 @@ async function saveNewCredential(): Promise<void> {
let created: Credential | null = null;
if (newCredType.value === "password") {
created = await invoke<Credential>("create_password", {
name: newCred.value.name.trim(),
username: newCred.value.username.trim(),
password: newCred.value.password,
domain: null,
});
created = await Call.ByName(
`${APP}.CreatePassword`,
newCred.value.name.trim(),
newCred.value.username.trim(),
newCred.value.password,
"", // domain not collected in this form
) as Credential;
} else {
// SSH Key
created = await invoke<Credential>("create_ssh_key", {
name: newCred.value.name.trim(),
username: newCred.value.username.trim(),
privateKeyPem: newCred.value.privateKeyPEM.trim(),
passphrase: newCred.value.passphrase || null,
});
// SSH Key: CreateSSHKeyCredential(name, username, privateKeyPEM string, passphrase string)
created = await Call.ByName(
`${APP}.CreateSSHKeyCredential`,
newCred.value.name.trim(),
newCred.value.username.trim(),
newCred.value.privateKeyPEM.trim(),
newCred.value.passphrase,
) as Credential;
}
// Refresh list and auto-select the new credential
@ -551,32 +561,27 @@ async function save(): Promise<void> {
try {
if (isEditing.value && editingId.value !== null) {
await invoke("update_connection", {
id: editingId.value,
input: {
await Call.ByName(`${CONN}.UpdateConnection`, editingId.value, {
name: form.value.name,
hostname: form.value.hostname,
port: form.value.port,
groupId: form.value.groupId,
credentialId: form.value.credentialId,
color: form.value.color || null,
color: form.value.color,
tags,
notes: form.value.notes || null,
},
notes: form.value.notes,
});
} else {
await invoke("create_connection", {
input: {
await Call.ByName(`${CONN}.CreateConnection`, {
name: form.value.name,
hostname: form.value.hostname,
port: form.value.port,
protocol: form.value.protocol,
groupId: form.value.groupId,
credentialId: form.value.credentialId,
color: form.value.color || null,
color: form.value.color,
tags,
notes: form.value.notes || null,
},
notes: form.value.notes,
});
}
// Refresh connections from backend

View File

@ -0,0 +1,117 @@
<template>
<div
class="flex mb-3"
:class="message.role === 'user' ? 'justify-end' : 'justify-start'"
>
<div
class="max-w-[90%] rounded-lg px-3 py-2 text-sm bg-[#161b22] border-l-2"
:class="message.role === 'user' ? 'border-[#58a6ff]' : 'border-[#3fb950]'"
>
<!-- Rendered content -->
<div
v-if="message.role === 'assistant'"
class="copilot-md text-[#e0e0e0] leading-relaxed break-words"
v-html="renderedContent"
/>
<div v-else class="text-[#e0e0e0] leading-relaxed break-words whitespace-pre-wrap">
{{ message.content }}
</div>
<!-- Tool call visualizations (assistant only) -->
<CopilotToolViz
v-for="tc in message.toolCalls"
:key="tc.id"
:tool-call="tc"
/>
<!-- Streaming cursor -->
<span
v-if="isStreaming && message.role === 'assistant'"
class="inline-block w-2 h-4 bg-[#3fb950] align-middle animate-pulse ml-0.5"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import type { Message } from "@/stores/copilot.store";
import CopilotToolViz from "./CopilotToolViz.vue";
const props = defineProps<{
message: Message;
isStreaming?: boolean;
}>();
/**
* Simple regex-based markdown renderer.
* Handles: bold, inline code, code blocks, list items, newlines.
* No external library needed.
*/
const renderedContent = computed(() => {
let text = props.message.content;
// Escape HTML
text = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
// Code blocks (```...```)
text = text.replace(
/```(\w*)\n?([\s\S]*?)```/g,
'<pre class="bg-[#0d1117] rounded p-2 my-1.5 overflow-x-auto text-xs font-mono"><code>$2</code></pre>',
);
// Inline code (`...`)
text = text.replace(
/`([^`]+)`/g,
'<code class="bg-[#0d1117] text-[#79c0ff] px-1 py-0.5 rounded text-xs font-mono">$1</code>',
);
// Bold (**...**)
text = text.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
// Italic (*...*)
text = text.replace(/\*(.+?)\*/g, "<em>$1</em>");
// List items (- item)
text = text.replace(/^- (.+)$/gm, '<li class="ml-3 list-disc">$1</li>');
// Wrap consecutive <li> in <ul>
text = text.replace(
/(<li[^>]*>.*?<\/li>\n?)+/g,
'<ul class="my-1">$&</ul>',
);
// Newlines to <br> (except inside <pre>)
text = text.replace(/\n(?!<\/?pre|<\/?ul|<\/?li)/g, "<br>");
return text;
});
</script>
<style scoped>
/* Markdown styling within copilot messages */
.copilot-md :deep(strong) {
color: #e0e0e0;
font-weight: 600;
}
.copilot-md :deep(em) {
color: #8b949e;
font-style: italic;
}
.copilot-md :deep(code) {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.copilot-md :deep(pre) {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
.copilot-md :deep(ul) {
padding-left: 0.5rem;
}
</style>

View File

@ -0,0 +1,249 @@
<template>
<div
class="flex flex-col h-full bg-[#0d1117] border-l border-[#30363d] overflow-hidden"
:style="{ width: panelWidth + 'px' }"
>
<!-- Header -->
<div class="flex items-center justify-between px-3 py-2 bg-[#161b22] border-b border-[#30363d] shrink-0">
<div class="flex items-center gap-2">
<!-- Ghost icon -->
<span class="text-base" title="XO AI Copilot">{{ ghostIcon }}</span>
<span class="text-sm font-bold text-[#58a6ff]">XO</span>
<span class="text-[10px] text-[#484f58]">{{ modelShort }}</span>
</div>
<div class="flex items-center gap-2">
<!-- Token counter -->
<span class="text-[10px] text-[#484f58]">
{{ formatTokens(store.tokenUsage.input) }} / {{ formatTokens(store.tokenUsage.output) }}
</span>
<!-- Settings button -->
<button
class="text-[#8b949e] hover:text-[#e0e0e0] transition-colors cursor-pointer"
title="Settings"
@click="store.showSettings = true"
>
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path fill-rule="evenodd" d="M7.429 1.525a6.593 6.593 0 011.142 0c.036.003.108.036.137.146l.289 1.105c.147.56.55.967.997 1.189.174.086.341.183.501.29.417.278.97.423 1.53.27l1.102-.303c.11-.03.175.016.195.046.219.31.41.641.573.989.014.031.022.11-.059.19l-.815.806c-.411.406-.562.957-.53 1.456a4.588 4.588 0 010 .582c-.032.499.119 1.05.53 1.456l.815.806c.08.08.073.159.059.19a6.494 6.494 0 01-.573.99c-.02.029-.086.074-.195.045l-1.103-.303c-.559-.153-1.112-.008-1.529.27-.16.107-.327.204-.5.29-.449.222-.851.628-.998 1.189l-.289 1.105c-.029.11-.101.143-.137.146a6.613 6.613 0 01-1.142 0c-.036-.003-.108-.037-.137-.146l-.289-1.105c-.147-.56-.55-.967-.997-1.189a4.502 4.502 0 01-.501-.29c-.417-.278-.97-.423-1.53-.27l-1.102.303c-.11.03-.175-.016-.195-.046a6.492 6.492 0 01-.573-.989c-.014-.031-.022-.11.059-.19l.815-.806c.411-.406.562-.957.53-1.456a4.587 4.587 0 010-.582c.032-.499-.119-1.05-.53-1.456l-.815-.806c-.08-.08-.073-.159-.059-.19a6.44 6.44 0 01.573-.99c.02-.029.086-.074.195-.045l1.103.303c.559.153 1.112.008 1.529-.27.16-.107.327-.204.5-.29.449-.222.851-.628.998-1.189l.289-1.105c.029-.11.101-.143.137-.146zM8 0c-.236 0-.47.01-.701.03-.743.065-1.29.615-1.458 1.261l-.29 1.106c-.017.066-.078.158-.211.224a5.994 5.994 0 00-.668.386c-.123.082-.233.117-.3.1L3.27 2.801c-.635-.175-1.322.017-1.768.681A7.963 7.963 0 00.767 4.905c-.324.627-.2 1.358.246 1.8l.816.806c.049.048.098.153.088.313a6.088 6.088 0 000 .352c.01.16-.04.265-.088.313l-.816.806c-.446.443-.57 1.173-.246 1.8a7.96 7.96 0 00.736 1.323c.446.664 1.133.856 1.768.681l1.103-.303c.066-.018.177.018.3.1.22.147.447.28.668.386.133.066.194.158.212.224l.289 1.106c.169.646.715 1.196 1.458 1.26a8.094 8.094 0 001.402 0c.743-.064 1.29-.614 1.458-1.26l.29-1.106c.017-.066.078-.158.211-.224a5.98 5.98 0 00.668-.386c.123-.082.233-.117.3-.1l1.102.302c.635.175 1.322-.017 1.768-.68a7.96 7.96 0 00.736-1.324c.324-.627.2-1.357-.246-1.8l-.816-.805c-.048-.048-.098-.153-.088-.313a6.15 6.15 0 000-.352c-.01-.16.04-.265.088-.313l.816-.806c.446-.443.57-1.173.246-1.8a7.963 7.963 0 00-.736-1.323c-.446-.664-1.133-.856-1.768-.681l-1.103.303c-.066.018-.176-.018-.3-.1a5.99 5.99 0 00-.667-.386c-.133-.066-.194-.158-.212-.224L10.16 1.29C9.99.645 9.444.095 8.701.031A8.094 8.094 0 008 0zm1.5 8a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0zM11 8a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
<!-- Close button -->
<button
class="text-[#8b949e] hover:text-[#e0e0e0] transition-colors cursor-pointer"
title="Close panel (Ctrl+Shift+K)"
@click="store.togglePanel()"
>
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z" />
</svg>
</button>
</div>
</div>
<!-- Unauthenticated state -->
<div
v-if="!store.isAuthenticated"
class="flex-1 flex flex-col items-center justify-center px-6"
>
<span class="text-4xl mb-4">{{ ghostIcon }}</span>
<p class="text-sm text-[#8b949e] text-center mb-4">
Connect to Claude to activate the XO AI copilot.
</p>
<button
class="px-4 py-2 text-sm font-medium text-white bg-[#1f6feb] hover:bg-[#388bfd] rounded transition-colors cursor-pointer"
@click="handleConnectLogin"
>
Connect to Claude
</button>
</div>
<!-- Message list -->
<div
v-else
ref="messageListRef"
class="flex-1 overflow-y-auto px-3 py-3"
>
<!-- Empty state -->
<div
v-if="store.messages.length === 0"
class="flex flex-col items-center justify-center h-full text-center"
>
<span class="text-3xl mb-3 opacity-40">{{ ghostIcon }}</span>
<p class="text-sm text-[#8b949e]">XO standing by.</p>
<p class="text-xs text-[#484f58] mt-1">Send a message to begin.</p>
</div>
<!-- Messages -->
<CopilotMessage
v-for="msg in store.messages"
:key="msg.id"
:message="msg"
:is-streaming="store.isStreaming && msg.id === lastAssistantId"
/>
</div>
<!-- Input area (authenticated only) -->
<div
v-if="store.isAuthenticated"
class="shrink-0 border-t border-[#30363d] p-3 bg-[#161b22]"
>
<div class="flex gap-2">
<textarea
ref="inputRef"
v-model="inputText"
:disabled="store.isStreaming"
placeholder="Message the XO..."
rows="1"
class="flex-1 resize-none px-2.5 py-1.5 text-sm rounded bg-[#161b22] border border-[#30363d] text-[#e0e0e0] placeholder-[#484f58] outline-none focus:border-[#58a6ff] transition-colors disabled:opacity-50"
@keydown="handleInputKeydown"
@input="autoResize"
/>
<button
class="shrink-0 px-2.5 py-1.5 rounded transition-colors cursor-pointer"
:class="
canSend
? 'bg-[#1f6feb] hover:bg-[#388bfd] text-white'
: 'bg-[#21262d] text-[#484f58] cursor-not-allowed'
"
:disabled="!canSend"
title="Send message"
@click="handleSend"
>
<svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
<path d="M.989 8 .064 2.68a1.342 1.342 0 0 1 1.85-1.462l13.402 5.744a1.13 1.13 0 0 1 0 2.076L1.913 14.782a1.343 1.343 0 0 1-1.85-1.463L.99 8Zm.603-5.108L2.18 7.25h4.57a.75.75 0 0 1 0 1.5H2.18l-.588 4.358L14.233 8 1.592 2.892Z" />
</svg>
</button>
</div>
<!-- Streaming indicator -->
<div v-if="store.isStreaming" class="flex items-center gap-1.5 mt-1.5 text-[10px] text-[#3fb950]">
<span class="w-1.5 h-1.5 rounded-full bg-[#3fb950] animate-pulse" />
XO is responding...
</div>
</div>
<!-- Settings modal -->
<CopilotSettings v-if="store.showSettings" @close="store.showSettings = false" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted } from "vue";
import { useCopilotStore } from "@/stores/copilot.store";
import { useCopilot } from "@/composables/useCopilot";
import CopilotMessage from "./CopilotMessage.vue";
import CopilotSettings from "./CopilotSettings.vue";
const store = useCopilotStore();
const { processMessage, checkAuth, startLogin, getModel } = useCopilot();
// Check auth status and load model on mount
onMounted(async () => {
await checkAuth();
await getModel();
});
const panelWidth = 320;
const inputText = ref("");
const inputRef = ref<HTMLTextAreaElement | null>(null);
const messageListRef = ref<HTMLDivElement | null>(null);
/** Short model name for header display. */
const modelShort = computed(() => {
const m = store.model;
if (m.includes("sonnet")) return "sonnet";
if (m.includes("opus")) return "opus";
if (m.includes("haiku")) return "haiku";
return m;
});
/** Trigger OAuth login from the inline panel button. */
async function handleConnectLogin(): Promise<void> {
await startLogin();
store.isAuthenticated = true;
}
/** Token formatter. */
function formatTokens(n: number): string {
if (n >= 1000) return (n / 1000).toFixed(1) + "K";
return String(n);
}
/** ID of the last assistant message (for streaming cursor). */
const lastAssistantId = computed(() => {
for (let i = store.messages.length - 1; i >= 0; i--) {
if (store.messages[i].role === "assistant") return store.messages[i].id;
}
return null;
});
/** Whether the send button should be active. */
const canSend = computed(() => inputText.value.trim().length > 0 && !store.isStreaming);
/** Send the message. */
async function handleSend(): Promise<void> {
const text = inputText.value.trim();
if (!text || store.isStreaming) return;
inputText.value = "";
resetTextareaHeight();
store.sendMessage(text);
await nextTick();
scrollToBottom();
await processMessage(text);
}
/** Handle keyboard shortcuts in the input area. */
function handleInputKeydown(event: KeyboardEvent): void {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleSend();
}
}
/** Auto-resize textarea based on content. */
function autoResize(): void {
const el = inputRef.value;
if (!el) return;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 120) + "px";
}
/** Reset textarea height back to single line. */
function resetTextareaHeight(): void {
const el = inputRef.value;
if (!el) return;
el.style.height = "auto";
}
/** Scroll the message list to the bottom. */
function scrollToBottom(): void {
const el = messageListRef.value;
if (!el) return;
el.scrollTop = el.scrollHeight;
}
/** Auto-scroll when messages change or streaming content updates. */
watch(
() => store.messages.length,
async () => {
await nextTick();
scrollToBottom();
},
);
watch(
() => {
const msgs = store.messages;
if (msgs.length === 0) return "";
return msgs[msgs.length - 1].content;
},
async () => {
await nextTick();
scrollToBottom();
},
);
// The ghost icon used in multiple places
const ghostIcon = "\uD83D\uDC7B";
</script>

View File

@ -0,0 +1,170 @@
<template>
<!-- Backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
@click.self="emit('close')"
>
<!-- Modal -->
<div class="w-80 bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between px-4 py-3 border-b border-[#30363d]">
<h3 class="text-sm font-semibold text-[#e0e0e0]">XO Settings</h3>
<button
class="text-[#8b949e] hover:text-[#e0e0e0] transition-colors cursor-pointer"
@click="emit('close')"
>
<svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
<path d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z" />
</svg>
</button>
</div>
<!-- Body -->
<div class="px-4 py-4 space-y-4">
<!-- Auth section -->
<div v-if="!store.isAuthenticated">
<button
class="w-full px-3 py-2 text-sm font-medium text-white bg-[#1f6feb] hover:bg-[#388bfd] rounded transition-colors cursor-pointer"
@click="handleLogin"
>
Connect to Claude (OAuth)
</button>
<button
class="w-full mt-2 px-3 py-2 text-sm font-medium text-[#58a6ff] border border-[#30363d] hover:bg-[#1c2128] rounded transition-colors cursor-pointer"
@click="handleImportClaudeCode"
>
Use Claude Code Token
</button>
<div class="mt-3">
<label class="block text-xs text-[#8b949e] mb-1">Or enter API Key</label>
<input
v-model="apiKey"
type="password"
placeholder="sk-ant-..."
class="w-full px-2.5 py-1.5 text-xs rounded bg-[#0d1117] border border-[#30363d] text-[#e0e0e0] placeholder-[#484f58] outline-none focus:border-[#58a6ff] transition-colors"
/>
<button
class="mt-2 w-full px-3 py-1.5 text-xs font-medium text-[#e0e0e0] bg-[#21262d] hover:bg-[#30363d] border border-[#30363d] rounded transition-colors cursor-pointer"
:disabled="!apiKey.trim()"
@click="handleApiKey"
>
Authenticate
</button>
</div>
</div>
<!-- Model selector -->
<div>
<label class="block text-xs text-[#8b949e] mb-1">Model</label>
<select
v-model="store.model"
class="w-full px-2.5 py-1.5 text-xs rounded bg-[#0d1117] border border-[#30363d] text-[#e0e0e0] outline-none focus:border-[#58a6ff] transition-colors cursor-pointer appearance-none"
>
<option value="claude-sonnet-4-5-20250514">claude-sonnet-4-5-20250514</option>
<option value="claude-opus-4-5-20250414">claude-opus-4-5-20250414</option>
<option value="claude-haiku-4-5-20251001">claude-haiku-4-5-20251001</option>
</select>
</div>
<!-- Token usage -->
<div>
<label class="block text-xs text-[#8b949e] mb-1">Token Usage</label>
<div class="flex items-center gap-3 text-xs text-[#e0e0e0]">
<span>
<span class="text-[#8b949e]">In:</span> {{ formatTokens(store.tokenUsage.input) }}
</span>
<span>
<span class="text-[#8b949e]">Out:</span> {{ formatTokens(store.tokenUsage.output) }}
</span>
</div>
</div>
<!-- Actions -->
<div class="space-y-2 pt-2 border-t border-[#30363d]">
<button
class="w-full px-3 py-1.5 text-xs font-medium text-[#e0e0e0] bg-[#21262d] hover:bg-[#30363d] border border-[#30363d] rounded transition-colors cursor-pointer"
@click="handleClearHistory"
>
Clear History
</button>
<button
v-if="store.isAuthenticated"
class="w-full px-3 py-1.5 text-xs font-medium text-[#f85149] bg-[#21262d] hover:bg-[#30363d] border border-[#30363d] rounded transition-colors cursor-pointer"
@click="handleDisconnect"
>
Disconnect
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from "vue";
import { Call } from "@wailsio/runtime";
import { useCopilotStore } from "@/stores/copilot.store";
import { useCopilot } from "@/composables/useCopilot";
const store = useCopilotStore();
const { startLogin, logout, setModel } = useCopilot();
const apiKey = ref("");
const emit = defineEmits<{
(e: "close"): void;
}>();
const APP = "github.com/vstockwell/wraith/internal/app.WraithApp";
async function handleLogin(): Promise<void> {
await startLogin();
// Auth state will be updated when the OAuth callback completes
// and the panel re-checks on next interaction.
store.isAuthenticated = true;
emit("close");
}
async function handleImportClaudeCode(): Promise<void> {
try {
await Call.ByName(`${APP}.ImportClaudeCodeToken`);
store.isAuthenticated = true;
alert("Claude Code token imported successfully.");
emit("close");
} catch (err: any) {
alert(`Failed to import token: ${err?.message ?? err}`);
}
}
function handleApiKey(): void {
if (!apiKey.value.trim()) return;
// TODO: Wails AIService.SetApiKey(apiKey)
store.isAuthenticated = true;
apiKey.value = "";
emit("close");
}
function handleClearHistory(): void {
store.clearHistory();
emit("close");
}
async function handleDisconnect(): Promise<void> {
await logout();
emit("close");
}
function formatTokens(n: number): string {
if (n >= 1000) return (n / 1000).toFixed(1) + "K";
return String(n);
}
// Sync model changes to the Go backend
watch(
() => store.model,
(newModel) => {
setModel(newModel);
},
);
</script>

View File

@ -0,0 +1,130 @@
<template>
<div
class="rounded border overflow-hidden my-2 text-xs"
:class="borderClass"
>
<!-- Tool call header clickable to expand -->
<button
class="w-full flex items-center gap-2 px-2.5 py-1.5 bg-[#21262d] hover:bg-[#282e36] transition-colors cursor-pointer text-left"
@click="expanded = !expanded"
>
<!-- Color bar -->
<span class="w-0.5 h-4 rounded-full shrink-0" :class="barColorClass" />
<!-- Status indicator -->
<span v-if="toolCall.status === 'pending'" class="shrink-0">
<svg class="w-3.5 h-3.5 animate-spin text-[#8b949e]" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" class="opacity-25" />
<path d="M4 12a8 8 0 018-8" stroke="currentColor" stroke-width="3" stroke-linecap="round" class="opacity-75" />
</svg>
</span>
<span v-else-if="toolCall.status === 'done'" class="shrink-0 text-[#3fb950]">
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M13.78 4.22a.75.75 0 010 1.06l-7.25 7.25a.75.75 0 01-1.06 0L2.22 9.28a.75.75 0 011.06-1.06L6 10.94l6.72-6.72a.75.75 0 011.06 0z" />
</svg>
</span>
<span v-else class="shrink-0 text-[#f85149]">
<svg class="w-3.5 h-3.5" viewBox="0 0 16 16" fill="currentColor">
<path d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z" />
</svg>
</span>
<!-- Icon + summary -->
<span class="text-[#e0e0e0] truncate">{{ icon }} {{ summary }}</span>
<!-- Expand chevron -->
<svg
class="w-3 h-3 ml-auto shrink-0 text-[#8b949e] transition-transform"
:class="{ 'rotate-90': expanded }"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M6.22 3.22a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06-1.06L9.94 8 6.22 4.28a.75.75 0 010-1.06z" />
</svg>
</button>
<!-- Expanded details -->
<div v-if="expanded" class="border-t border-[#30363d] bg-[#161b22]">
<div class="px-2.5 py-2">
<div class="text-[10px] text-[#8b949e] mb-1 uppercase tracking-wide">Input</div>
<pre class="text-[11px] text-[#e0e0e0] whitespace-pre-wrap break-all font-mono bg-[#0d1117] rounded p-2">{{ formattedInput }}</pre>
</div>
<div v-if="toolCall.result !== undefined" class="px-2.5 pb-2">
<div class="text-[10px] text-[#8b949e] mb-1 uppercase tracking-wide">Result</div>
<pre class="text-[11px] text-[#e0e0e0] whitespace-pre-wrap break-all font-mono bg-[#0d1117] rounded p-2">{{ formattedResult }}</pre>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue";
import type { ToolCall } from "@/stores/copilot.store";
const props = defineProps<{
toolCall: ToolCall;
}>();
const expanded = ref(false);
/** Border color class based on tool call status. */
const borderClass = computed(() => {
if (props.toolCall.status === "error") return "border-[#f85149]/30";
return "border-[#30363d]";
});
/** Left color bar based on tool type. */
const barColorClass = computed(() => {
const name = props.toolCall.name;
if (name.startsWith("terminal_") || name === "terminal_cwd") return "bg-[#3fb950]";
if (name.startsWith("sftp_")) return "bg-[#e3b341]";
if (name.startsWith("rdp_")) return "bg-[#1f6feb]";
return "bg-[#8b949e]";
});
/** Icon for the tool type. */
const icon = computed(() => {
const name = props.toolCall.name;
const icons: Record<string, string> = {
terminal_write: "\u27E9",
terminal_read: "\u25CE",
terminal_cwd: "\u27E9",
sftp_read: "\uD83D\uDCC4",
sftp_write: "\uD83D\uDCBE",
rdp_screenshot: "\uD83D\uDCF8",
rdp_click: "\uD83D\uDDB1",
rdp_type: "\u2328",
list_sessions: "\u2261",
connect_session: "\u2192",
disconnect_session: "\u2717",
};
return icons[name] ?? "\u2022";
});
/** One-line summary of the tool call. */
const summary = computed(() => {
const name = props.toolCall.name;
const input = props.toolCall.input;
if (name === "terminal_write") return `Typed \`${input.text}\` in session ${input.sessionId}`;
if (name === "terminal_read") return `Read ${input.lines ?? "?"} lines from session ${input.sessionId}`;
if (name === "terminal_cwd") return `Working directory in session ${input.sessionId}`;
if (name === "sftp_read") return `Read ${input.path}`;
if (name === "sftp_write") return `Wrote ${input.path}`;
if (name === "rdp_screenshot") return `Screenshot from session ${input.sessionId}`;
if (name === "rdp_click") return `Clicked at (${input.x}, ${input.y})`;
if (name === "rdp_type") return `Typed \`${input.text}\``;
if (name === "list_sessions") return "List active sessions";
if (name === "connect_session") return `Connect to session ${input.sessionId}`;
if (name === "disconnect_session") return `Disconnect session ${input.sessionId}`;
return name;
});
const formattedInput = computed(() => JSON.stringify(props.toolCall.input, null, 2));
const formattedResult = computed(() =>
props.toolCall.result !== undefined
? JSON.stringify(props.toolCall.result, null, 2)
: "",
);
</script>

View File

@ -1,17 +1,10 @@
<template>
<div
class="flex flex-col border-b border-[var(--wraith-border)] bg-[var(--wraith-bg-secondary)]"
:style="{ height: editorHeight + 'px' }"
>
<div class="flex flex-col border-b border-[var(--wraith-border)] bg-[var(--wraith-bg-secondary)]" :style="{ height: editorHeight + 'px' }">
<!-- Editor toolbar -->
<div class="flex items-center justify-between px-3 py-1.5 border-b border-[var(--wraith-border)] shrink-0">
<div class="flex items-center gap-2 text-xs min-w-0">
<!-- File icon -->
<svg
class="w-3.5 h-3.5 text-[var(--wraith-text-muted)] shrink-0"
viewBox="0 0 16 16"
fill="currentColor"
>
<svg class="w-3.5 h-3.5 text-[var(--wraith-text-muted)] shrink-0" viewBox="0 0 16 16" fill="currentColor">
<path d="M3.75 1.5a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25V6H9.75A1.75 1.75 0 0 1 8 4.25V1.5H3.75zm5.75.56v2.19c0 .138.112.25.25.25h2.19L9.5 2.06zM2 1.75C2 .784 2.784 0 3.75 0h5.086c.464 0 .909.184 1.237.513l3.414 3.414c.329.328.513.773.513 1.237v8.086A1.75 1.75 0 0 1 12.25 15h-8.5A1.75 1.75 0 0 1 2 13.25V1.75z" />
</svg>
@ -20,11 +13,7 @@
</span>
<!-- Unsaved indicator -->
<span
v-if="hasUnsavedChanges"
class="w-2 h-2 rounded-full bg-[var(--wraith-accent-yellow)] shrink-0"
title="Unsaved changes"
/>
<span v-if="hasUnsavedChanges" class="w-2 h-2 rounded-full bg-[var(--wraith-accent-yellow)] shrink-0" title="Unsaved changes" />
</div>
<div class="flex items-center gap-2 shrink-0">
@ -45,7 +34,6 @@
<!-- Close button -->
<button
class="text-[var(--wraith-text-muted)] hover:text-[var(--wraith-accent-red)] transition-colors cursor-pointer"
title="Close editor"
@click="emit('close')"
>
<svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
@ -62,46 +50,33 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from "vue";
import { invoke } from "@tauri-apps/api/core";
import {
EditorView,
keymap,
lineNumbers,
highlightActiveLine,
highlightSpecialChars,
} from "@codemirror/view";
import { Call } from "@wailsio/runtime";
const SFTP = "github.com/vstockwell/wraith/internal/sftp.SFTPService";
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightSpecialChars } from "@codemirror/view";
import { EditorState } from "@codemirror/state";
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import {
syntaxHighlighting,
defaultHighlightStyle,
bracketMatching,
} from "@codemirror/language";
import { syntaxHighlighting, defaultHighlightStyle, bracketMatching } from "@codemirror/language";
import { oneDark } from "@codemirror/theme-one-dark";
import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete";
const props = defineProps<{
/** Initial file content to display. */
content: string;
/** Remote path of the file being edited. */
filePath: string;
/** Active SSH session ID — used for the SFTP write call. */
sessionId: string;
}>();
const emit = defineEmits<{
/** Emitted when the user clicks the close button. */
close: [];
}>();
const editorRef = ref<HTMLElement | null>(null);
const hasUnsavedChanges = ref(false);
/** Fixed height for the inline editor panel — sits above the terminal. */
const editorHeight = ref(300);
let view: EditorView | null = null;
/** Lazily load a language extension based on the file extension. */
/** Detect language extension from file path. */
async function getLanguageExtension(path: string) {
const ext = path.split(".").pop()?.toLowerCase() ?? "";
switch (ext) {
@ -130,8 +105,9 @@ async function getLanguageExtension(path: string) {
}
}
/** Build the full extension list for the editor (used on mount and on file change). */
async function buildExtensions() {
onMounted(async () => {
if (!editorRef.value) return;
const langExt = await getLanguageExtension(props.filePath);
const extensions = [
@ -169,15 +145,15 @@ async function buildExtensions() {
extensions.push(langExt);
}
return extensions;
}
const state = EditorState.create({
doc: props.content,
extensions,
});
onMounted(async () => {
if (!editorRef.value) return;
const extensions = await buildExtensions();
const state = EditorState.create({ doc: props.content, extensions });
view = new EditorView({ state, parent: editorRef.value });
view = new EditorView({
state,
parent: editorRef.value,
});
});
onBeforeUnmount(() => {
@ -187,40 +163,69 @@ onBeforeUnmount(() => {
}
});
/**
* Re-create the editor when the file changes (new file opened in same panel).
* Reset unsaved-changes indicator on each new file load.
*/
// Re-create editor when content/filePath changes
watch(
() => props.filePath,
async () => {
if (!editorRef.value) return;
// Destroy the old view before re-creating
if (view) {
view.destroy();
view = null;
}
if (!view || !editorRef.value) return;
hasUnsavedChanges.value = false;
const extensions = await buildExtensions();
const state = EditorState.create({ doc: props.content, extensions });
view = new EditorView({ state, parent: editorRef.value });
const langExt = await getLanguageExtension(props.filePath);
const extensions = [
lineNumbers(),
highlightActiveLine(),
highlightSpecialChars(),
history(),
bracketMatching(),
closeBrackets(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
oneDark,
keymap.of([
...defaultKeymap,
...historyKeymap,
...closeBracketsKeymap,
]),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
hasUnsavedChanges.value = true;
}
}),
EditorView.theme({
"&": {
height: "100%",
fontSize: "13px",
},
".cm-scroller": {
overflow: "auto",
fontFamily: "'JetBrains Mono', 'Cascadia Code', 'Fira Code', Menlo, Monaco, monospace",
},
}),
];
if (langExt) {
extensions.push(langExt);
}
view.destroy();
const state = EditorState.create({
doc: props.content,
extensions,
});
view = new EditorView({
state,
parent: editorRef.value,
});
},
);
/** Save the current editor content to the remote file via Tauri SFTP. */
async function handleSave(): Promise<void> {
if (!view || !hasUnsavedChanges.value) return;
const currentContent = view.state.doc.toString();
try {
await invoke("sftp_write_file", {
sessionId: props.sessionId,
path: props.filePath,
content: currentContent,
});
await Call.ByName(`${SFTP}.WriteFile`, props.sessionId, props.filePath, currentContent);
hasUnsavedChanges.value = false;
} catch (err) {
console.error("Failed to save file:", err);

View File

@ -1,5 +1,46 @@
<template>
<div class="rdp-container" ref="containerRef">
<!-- Toolbar -->
<div class="rdp-toolbar">
<div class="rdp-toolbar-left">
<span class="rdp-toolbar-label">RDP</span>
<span class="rdp-toolbar-session">{{ sessionId }}</span>
</div>
<div class="rdp-toolbar-right">
<button
class="rdp-toolbar-btn"
:class="{ active: keyboardGrabbed }"
title="Toggle keyboard capture"
@click="handleToggleKeyboard"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M1 4a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1V4zm1 0v7h12V4H2z"/>
<path d="M3 6h2v1H3V6zm3 0h2v1H6V6zm3 0h2v1H9V6zm3 0h1v1h-1V6zM3 8h1v1H3V8zm2 0h6v1H5V8zm7 0h1v1h-1V8z"/>
</svg>
</button>
<button
class="rdp-toolbar-btn"
:class="{ active: clipboardSync }"
title="Toggle clipboard sync"
@click="handleToggleClipboard"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3z"/>
</svg>
</button>
<button
class="rdp-toolbar-btn"
title="Fullscreen"
@click="handleFullscreen"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M1.5 1a.5.5 0 0 0-.5.5v4a.5.5 0 0 1-1 0v-4A1.5 1.5 0 0 1 1.5 0h4a.5.5 0 0 1 0 1h-4zM10 .5a.5.5 0 0 1 .5-.5h4A1.5 1.5 0 0 1 16 1.5v4a.5.5 0 0 1-1 0v-4a.5.5 0 0 0-.5-.5h-4a.5.5 0 0 1-.5-.5zM.5 10a.5.5 0 0 1 .5.5v4a.5.5 0 0 0 .5.5h4a.5.5 0 0 1 0 1h-4A1.5 1.5 0 0 1 0 14.5v-4a.5.5 0 0 1 .5-.5zm15 0a.5.5 0 0 1 .5.5v4a1.5 1.5 0 0 1-1.5 1.5h-4a.5.5 0 0 1 0-1h4a.5.5 0 0 0 .5-.5v-4a.5.5 0 0 1 .5-.5z"/>
</svg>
</button>
</div>
</div>
<!-- Canvas -->
<div class="rdp-canvas-wrapper" ref="canvasWrapper">
<canvas
@ -16,7 +57,7 @@
/>
</div>
<!-- Connection status overlay visible until first frame arrives -->
<!-- Connection status overlay -->
<div v-if="!connected" class="rdp-overlay">
<div class="rdp-overlay-content">
<div class="rdp-spinner" />
@ -57,21 +98,13 @@ const {
toggleClipboardSync,
} = useRdp();
// Expose toolbar state and controls so RdpToolbar can be a sibling component
// driven by the same composable instance via the parent (SessionContainer).
defineExpose({
keyboardGrabbed,
clipboardSync,
toggleKeyboardGrab,
toggleClipboardSync,
canvasWrapper,
});
/**
* Convert canvas-relative mouse coordinates to RDP coordinates,
* accounting for CSS scaling of the canvas element.
* accounting for CSS scaling of the canvas.
*/
function toRdpCoords(e: MouseEvent): { x: number; y: number } | null {
function toRdpCoords(
e: MouseEvent,
): { x: number; y: number } | null {
const canvas = canvasRef.value;
if (!canvas) return null;
@ -102,7 +135,12 @@ function handleMouseDown(e: MouseEvent): void {
break;
}
sendMouse(props.sessionId, coords.x, coords.y, buttonFlag | MouseFlag.Down);
sendMouse(
props.sessionId,
coords.x,
coords.y,
buttonFlag | MouseFlag.Down,
);
}
function handleMouseUp(e: MouseEvent): void {
@ -153,9 +191,37 @@ function handleKeyUp(e: KeyboardEvent): void {
sendKey(props.sessionId, e.code, false);
}
function handleToggleKeyboard(): void {
toggleKeyboardGrab();
// Focus canvas when grabbing keyboard
if (keyboardGrabbed.value && canvasRef.value) {
canvasRef.value.focus();
}
}
function handleToggleClipboard(): void {
toggleClipboardSync();
}
function handleFullscreen(): void {
const wrapper = canvasWrapper.value;
if (!wrapper) return;
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
wrapper.requestFullscreen();
}
}
onMounted(() => {
if (canvasRef.value) {
startFrameLoop(props.sessionId, canvasRef.value, rdpWidth, rdpHeight);
startFrameLoop(
props.sessionId,
canvasRef.value,
rdpWidth,
rdpHeight,
);
}
});
@ -163,7 +229,7 @@ onBeforeUnmount(() => {
stopFrameLoop();
});
// Focus canvas when this tab becomes active and keyboard is grabbed
// Focus canvas when this tab becomes active
watch(
() => props.isActive,
(active) => {
@ -186,6 +252,70 @@ watch(
position: relative;
}
.rdp-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
height: 32px;
padding: 0 8px;
background: var(--wraith-bg-secondary, #161b22);
border-bottom: 1px solid var(--wraith-border, #30363d);
flex-shrink: 0;
}
.rdp-toolbar-left {
display: flex;
align-items: center;
gap: 8px;
}
.rdp-toolbar-label {
font-size: 11px;
font-weight: 600;
color: var(--wraith-accent, #58a6ff);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.rdp-toolbar-session {
font-size: 11px;
color: var(--wraith-text-muted, #484f58);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rdp-toolbar-right {
display: flex;
align-items: center;
gap: 2px;
}
.rdp-toolbar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 24px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--wraith-text-secondary, #8b949e);
cursor: pointer;
transition: all 0.15s ease;
}
.rdp-toolbar-btn:hover {
background: var(--wraith-bg-tertiary, #21262d);
color: var(--wraith-text-primary, #e6edf3);
}
.rdp-toolbar-btn.active {
background: var(--wraith-accent, #58a6ff);
color: #ffffff;
}
.rdp-canvas-wrapper {
flex: 1;
display: flex;

View File

@ -0,0 +1,61 @@
<template>
<div class="flex-1 flex flex-col bg-[var(--wraith-bg-primary)] min-h-0 relative">
<!-- Terminal views v-show keeps them alive across tab switches -->
<div
v-for="session in sshSessions"
:key="session.id"
v-show="session.id === sessionStore.activeSessionId"
class="absolute inset-0"
>
<TerminalView
:session-id="session.id"
:is-active="session.id === sessionStore.activeSessionId"
/>
</div>
<!-- RDP views v-show keeps them alive across tab switches -->
<div
v-for="session in rdpSessions"
:key="session.id"
v-show="session.id === sessionStore.activeSessionId"
class="absolute inset-0"
>
<RdpView
:session-id="session.id"
:is-active="session.id === sessionStore.activeSessionId"
/>
</div>
<!-- No session placeholder -->
<div
v-if="!sessionStore.activeSession"
class="flex-1 flex items-center justify-center"
>
<div class="text-center">
<p class="text-[var(--wraith-text-muted)] text-sm">
No active session
</p>
<p class="text-[var(--wraith-text-muted)] text-xs mt-1">
Double-click a connection to start a session
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useSessionStore } from "@/stores/session.store";
import TerminalView from "@/components/terminal/TerminalView.vue";
import RdpView from "@/components/rdp/RdpView.vue";
const sessionStore = useSessionStore();
const sshSessions = computed(() =>
sessionStore.sessions.filter((s) => s.protocol === "ssh"),
);
const rdpSessions = computed(() =>
sessionStore.sessions.filter((s) => s.protocol === "rdp"),
);
</script>

View File

@ -0,0 +1,126 @@
<template>
<div class="flex items-center bg-[var(--wraith-bg-secondary)] border-b border-[var(--wraith-border)] h-9 shrink-0">
<!-- Tabs -->
<div class="flex items-center overflow-x-auto min-w-0">
<button
v-for="session in sessionStore.sessions"
:key="session.id"
class="group flex items-center gap-2 px-3 h-9 text-xs whitespace-nowrap border-r border-[var(--wraith-border)] transition-all duration-500 cursor-pointer shrink-0"
:class="[
session.id === sessionStore.activeSessionId
? 'bg-[var(--wraith-bg-primary)] text-[var(--wraith-text-primary)] border-b-2 border-b-[var(--wraith-accent-blue)]'
: 'text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-secondary)] hover:bg-[var(--wraith-bg-tertiary)]',
isRootUser(session) ? 'border-t-2 border-t-[#f8514966]' : '',
]"
@click="sessionStore.activateSession(session.id)"
>
<!-- Protocol icon -->
<span class="shrink-0">
<!-- SSH terminal icon -->
<svg
v-if="session.protocol === 'ssh'"
class="w-3.5 h-3.5 text-[#3fb950]"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v10.5A1.75 1.75 0 0 1 14.25 15H1.75A1.75 1.75 0 0 1 0 13.25Zm1.75-.25a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V2.75a.25.25 0 0 0-.25-.25ZM7.25 8a.749.749 0 0 1-.22.53l-2.25 2.25a.749.749 0 1 1-1.06-1.06L5.44 8 3.72 6.28a.749.749 0 1 1 1.06-1.06l2.25 2.25c.141.14.22.331.22.53Zm1.5 1.5h3a.75.75 0 0 1 0 1.5h-3a.75.75 0 0 1 0-1.5Z" />
</svg>
<!-- RDP monitor icon -->
<svg
v-else
class="w-3.5 h-3.5 text-[#1f6feb]"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M1.75 2.5h12.5a.25.25 0 0 1 .25.25v7.5a.25.25 0 0 1-.25.25H1.75a.25.25 0 0 1-.25-.25v-7.5a.25.25 0 0 1 .25-.25ZM14.25 1H1.75A1.75 1.75 0 0 0 0 2.75v7.5C0 11.216.784 12 1.75 12h4.388l-.533 1.5H4a.75.75 0 0 0 0 1.5h8a.75.75 0 0 0 0-1.5h-1.605l-.533-1.5h4.388A1.75 1.75 0 0 0 16 10.25v-7.5A1.75 1.75 0 0 0 14.25 1ZM9.112 13.5H6.888l.533-1.5h1.158l.533 1.5Z" />
</svg>
</span>
<span>{{ session.name }}</span>
<!-- Environment tag badges -->
<template v-if="getSessionTags(session).length > 0">
<span
v-for="tag in getSessionTags(session)"
:key="tag"
class="px-1 py-0.5 text-[9px] font-semibold rounded leading-none"
:class="tagClass(tag)"
>
{{ tag }}
</span>
</template>
<!-- Close button -->
<span
class="ml-1 opacity-0 group-hover:opacity-100 hover:text-[var(--wraith-accent-red)] transition-opacity"
@click.stop="sessionStore.closeSession(session.id)"
>
&times;
</span>
</button>
</div>
<!-- New tab button -->
<button
class="flex items-center justify-center w-9 h-9 text-[var(--wraith-text-muted)] hover:text-[var(--wraith-text-primary)] hover:bg-[var(--wraith-bg-tertiary)] transition-colors cursor-pointer shrink-0"
title="New session"
>
+
</button>
</div>
</template>
<script setup lang="ts">
import { useSessionStore, type Session } from "@/stores/session.store";
import { useConnectionStore } from "@/stores/connection.store";
const sessionStore = useSessionStore();
const connectionStore = useConnectionStore();
/** Get tags for a session's underlying connection. */
function getSessionTags(session: Session): string[] {
const conn = connectionStore.connections.find((c) => c.id === session.connectionId);
return conn?.tags ?? [];
}
/** Check if the connection for this session uses the root user. */
function isRootUser(session: Session): boolean {
// Check username stored on the session object (set during connect)
if (session.username === "root") return true;
// Fall back to checking the connection's options JSON for a stored username
const conn = connectionStore.connections.find((c) => c.id === session.connectionId);
if (!conn) return false;
if (conn.options) {
try {
const opts = JSON.parse(conn.options);
if (opts?.username === "root") return true;
} catch {
// ignore malformed options
}
}
// Also check if "root" appears in the connection tags
return conn.tags?.includes("root") ?? false;
}
/** Return Tailwind classes for environment tag badges. */
function tagClass(tag: string): string {
const t = tag.toUpperCase();
if (t === "PROD" || t === "PRODUCTION") {
return "bg-[#da3633]/20 text-[#f85149]";
}
if (t === "DEV" || t === "DEVELOPMENT") {
return "bg-[#238636]/20 text-[#3fb950]";
}
if (t === "STAGING" || t === "STG") {
return "bg-[#9e6a03]/20 text-[#d29922]";
}
if (t === "TEST" || t === "QA") {
return "bg-[#1f6feb]/20 text-[#58a6ff]";
}
// Default for other tags
return "bg-[var(--wraith-bg-tertiary)] text-[var(--wraith-text-muted)]";
}
</script>

View File

@ -152,10 +152,12 @@
<script setup lang="ts">
import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { Call } from "@wailsio/runtime";
import { useSftp, type FileEntry } from "@/composables/useSftp";
import { useTransfers } from "@/composables/useTransfers";
const SFTP = "github.com/vstockwell/wraith/internal/sftp.SFTPService";
const props = defineProps<{
sessionId: string;
}>();
@ -182,7 +184,7 @@ function handleEntryDblClick(entry: FileEntry): void {
}
}
// Download
// S-2: Download
async function handleDownload(): Promise<void> {
if (!selectedEntry.value || selectedEntry.value.isDir) return;
@ -191,10 +193,7 @@ async function handleDownload(): Promise<void> {
const transferId = addTransfer(entry.name, "download");
try {
const content = await invoke<string>("sftp_read_file", {
sessionId: props.sessionId,
path: entry.path,
});
const content = await Call.ByName(`${SFTP}.ReadFile`, props.sessionId, entry.path) as string;
const blob = new Blob([content], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
@ -211,7 +210,7 @@ async function handleDownload(): Promise<void> {
}
}
// Delete
// S-3: Delete
async function handleDelete(): Promise<void> {
if (!selectedEntry.value) return;
@ -220,7 +219,7 @@ async function handleDelete(): Promise<void> {
if (!confirm(`Delete "${entry.name}"?`)) return;
try {
await invoke("sftp_delete", { sessionId: props.sessionId, path: entry.path });
await Call.ByName(`${SFTP}.Delete`, props.sessionId, entry.path);
selectedEntry.value = null;
await refresh();
} catch (err) {
@ -228,7 +227,7 @@ async function handleDelete(): Promise<void> {
}
}
// New Folder
// S-4: New Folder
async function handleNewFolder(): Promise<void> {
const folderName = prompt("New folder name:");
@ -238,14 +237,14 @@ async function handleNewFolder(): Promise<void> {
const fullPath = currentPath.value.replace(/\/$/, "") + "/" + trimmed;
try {
await invoke("sftp_mkdir", { sessionId: props.sessionId, path: fullPath });
await Call.ByName(`${SFTP}.Mkdir`, props.sessionId, fullPath);
await refresh();
} catch (err) {
console.error("SFTP mkdir error:", err);
}
}
// Upload
// S-1: Upload
function handleUpload(): void {
fileInputRef.value?.click();
@ -267,11 +266,7 @@ function handleFileSelected(event: Event): void {
const remotePath = currentPath.value.replace(/\/$/, "") + "/" + file.name;
try {
await invoke("sftp_write_file", {
sessionId: props.sessionId,
path: remotePath,
content,
});
await Call.ByName(`${SFTP}.WriteFile`, props.sessionId, remotePath, content);
completeTransfer(transferId);
await refresh();
} catch (err) {

View File

@ -6,8 +6,8 @@
class="flex-1 flex items-center justify-center gap-1 px-2 py-1 text-[10px] font-medium rounded
bg-[var(--wraith-bg-tertiary)] text-[var(--wraith-text-muted)]
hover:text-[var(--wraith-text-primary)] hover:bg-[var(--wraith-bg-secondary)] transition-colors"
title="New Group"
@click="addGroup"
title="New Group"
>
<svg viewBox="0 0 16 16" fill="currentColor" class="w-3 h-3"><path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"/></svg>
Group
@ -16,8 +16,8 @@
class="flex-1 flex items-center justify-center gap-1 px-2 py-1 text-[10px] font-medium rounded
bg-[var(--wraith-bg-tertiary)] text-[var(--wraith-text-muted)]
hover:text-[var(--wraith-text-primary)] hover:bg-[var(--wraith-bg-secondary)] transition-colors"
title="New Connection"
@click="editDialog?.openNew()"
title="New Connection"
>
<svg viewBox="0 0 16 16" fill="currentColor" class="w-3 h-3"><path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"/></svg>
Host
@ -97,13 +97,15 @@
<script setup lang="ts">
import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { Call } from "@wailsio/runtime";
import { useConnectionStore, type Connection, type Group } from "@/stores/connection.store";
import { useSessionStore } from "@/stores/session.store";
import ContextMenu from "@/components/common/ContextMenu.vue";
import type { ContextMenuItem } from "@/components/common/ContextMenu.vue";
import ConnectionEditDialog from "@/components/connections/ConnectionEditDialog.vue";
const CONN = "github.com/vstockwell/wraith/internal/connections.ConnectionService";
const connectionStore = useConnectionStore();
const sessionStore = useSessionStore();
@ -128,7 +130,7 @@ async function addGroup(): Promise<void> {
const name = prompt("New group name:");
if (!name) return;
try {
await invoke("create_group", { name, parentId: null });
await Call.ByName(`${CONN}.CreateGroup`, name, null);
await connectionStore.loadGroups();
} catch (err) {
console.error("Failed to create group:", err);
@ -187,7 +189,7 @@ function showGroupMenu(event: MouseEvent, group: Group): void {
const newName = prompt("Rename group:", group.name);
if (newName && newName !== group.name) {
try {
await invoke("rename_group", { groupId: group.id, name: newName });
await Call.ByName(`${CONN}.RenameGroup`, group.id, newName);
await connectionStore.loadGroups();
} catch (err) {
console.error("Failed to rename group:", err);
@ -206,10 +208,10 @@ function showGroupMenu(event: MouseEvent, group: Group): void {
contextMenu.value?.open(event, items);
}
/** Duplicate a connection via the Rust backend. */
/** Duplicate a connection via the Go backend. */
async function duplicateConnection(conn: Connection): Promise<void> {
try {
await invoke("create_connection", {
await Call.ByName(`${CONN}.CreateConnection`, {
name: `${conn.name} (copy)`,
hostname: conn.hostname,
port: conn.port,
@ -227,22 +229,22 @@ async function duplicateConnection(conn: Connection): Promise<void> {
}
}
/** Delete a connection via the Rust backend after confirmation. */
/** Delete a connection via the Go backend after confirmation. */
async function deleteConnection(conn: Connection): Promise<void> {
if (!confirm(`Delete "${conn.name}"?`)) return;
try {
await invoke("delete_connection", { connectionId: conn.id });
await Call.ByName(`${CONN}.DeleteConnection`, conn.id);
await connectionStore.loadConnections();
} catch (err) {
console.error("Failed to delete connection:", err);
}
}
/** Delete a group via the Rust backend after confirmation. */
/** Delete a group via the Go backend after confirmation. */
async function deleteGroup(group: Group): Promise<void> {
if (!confirm(`Delete group "${group.name}" and all its connections?`)) return;
try {
await invoke("delete_group", { groupId: group.id });
await Call.ByName(`${CONN}.DeleteGroup`, group.id);
await connectionStore.loadAll();
} catch (err) {
console.error("Failed to delete group:", err);

View File

@ -0,0 +1,89 @@
<template>
<div
ref="containerRef"
class="terminal-container"
@focus="handleFocus"
/>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from "vue";
import { useTerminal } from "@/composables/useTerminal";
import { useSessionStore } from "@/stores/session.store";
import "@/assets/css/terminal.css";
const props = defineProps<{
sessionId: string;
isActive: boolean;
}>();
const sessionStore = useSessionStore();
const containerRef = ref<HTMLElement | null>(null);
const { terminal, mount, fit } = useTerminal(props.sessionId);
onMounted(() => {
if (containerRef.value) {
mount(containerRef.value);
}
// Apply the current theme immediately if one is already active
if (sessionStore.activeTheme) {
applyTheme();
}
// Track terminal dimensions in the session store
terminal.onResize(({ cols, rows }) => {
sessionStore.setTerminalDimensions(props.sessionId, cols, rows);
});
});
// Re-fit and focus terminal when this tab becomes active
watch(
() => props.isActive,
(active) => {
if (active) {
// nextTick is not needed fit and focus happen after the DOM update
setTimeout(() => {
fit();
terminal.focus();
}, 0);
}
},
);
/** Apply the session store's active theme to this terminal instance. */
function applyTheme(): void {
const theme = sessionStore.activeTheme;
if (!theme) return;
terminal.options.theme = {
background: theme.background,
foreground: theme.foreground,
cursor: theme.cursor,
black: theme.black,
red: theme.red,
green: theme.green,
yellow: theme.yellow,
blue: theme.blue,
magenta: theme.magenta,
cyan: theme.cyan,
white: theme.white,
brightBlack: theme.brightBlack,
brightRed: theme.brightRed,
brightGreen: theme.brightGreen,
brightYellow: theme.brightYellow,
brightBlue: theme.brightBlue,
brightMagenta: theme.brightMagenta,
brightCyan: theme.brightCyan,
brightWhite: theme.brightWhite,
};
}
// Watch for theme changes in the session store and apply to this terminal
watch(() => sessionStore.activeTheme, (newTheme) => {
if (newTheme) applyTheme();
});
function handleFocus(): void {
terminal.focus();
}
</script>

View File

@ -0,0 +1,150 @@
import { useCopilotStore } from "@/stores/copilot.store";
import type { ToolCall } from "@/stores/copilot.store";
import { Call } from "@wailsio/runtime";
/**
* Fully qualified Go method name prefix for AIService bindings.
* Wails v3 ByName format: 'package.struct.method'
*/
const AI = "github.com/vstockwell/wraith/internal/ai.AIService";
/** Call a bound Go method on AIService by name. */
async function callAI<T = unknown>(method: string, ...args: unknown[]): Promise<T> {
return Call.ByName(`${AI}.${method}`, ...args) as Promise<T>;
}
/** Shape returned by Go AIService.SendMessage. */
interface ChatResponse {
text: string;
toolCalls?: {
name: string;
input: unknown;
result: unknown;
error?: string;
}[];
}
/**
* Composable providing real Wails binding wrappers for the AI copilot.
*
* Calls the Go AIService via Wails v3 Call.ByName. SendMessage blocks
* until the full response (including tool-use loops) is complete.
*/
export function useCopilot() {
const store = useCopilotStore();
/**
* Process a user message by calling the real Go backend.
* The backend blocks until the full response is ready (no streaming yet).
*/
async function processMessage(text: string): Promise<void> {
store.isStreaming = true;
// Ensure we have a conversation
if (!store.activeConversationId) {
try {
const convId = await callAI<string>("NewConversation");
store.activeConversationId = convId;
} catch (err) {
store.messages.push({
id: `msg-${Date.now()}`,
role: "assistant",
content: `Error creating conversation: ${err}`,
timestamp: Date.now(),
});
store.isStreaming = false;
return;
}
}
try {
const response = await callAI<ChatResponse>(
"SendMessage",
store.activeConversationId,
text,
);
// Build the assistant message from the response
const toolCalls: ToolCall[] | undefined = response.toolCalls?.map(
(tc) => ({
id: `tc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: tc.name,
input: (tc.input ?? {}) as Record<string, unknown>,
result: tc.result,
status: (tc.error ? "error" : "done") as "done" | "error",
}),
);
store.messages.push({
id: `msg-${Date.now()}`,
role: "assistant",
content: response.text || "",
toolCalls: toolCalls,
timestamp: Date.now(),
});
} catch (err) {
store.messages.push({
id: `msg-${Date.now()}`,
role: "assistant",
content: `Error: ${err}`,
timestamp: Date.now(),
});
} finally {
store.isStreaming = false;
}
}
/** Begin the OAuth login flow (opens browser). */
async function startLogin(): Promise<void> {
try {
await callAI("StartLogin");
} catch (err) {
console.error("StartLogin failed:", err);
}
}
/** Check whether the user is authenticated. */
async function checkAuth(): Promise<boolean> {
try {
const authed = await callAI<boolean>("IsAuthenticated");
store.isAuthenticated = authed;
return authed;
} catch {
return false;
}
}
/** Log out and clear tokens. */
async function logout(): Promise<void> {
try {
await callAI("Logout");
store.isAuthenticated = false;
store.clearHistory();
} catch (err) {
console.error("Logout failed:", err);
}
}
/** Sync the model setting to the Go backend. */
async function setModel(model: string): Promise<void> {
try {
await callAI("SetModel", model);
store.model = model;
} catch (err) {
console.error("SetModel failed:", err);
}
}
/** Load the current model from the Go backend. */
async function getModel(): Promise<string> {
try {
const m = await callAI<string>("GetModel");
store.model = m;
return m;
} catch {
return store.model;
}
}
return { processMessage, startLogin, checkAuth, logout, setModel, getModel };
}

View File

@ -1,8 +1,10 @@
import { ref, onBeforeUnmount } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { Call } from "@wailsio/runtime";
const APP = "github.com/vstockwell/wraith/internal/app.WraithApp";
/**
* RDP mouse event flags match the Rust constants in src-tauri/src/rdp/input.rs
* RDP mouse event flags match the Go constants in internal/rdp/input.go
*/
export const MouseFlag = {
Move: 0x0800,
@ -17,7 +19,7 @@ export const MouseFlag = {
/**
* JavaScript KeyboardEvent.code RDP scancode mapping.
* Mirrors the Rust ScancodeMap in src-tauri/src/rdp/input.rs.
* Mirrors the Go ScancodeMap in internal/rdp/input.go.
*/
export const ScancodeMap: Record<string, number> = {
// Row 0: Escape + Function keys
@ -151,21 +153,26 @@ export function jsKeyToScancode(code: string): number | null {
}
export interface UseRdpReturn {
/** Whether the RDP session is connected (first frame received) */
/** Whether the RDP session is connected (mock: always true after init) */
connected: ReturnType<typeof ref<boolean>>;
/** Whether keyboard capture is enabled */
keyboardGrabbed: ReturnType<typeof ref<boolean>>;
/** Whether clipboard sync is enabled */
clipboardSync: ReturnType<typeof ref<boolean>>;
/** Fetch the current frame as RGBA ImageData */
fetchFrame: (sessionId: string, width: number, height: number) => Promise<ImageData | null>;
fetchFrame: (sessionId: string) => Promise<ImageData | null>;
/** Send a mouse event to the backend */
sendMouse: (sessionId: string, x: number, y: number, flags: number) => void;
sendMouse: (
sessionId: string,
x: number,
y: number,
flags: number,
) => void;
/** Send a key event to the backend */
sendKey: (sessionId: string, code: string, pressed: boolean) => void;
/** Send clipboard text to the remote session */
sendClipboard: (sessionId: string, text: string) => void;
/** Start the frame rendering loop targeting ~30fps */
/** Start the frame rendering loop */
startFrameLoop: (
sessionId: string,
canvas: HTMLCanvasElement,
@ -183,11 +190,8 @@ export interface UseRdpReturn {
/**
* Composable that manages an RDP session's rendering and input.
*
* Uses Tauri's invoke() to call Rust commands:
* rdp_get_frame base64 RGBA string
* rdp_send_mouse fire-and-forget
* rdp_send_key fire-and-forget
* rdp_send_clipboard fire-and-forget
* All backend calls are currently stubs that will be replaced with
* Wails bindings once the Go RDP service is wired up.
*/
export function useRdp(): UseRdpReturn {
const connected = ref(false);
@ -198,20 +202,20 @@ export function useRdp(): UseRdpReturn {
let frameCount = 0;
/**
* Fetch the current frame from the Rust RDP backend.
* Fetch the current frame from the Go RDP backend.
*
* rdp_get_frame returns raw RGBA bytes (width*height*4) serialised as a
* base64 string over Tauri's IPC bridge. We decode it to Uint8ClampedArray
* and wrap in an ImageData for putImageData().
* Go's GetFrame returns []byte (raw RGBA, Width*Height*4 bytes).
* Wails serialises Go []byte as a base64-encoded string over the JSON bridge,
* so we decode it back to a Uint8ClampedArray and wrap it in an ImageData.
*/
async function fetchFrame(
sessionId: string,
width: number,
height: number,
width = 1920,
height = 1080,
): Promise<ImageData | null> {
let raw: string;
try {
raw = await invoke<string>("rdp_get_frame", { sessionId });
raw = (await Call.ByName(`${APP}.RDPGetFrame`, sessionId)) as string;
} catch {
// Session may not be connected yet or backend returned an error — skip frame
return null;
@ -240,7 +244,7 @@ export function useRdp(): UseRdpReturn {
/**
* Send a mouse event to the remote session.
* Calls Rust rdp_send_mouse(sessionId, x, y, flags).
* Calls Go WraithApp.RDPSendMouse(sessionId, x, y, flags).
* Fire-and-forget mouse events are best-effort.
*/
function sendMouse(
@ -249,7 +253,7 @@ export function useRdp(): UseRdpReturn {
y: number,
flags: number,
): void {
invoke("rdp_send_mouse", { sessionId, x, y, flags }).catch(
Call.ByName(`${APP}.RDPSendMouse`, sessionId, x, y, flags).catch(
(err: unknown) => {
console.warn("[useRdp] sendMouse failed:", err);
},
@ -258,14 +262,18 @@ export function useRdp(): UseRdpReturn {
/**
* Send a key event, mapping the JS KeyboardEvent.code to an RDP scancode.
* Calls Rust rdp_send_key(sessionId, scancode, pressed).
* Calls Go WraithApp.RDPSendKey(sessionId, scancode, pressed).
* Unmapped keys are silently dropped not every JS key has an RDP scancode.
*/
function sendKey(sessionId: string, code: string, pressed: boolean): void {
function sendKey(
sessionId: string,
code: string,
pressed: boolean,
): void {
const scancode = jsKeyToScancode(code);
if (scancode === null) return;
invoke("rdp_send_key", { sessionId, scancode, pressed }).catch(
Call.ByName(`${APP}.RDPSendKey`, sessionId, scancode, pressed).catch(
(err: unknown) => {
console.warn("[useRdp] sendKey failed:", err);
},
@ -274,10 +282,10 @@ export function useRdp(): UseRdpReturn {
/**
* Send clipboard text to the remote RDP session.
* Calls Rust rdp_send_clipboard(sessionId, text).
* Calls Go WraithApp.RDPSendClipboard(sessionId, text).
*/
function sendClipboard(sessionId: string, text: string): void {
invoke("rdp_send_clipboard", { sessionId, text }).catch(
Call.ByName(`${APP}.RDPSendClipboard`, sessionId, text).catch(
(err: unknown) => {
console.warn("[useRdp] sendClipboard failed:", err);
},
@ -285,11 +293,8 @@ export function useRdp(): UseRdpReturn {
}
/**
* Start the rendering loop. Fetches frames and draws them on the canvas.
*
* Targets ~30fps by skipping every other rAF tick (rAF fires at ~60fps).
* Sets connected = true as soon as the loop starts the overlay dismisses
* on first successful frame render.
* Start the rendering loop. Fetches frames and draws them on the canvas
* using requestAnimationFrame.
*/
function startFrameLoop(
sessionId: string,
@ -297,6 +302,7 @@ export function useRdp(): UseRdpReturn {
width: number,
height: number,
): void {
connected.value = true;
const ctx = canvas.getContext("2d");
if (!ctx) return;
@ -306,15 +312,11 @@ export function useRdp(): UseRdpReturn {
function renderLoop(): void {
frameCount++;
// Throttle to ~30fps by skipping odd-numbered rAF ticks
// Throttle to ~30fps (skip every other frame at 60fps rAF)
if (frameCount % 2 === 0) {
fetchFrame(sessionId, width, height).then((imageData) => {
if (imageData && ctx) {
ctx.putImageData(imageData, 0, 0);
// Mark connected on first successful frame
if (!connected.value) {
connected.value = true;
}
}
});
}
@ -326,7 +328,7 @@ export function useRdp(): UseRdpReturn {
}
/**
* Stop the rendering loop and reset connected state.
* Stop the rendering loop.
*/
function stopFrameLoop(): void {
if (animFrameId !== null) {
@ -334,7 +336,6 @@ export function useRdp(): UseRdpReturn {
animFrameId = null;
}
connected.value = false;
frameCount = 0;
}
function toggleKeyboardGrab(): void {

View File

@ -1,6 +1,7 @@
import { ref, onBeforeUnmount, type Ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { Call, Events } from "@wailsio/runtime";
const SFTP = "github.com/vstockwell/wraith/internal/sftp.SFTPService";
export interface FileEntry {
name: string;
@ -23,7 +24,7 @@ export interface UseSftpReturn {
/**
* Composable that manages SFTP file browsing state.
* Calls the Rust SFTP commands via Tauri invoke.
* Calls the real Go SFTPService via Wails bindings.
*/
export function useSftp(sessionId: string): UseSftpReturn {
const currentPath = ref("/");
@ -31,12 +32,9 @@ export function useSftp(sessionId: string): UseSftpReturn {
const isLoading = ref(false);
const followTerminal = ref(true);
// Holds the unlisten function returned by listen() — called on cleanup.
let unlistenCwd: UnlistenFn | null = null;
async function listDirectory(path: string): Promise<FileEntry[]> {
try {
const result = await invoke<FileEntry[]>("sftp_list", { sessionId, path });
const result = await Call.ByName(`${SFTP}.List`, sessionId, path) as FileEntry[];
return result ?? [];
} catch (err) {
console.error("SFTP list error:", err);
@ -68,20 +66,26 @@ export function useSftp(sessionId: string): UseSftpReturn {
await navigateTo(currentPath.value);
}
// Listen for CWD changes from the Rust backend (OSC 7 tracking).
// listen() returns Promise<UnlistenFn> — store it for cleanup.
listen<string>(`ssh:cwd:${sessionId}`, (event) => {
// Listen for CWD changes from the Go backend (OSC 7 tracking)
const cleanupCwd = Events.On(`ssh:cwd:${sessionId}`, (event: any) => {
if (!followTerminal.value) return;
const newPath = event.payload;
let newPath: string;
if (typeof event === "string") {
newPath = event;
} else if (event?.data && typeof event.data === "string") {
newPath = event.data;
} else if (Array.isArray(event?.data)) {
newPath = String(event.data[0] ?? "");
} else {
return;
}
if (newPath && newPath !== currentPath.value) {
navigateTo(newPath);
}
}).then((unlisten) => {
unlistenCwd = unlisten;
});
onBeforeUnmount(() => {
if (unlistenCwd) unlistenCwd();
if (cleanupCwd) cleanupCwd();
});
// Load home directory on init

View File

@ -3,10 +3,11 @@ 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 { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
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",
@ -36,7 +37,6 @@ const defaultTheme = {
export interface UseTerminalReturn {
terminal: Terminal;
fitAddon: FitAddon;
searchAddon: SearchAddon;
mount: (container: HTMLElement) => void;
destroy: () => void;
write: (data: string) => void;
@ -47,9 +47,9 @@ export interface UseTerminalReturn {
* Composable that manages an xterm.js Terminal lifecycle.
*
* Wires bidirectional I/O:
* - User keystrokes ssh_write (via Tauri invoke)
* - SSH stdout xterm.js (via Tauri listen, base64 encoded)
* - Terminal resize ssh_resize (via Tauri invoke)
* - 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();
@ -75,14 +75,14 @@ export function useTerminal(sessionId: string): UseTerminalReturn {
// Forward typed data to the SSH backend
terminal.onData((data: string) => {
invoke("ssh_write", { sessionId, data }).catch((err: unknown) => {
Call.ByName(`${SSH}.Write`, sessionId, data).catch((err: unknown) => {
console.error("SSH write error:", err);
});
});
// Forward resize events to the SSH backend
terminal.onResize((size: { cols: number; rows: number }) => {
invoke("ssh_resize", { sessionId, cols: size.cols, rows: size.rows }).catch((err: unknown) => {
Call.ByName(`${SSH}.Resize`, sessionId, size.cols, size.rows).catch((err: unknown) => {
console.error("SSH resize error:", err);
});
});
@ -100,22 +100,19 @@ export function useTerminal(sessionId: string): UseTerminalReturn {
e.stopPropagation();
navigator.clipboard.readText().then((text) => {
if (text) {
invoke("ssh_write", { sessionId, data: text }).catch(() => {});
Call.ByName(`${SSH}.Write`, sessionId, text).catch(() => {});
}
}).catch(() => {});
}
// Listen for SSH output events from the Rust backend (base64 encoded)
// Tauri listen() returns a Promise<UnlistenFn> — store both the promise and
// the resolved unlisten function so we can clean up properly.
let unlistenFn: UnlistenFn | null = null;
let unlistenPromise: Promise<UnlistenFn> | null = null;
// Listen for SSH output events from the Go backend (base64 encoded)
let cleanupEvent: (() => void) | null = null;
let resizeObserver: ResizeObserver | null = null;
// Streaming TextDecoder persists across events so split multi-byte UTF-8
// sequences at chunk boundaries are decoded correctly (e.g. a 3-byte em-dash
// split across two Rust read() calls).
// split across two Go read() calls).
const utf8Decoder = new TextDecoder("utf-8", { fatal: false });
// Write batching — accumulate chunks and flush once per animation frame.
@ -143,7 +140,7 @@ export function useTerminal(sessionId: string): UseTerminalReturn {
terminal.open(container);
// Wait for fonts to load before measuring cell dimensions.
// If the font (Cascadia Mono etc.) isn't loaded when fitAddon.fit()
// If the font (JetBrains Mono etc.) isn't loaded when fitAddon.fit()
// runs, canvas.measureText() uses a fallback font and gets wrong
// cell widths — producing tiny dashes and 200+ column terminals.
document.fonts.ready.then(() => {
@ -153,11 +150,23 @@ export function useTerminal(sessionId: string): UseTerminalReturn {
// Right-click paste on the terminal's DOM element
terminal.element?.addEventListener("contextmenu", handleRightClickPaste);
// Subscribe to SSH output events for this session.
// Tauri v2 listen() callback receives { payload: T } — the base64 string
// is in event.payload (not event.data as in Wails).
unlistenPromise = listen<string>(`ssh:data:${sessionId}`, (event) => {
const b64data = event.payload;
// Subscribe to SSH output events for this session
// Wails v3 Events.On callback receives a CustomEvent object with .data property
cleanupEvent = Events.On(`ssh:data:${sessionId}`, (event: any) => {
// Extract the base64 string from the event
// event.data is the first argument passed to app.Event.Emit()
let b64data: string;
if (typeof event === "string") {
b64data = event;
} else if (event?.data && typeof event.data === "string") {
b64data = event.data;
} else if (Array.isArray(event?.data)) {
b64data = String(event.data[0] ?? "");
} else {
// Debug: log the actual shape so we can fix it
console.warn("ssh:data unexpected shape:", JSON.stringify(event)?.slice(0, 200));
return;
}
try {
// atob() returns Latin-1 — each byte becomes a char code 0x000xFF.
@ -178,11 +187,6 @@ export function useTerminal(sessionId: string): UseTerminalReturn {
}
});
// Capture the resolved unlisten function for synchronous cleanup
unlistenPromise.then((fn) => {
unlistenFn = fn;
});
// Auto-fit when the container resizes
resizeObserver = new ResizeObserver(() => {
fitAddon.fit();
@ -202,18 +206,10 @@ export function useTerminal(sessionId: string): UseTerminalReturn {
}
terminal.element?.removeEventListener("contextmenu", handleRightClickPaste);
selectionDisposable.dispose();
// Clean up the Tauri event listener.
// If the promise already resolved, call unlisten directly.
// If it's still pending, wait for resolution then call it.
if (unlistenFn) {
unlistenFn();
unlistenFn = null;
} else if (unlistenPromise) {
unlistenPromise.then((fn) => fn());
if (cleanupEvent) {
cleanupEvent();
cleanupEvent = null;
}
unlistenPromise = null;
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
@ -233,5 +229,5 @@ export function useTerminal(sessionId: string): UseTerminalReturn {
destroy();
});
return { terminal, fitAddon, searchAddon, mount, destroy, write, fit };
return { terminal, fitAddon, mount, destroy, write, fit };
}

7
frontend/src/env.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<object, object, unknown>;
export default component;
}

View File

@ -3,10 +3,10 @@
<!-- Toolbar -->
<div
class="h-10 flex items-center justify-between px-4 bg-[var(--wraith-bg-secondary)] border-b border-[var(--wraith-border)] shrink-0"
data-tauri-drag-region
style="--wails-draggable: drag"
>
<div class="flex items-center gap-3">
<span class="text-sm font-bold tracking-widest text-[var(--wraith-accent-blue)]">
<div class="flex items-center gap-3" style="--wails-draggable: no-drag">
<span class="text-sm font-bold tracking-widest text-[var(--wraith-accent-blue)]" style="--wails-draggable: drag">
WRAITH
</span>
@ -61,7 +61,7 @@
</div>
<!-- Quick Connect -->
<div class="flex-1 max-w-xs mx-4">
<div class="flex-1 max-w-xs mx-4" style="--wails-draggable: no-drag">
<input
v-model="quickConnectInput"
type="text"
@ -71,7 +71,7 @@
/>
</div>
<div class="flex items-center gap-3 text-xs text-[var(--wraith-text-secondary)]">
<div class="flex items-center gap-3 text-xs text-[var(--wraith-text-secondary)]" style="--wails-draggable: no-drag">
<span>{{ sessionStore.sessionCount }} session{{ sessionStore.sessionCount !== 1 ? "s" : "" }}</span>
<button
class="hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@ -82,6 +82,7 @@
<path d="M11.5 7a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0zm-.82 4.74a6 6 0 1 1 1.06-1.06l3.04 3.04a.75.75 0 1 1-1.06 1.06l-3.04-3.04z" />
</svg>
</button>
<!-- XO Copilot removed, will be replaced with embedded terminal running claude -->
<button
class="hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@ -125,23 +126,23 @@
<!-- Connection tree -->
<ConnectionTree v-if="sidebarTab === 'connections'" />
<!-- SFTP browser -->
<!-- SFTP file tree -->
<template v-else-if="sidebarTab === 'sftp'">
<template v-if="activeSessionId">
<FileTree
:session-id="activeSessionId"
class="flex-1 min-h-0"
v-if="sessionStore.activeSession && sessionStore.activeSession.protocol === 'ssh'"
:session-id="sessionStore.activeSession.id"
@open-file="handleOpenFile"
/>
<TransferProgress />
</template>
<div v-else class="flex items-center justify-center py-8 px-3">
<p class="text-[var(--wraith-text-muted)] text-xs text-center">
No active session
Connect to an SSH session to browse files
</p>
</div>
</template>
</div>
<!-- Transfer progress (SFTP mode only) -->
<TransferProgress v-if="sidebarTab === 'sftp'" />
</div>
<!-- Content area -->
@ -149,7 +150,7 @@
<!-- Tab bar -->
<TabBar />
<!-- Inline file editor shown above the terminal when a file is open -->
<!-- Editor panel (if a file is open) -->
<EditorWindow
v-if="editorFile"
:content="editorFile.content"
@ -159,14 +160,16 @@
/>
<!-- Session area -->
<SessionContainer ref="sessionContainer" />
<SessionContainer />
</div>
<!-- Copilot Panel removed, will be replaced with embedded terminal running claude -->
</div>
<!-- Status bar -->
<StatusBar ref="statusBar" @open-theme-picker="themePicker?.open()" />
<!-- Command Palette (Ctrl+K) stub, full implementation Phase N -->
<!-- Command Palette (Ctrl+K) -->
<CommandPalette
ref="commandPalette"
@open-import="importDialog?.open()"
@ -219,36 +222,37 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { ref, watch, onMounted, onUnmounted } from "vue";
import { Call, Application } from "@wailsio/runtime";
import { useAppStore } from "@/stores/app.store";
import { useConnectionStore } from "@/stores/connection.store";
import { useSessionStore } from "@/stores/session.store";
// copilot removed will be replaced with embedded terminal running claude
import SidebarToggle from "@/components/sidebar/SidebarToggle.vue";
import ConnectionTree from "@/components/sidebar/ConnectionTree.vue";
import FileTree from "@/components/sftp/FileTree.vue";
import TransferProgress from "@/components/sftp/TransferProgress.vue";
import TabBar from "@/components/session/TabBar.vue";
import SessionContainer from "@/components/session/SessionContainer.vue";
import StatusBar from "@/components/common/StatusBar.vue";
import EditorWindow from "@/components/editor/EditorWindow.vue";
import CommandPalette from "@/components/common/CommandPalette.vue";
import ThemePicker from "@/components/common/ThemePicker.vue";
import ImportDialog from "@/components/common/ImportDialog.vue";
import SettingsModal from "@/components/common/SettingsModal.vue";
import ConnectionEditDialog from "@/components/connections/ConnectionEditDialog.vue";
import FileTree from "@/components/sftp/FileTree.vue";
import TransferProgress from "@/components/sftp/TransferProgress.vue";
import EditorWindow from "@/components/editor/EditorWindow.vue";
import type { FileEntry } from "@/composables/useSftp";
// CopilotPanel removed
const SFTP = "github.com/vstockwell/wraith/internal/sftp.SFTPService";
const CONN = "github.com/vstockwell/wraith/internal/connections.ConnectionService";
import type { ThemeDefinition } from "@/components/common/ThemePicker.vue";
import type { SidebarTab } from "@/components/sidebar/SidebarToggle.vue";
import type { FileEntry } from "@/composables/useSftp";
const appStore = useAppStore();
const connectionStore = useConnectionStore();
const sessionStore = useSessionStore();
/** Active SSH session ID, exposed to the SFTP sidebar. */
const activeSessionId = computed(() => sessionStore.activeSessionId);
// copilotStore removed
const sidebarWidth = ref(240);
const sidebarVisible = ref(true);
@ -258,21 +262,22 @@ const quickConnectInput = ref("");
/** Whether to show the MobaXTerm import prompt (first run, no connections). */
const showMobaPrompt = ref(false);
// Auto-switch to SFTP tab when an SSH session becomes active
watch(() => sessionStore.activeSession, (session) => {
if (session && session.protocol === "ssh") {
sidebarTab.value = "sftp";
}
});
/** Currently open file in the editor panel (null = no file open). */
const editorFile = ref<{ content: string; path: string; sessionId: string } | null>(null);
const commandPalette = ref<InstanceType<typeof CommandPalette> | null>(null);
const themePicker = ref<InstanceType<typeof ThemePicker> | null>(null);
const importDialog = ref<InstanceType<typeof ImportDialog> | null>(null);
const settingsModal = ref<InstanceType<typeof SettingsModal> | null>(null);
const connectionEditDialog = ref<InstanceType<typeof ConnectionEditDialog> | null>(null);
const statusBar = ref<InstanceType<typeof StatusBar> | null>(null);
const sessionContainer = ref<InstanceType<typeof SessionContainer> | null>(null);
/** Currently open file in the inline editor. Null when the editor is closed. */
interface EditorFile {
path: string;
content: string;
sessionId: string;
}
const editorFile = ref<EditorFile | null>(null);
/** File menu dropdown state. */
const showFileMenu = ref(false);
@ -283,7 +288,7 @@ function closeFileMenuDeferred(): void {
}
/** Handle file menu item clicks. */
async function handleFileMenuAction(action: string): Promise<void> {
function handleFileMenuAction(action: string): void {
showFileMenu.value = false;
switch (action) {
case "new-connection":
@ -296,15 +301,31 @@ async function handleFileMenuAction(action: string): Promise<void> {
settingsModal.value?.open();
break;
case "exit":
try {
await getCurrentWindow().close();
} catch {
window.close();
}
Application.Quit().catch(() => window.close());
break;
}
}
/** Handle file open from SFTP sidebar -- reads real content via SFTPService. */
async function handleOpenFile(entry: FileEntry): Promise<void> {
if (!sessionStore.activeSession) {
console.error("No active session to read file from");
return;
}
const sessionId = sessionStore.activeSession.id;
try {
const content = await Call.ByName(`${SFTP}.ReadFile`, sessionId, entry.path) as string;
editorFile.value = {
content,
path: entry.path,
sessionId,
};
} catch (err) {
console.error("Failed to read file:", err);
}
}
/** Handle theme selection from the ThemePicker. */
function handleThemeSelect(theme: ThemeDefinition): void {
statusBar.value?.setThemeName(theme.name);
@ -312,27 +333,6 @@ function handleThemeSelect(theme: ThemeDefinition): void {
sessionStore.setTheme(theme);
}
/**
* Called when the user double-clicks a file in the SFTP FileTree.
* Reads the file content via Tauri SFTP and opens it in the inline editor.
*/
async function handleOpenFile(entry: FileEntry): Promise<void> {
if (!activeSessionId.value) return;
try {
const content = await invoke<string>("sftp_read_file", {
sessionId: activeSessionId.value,
path: entry.path,
});
editorFile.value = {
path: entry.path,
content,
sessionId: activeSessionId.value,
};
} catch (err) {
console.error("Failed to open SFTP file:", err);
}
}
/**
* Quick Connect: parse user@host:port and open a session.
* Default protocol: SSH, default port: 22.
@ -379,7 +379,7 @@ async function handleQuickConnect(): Promise<void> {
try {
// Create a persistent connection record then connect to it
const conn = await invoke<{ id: number }>("create_connection", {
const conn = await Call.ByName(`${CONN}.CreateConnection`, {
name,
hostname,
port,
@ -390,7 +390,7 @@ async function handleQuickConnect(): Promise<void> {
tags: username ? [username] : [],
notes: "",
options: username ? JSON.stringify({ username }) : "{}",
});
}) as { id: number };
// Add to local store so sessionStore.connect can find it
connectionStore.connections.push({
@ -401,7 +401,6 @@ async function handleQuickConnect(): Promise<void> {
protocol,
groupId: null,
tags: username ? [username] : [],
options: username ? JSON.stringify({ username }) : "{}",
});
await sessionStore.connect(conn.id);
@ -478,21 +477,11 @@ function handleKeydown(event: KeyboardEvent): void {
sidebarVisible.value = !sidebarVisible.value;
return;
}
// Ctrl+F open terminal scrollback search (SSH sessions only)
if (ctrl && event.key === "f") {
const active = sessionStore.activeSession;
if (active?.protocol === "ssh") {
event.preventDefault();
sessionContainer.value?.openActiveSearch();
}
return;
}
}
onMounted(async () => {
document.addEventListener("keydown", handleKeydown);
// Load connections and groups from the Rust backend after vault unlock
// Load connections and groups from the Go backend after vault unlock
await connectionStore.loadAll();
// First-run: if no connections found, offer to import from MobaXTerm

View File

@ -0,0 +1,129 @@
<template>
<div class="h-screen w-screen flex items-center justify-center bg-[var(--wraith-bg-primary)]">
<div class="w-full max-w-sm px-6">
<!-- Branding -->
<div class="text-center mb-8">
<h1 class="text-4xl font-bold tracking-widest text-[var(--wraith-accent-blue)]">
WRAITH
</h1>
<p class="text-[var(--wraith-text-secondary)] mt-2 text-sm">
{{ appStore.isFirstRun ? "Create a master password" : "Enter your master password" }}
</p>
</div>
<!-- Card -->
<form
class="bg-[var(--wraith-bg-secondary)] border border-[var(--wraith-border)] rounded-lg p-6 space-y-4"
@submit.prevent="handleSubmit"
>
<!-- Error -->
<div
v-if="appStore.error"
class="text-sm text-[var(--wraith-accent-red)] bg-[var(--wraith-accent-red)]/10 border border-[var(--wraith-accent-red)]/20 rounded px-3 py-2"
>
{{ appStore.error }}
</div>
<!-- Password -->
<div>
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1.5">
Master Password
</label>
<input
ref="passwordInput"
v-model="password"
type="password"
autocomplete="current-password"
placeholder="Enter password..."
class="w-full px-3 py-2 text-sm rounded bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
@input="appStore.clearError()"
/>
</div>
<!-- Confirm password (first run only) -->
<div v-if="appStore.isFirstRun">
<label class="block text-xs text-[var(--wraith-text-secondary)] mb-1.5">
Confirm Password
</label>
<input
v-model="confirmPassword"
type="password"
autocomplete="new-password"
placeholder="Confirm password..."
class="w-full px-3 py-2 text-sm rounded bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] text-[var(--wraith-text-primary)] placeholder-[var(--wraith-text-muted)] outline-none focus:border-[var(--wraith-accent-blue)] transition-colors"
@input="appStore.clearError()"
/>
</div>
<!-- Submit -->
<button
type="submit"
:disabled="submitting"
class="w-full py-2 text-sm font-medium rounded bg-[var(--wraith-accent-blue)] text-white hover:opacity-90 disabled:opacity-50 transition-opacity cursor-pointer disabled:cursor-not-allowed"
>
<span v-if="submitting">{{ appStore.isFirstRun ? "Creating..." : "Unlocking..." }}</span>
<span v-else>{{ appStore.isFirstRun ? "Create Vault" : "Unlock" }}</span>
</button>
</form>
<!-- Version -->
<p class="text-center text-xs text-[var(--wraith-text-muted)] mt-4">
v{{ appVersion }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { Call } from "@wailsio/runtime";
import { useAppStore } from "@/stores/app.store";
const APP = "github.com/vstockwell/wraith/internal/app.WraithApp";
const appStore = useAppStore();
const password = ref("");
const confirmPassword = ref("");
const submitting = ref(false);
const passwordInput = ref<HTMLInputElement | null>(null);
const appVersion = ref("...");
onMounted(async () => {
passwordInput.value?.focus();
try {
const ver = await Call.ByName(`${APP}.GetVersion`) as string;
if (ver) appVersion.value = ver;
} catch { /* ignore */ }
});
async function handleSubmit(): Promise<void> {
if (!password.value) {
appStore.error = "Password is required";
return;
}
if (appStore.isFirstRun) {
if (password.value.length < 8) {
appStore.error = "Password must be at least 8 characters";
return;
}
if (password.value !== confirmPassword.value) {
appStore.error = "Passwords do not match";
return;
}
}
submitting.value = true;
try {
if (appStore.isFirstRun) {
await appStore.createVault(password.value);
} else {
await appStore.unlock(password.value);
}
} catch {
// Error is set in the store
} finally {
submitting.value = false;
}
}
</script>

View File

@ -1,7 +1,7 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import "./assets/main.css";
import "./assets/css/main.css";
const app = createApp(App);
app.use(createPinia());

View File

@ -0,0 +1,75 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { Call } from "@wailsio/runtime";
/** Fully qualified Go method name prefix for WraithApp bindings. */
const APP = "github.com/vstockwell/wraith/internal/app.WraithApp";
/**
* Wraith application store.
* Manages unlock state, first-run detection, and vault operations.
*/
export const useAppStore = defineStore("app", () => {
const isUnlocked = ref(false);
const isFirstRun = ref(true);
const isLoading = ref(true);
const error = ref<string | null>(null);
/** Check whether the vault has been created before. */
async function checkFirstRun(): Promise<void> {
try {
isFirstRun.value = await Call.ByName(`${APP}.IsFirstRun`) as boolean;
} catch {
isFirstRun.value = true;
} finally {
isLoading.value = false;
}
}
/** Create a new vault with the given master password. */
async function createVault(password: string): Promise<void> {
error.value = null;
try {
await Call.ByName(`${APP}.CreateVault`, password);
isFirstRun.value = false;
isUnlocked.value = true;
} catch (e) {
error.value = e instanceof Error ? e.message : String(e) || "Failed to create vault";
throw e;
}
}
/** Unlock an existing vault with the master password. */
async function unlock(password: string): Promise<void> {
error.value = null;
try {
await Call.ByName(`${APP}.Unlock`, password);
isUnlocked.value = true;
} catch (e) {
error.value = e instanceof Error ? e.message : String(e) || "Invalid master password";
throw e;
}
}
/** Lock the vault (return to unlock screen). */
function lock(): void {
isUnlocked.value = false;
}
/** Clear the current error message. */
function clearError(): void {
error.value = null;
}
return {
isUnlocked,
isFirstRun,
isLoading,
error,
checkFirstRun,
createVault,
unlock,
lock,
clearError,
};
});

View File

@ -1,6 +1,9 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { Call } from "@wailsio/runtime";
/** Fully qualified Go method name prefix for ConnectionService bindings. */
const CONN = "github.com/vstockwell/wraith/internal/connections.ConnectionService";
export interface Connection {
id: number;
@ -32,7 +35,7 @@ export interface Group {
/**
* Connection store.
* Manages connections, groups, and search state.
* Loads data from the Rust backend via Tauri invoke.
* Loads data from the Go backend via Wails bindings.
*/
export const useConnectionStore = defineStore("connection", () => {
const connections = ref<Connection[]>([]);
@ -69,10 +72,10 @@ export const useConnectionStore = defineStore("connection", () => {
return connectionsByGroup(groupId).length > 0;
}
/** Load connections from the Rust backend. */
/** Load connections from the Go backend. */
async function loadConnections(): Promise<void> {
try {
const conns = await invoke<Connection[]>("list_connections");
const conns = await Call.ByName(`${CONN}.ListConnections`) as Connection[];
connections.value = conns || [];
} catch (err) {
console.error("Failed to load connections:", err);
@ -80,10 +83,10 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
/** Load groups from the Rust backend. */
/** Load groups from the Go backend. */
async function loadGroups(): Promise<void> {
try {
const grps = await invoke<Group[]>("list_groups");
const grps = await Call.ByName(`${CONN}.ListGroups`) as Group[];
groups.value = grps || [];
} catch (err) {
console.error("Failed to load groups:", err);
@ -91,7 +94,7 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
/** Load both connections and groups from the Rust backend. */
/** Load both connections and groups from the Go backend. */
async function loadAll(): Promise<void> {
await Promise.all([loadConnections(), loadGroups()]);
}

View File

@ -0,0 +1,163 @@
import { defineStore } from "pinia";
import { ref } from "vue";
export interface ToolCall {
id: string;
name: string;
input: Record<string, unknown>;
result?: unknown;
status: "pending" | "done" | "error";
}
export interface Message {
id: string;
role: "user" | "assistant";
content: string;
toolCalls?: ToolCall[];
timestamp: number;
}
export interface ConversationSummary {
id: string;
title: string;
model: string;
createdAt: string;
tokensIn: number;
tokensOut: number;
}
/**
* Copilot (XO) store.
* Manages the AI assistant panel state, messages, and streaming.
*
* Backend calls are handled by the useCopilot composable;
* this store manages purely reactive UI state.
*/
export const useCopilotStore = defineStore("copilot", () => {
const isPanelOpen = ref(false);
const isAuthenticated = ref(false);
const isStreaming = ref(false);
const activeConversationId = ref<string | null>(null);
const messages = ref<Message[]>([]);
const conversations = ref<ConversationSummary[]>([]);
const model = ref("claude-sonnet-4-20250514");
const tokenUsage = ref({ input: 0, output: 0 });
const showSettings = ref(false);
/** Toggle the copilot panel open/closed. */
function togglePanel(): void {
isPanelOpen.value = !isPanelOpen.value;
}
/** Start a new conversation (resets local state). */
function newConversation(): void {
activeConversationId.value = null;
messages.value = [];
tokenUsage.value = { input: 0, output: 0 };
}
/** Add a user message to the local message list. */
function sendMessage(text: string): void {
const userMsg: Message = {
id: `msg-${Date.now()}`,
role: "user",
content: text,
timestamp: Date.now(),
};
messages.value.push(userMsg);
// Rough token estimate for display purposes
tokenUsage.value.input += Math.ceil(text.length / 4);
}
/** Append a streaming text delta to the latest assistant message. */
function appendStreamDelta(text: string): void {
const last = messages.value[messages.value.length - 1];
if (last && last.role === "assistant") {
last.content += text;
}
}
/** Create a new assistant message (for streaming start). */
function createAssistantMessage(): Message {
const msg: Message = {
id: `msg-${Date.now()}`,
role: "assistant",
content: "",
toolCalls: [],
timestamp: Date.now(),
};
messages.value.push(msg);
return msg;
}
/** Add a tool call to the latest assistant message. */
function addToolCall(call: ToolCall): void {
const last = messages.value[messages.value.length - 1];
if (last && last.role === "assistant") {
if (!last.toolCalls) last.toolCalls = [];
last.toolCalls.push(call);
}
}
/** Complete a pending tool call with a result. */
function completeToolCall(callId: string, result: unknown): void {
for (const msg of messages.value) {
if (!msg.toolCalls) continue;
const tc = msg.toolCalls.find((t) => t.id === callId);
if (tc) {
tc.result = result;
tc.status = "done";
break;
}
}
}
/** Mark a tool call as errored. */
function failToolCall(callId: string, error: unknown): void {
for (const msg of messages.value) {
if (!msg.toolCalls) continue;
const tc = msg.toolCalls.find((t) => t.id === callId);
if (tc) {
tc.result = error;
tc.status = "error";
break;
}
}
}
/** Load conversation list from backend. */
async function loadConversations(): Promise<void> {
// TODO: wire to AIService.ListConversations when conversation history UI is built
conversations.value = [];
}
/** Clear all messages in the current conversation. */
function clearHistory(): void {
messages.value = [];
activeConversationId.value = null;
tokenUsage.value = { input: 0, output: 0 };
}
return {
isPanelOpen,
isAuthenticated,
isStreaming,
activeConversationId,
messages,
conversations,
model,
tokenUsage,
showSettings,
togglePanel,
newConversation,
sendMessage,
appendStreamDelta,
createAssistantMessage,
addToolCall,
completeToolCall,
failToolCall,
loadConversations,
clearHistory,
};
});

View File

@ -1,9 +1,11 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { Call } from "@wailsio/runtime";
import { useConnectionStore } from "@/stores/connection.store";
import type { ThemeDefinition } from "@/components/common/ThemePicker.vue";
const APP = "github.com/vstockwell/wraith/internal/app.WraithApp";
export interface Session {
id: string;
connectionId: number;
@ -46,13 +48,9 @@ export const useSessionStore = defineStore("session", () => {
const session = sessions.value[idx];
// Disconnect the backend session using the protocol-appropriate command
// Disconnect the backend session
try {
if (session.protocol === "rdp") {
await invoke("disconnect_rdp", { sessionId: session.id });
} else {
await invoke("disconnect_session", { sessionId: session.id });
}
await Call.ByName(`${APP}.DisconnectSession`, session.id);
} catch (err) {
console.error("Failed to disconnect session:", err);
}
@ -84,10 +82,6 @@ export const useSessionStore = defineStore("session", () => {
* Connect to a server by connection ID.
* Multiple sessions to the same host are allowed (MobaXTerm-style).
* Each gets its own tab with a disambiguated name like "Asgard (2)".
*
* For Tauri: we must resolve the connection details ourselves and pass
* hostname/port/username/password directly to connect_ssh, because the
* Rust side has no knowledge of connection IDs the vault owns credentials.
*/
async function connect(connectionId: number): Promise<void> {
const connectionStore = useConnectionStore();
@ -99,56 +93,49 @@ export const useSessionStore = defineStore("session", () => {
if (conn.protocol === "ssh") {
let sessionId: string;
let resolvedUsername: string | undefined;
let resolvedPassword = "";
// Extract stored username from connection options JSON if present
if (conn.options) {
try {
const opts = JSON.parse(conn.options);
if (opts?.username) resolvedUsername = opts.username;
if (opts?.password) resolvedPassword = opts.password;
} catch {
// ignore malformed options
}
}
try {
sessionId = await invoke<string>("connect_ssh", {
hostname: conn.hostname,
port: conn.port,
username: resolvedUsername ?? "",
password: resolvedPassword,
cols: 120,
rows: 40,
});
} catch (sshErr: unknown) {
const errMsg = sshErr instanceof Error
? sshErr.message
: typeof sshErr === "string"
? sshErr
: String(sshErr);
// Try with stored credentials first
sessionId = await Call.ByName(
`${APP}.ConnectSSH`,
connectionId,
120, // cols (will be resized by xterm.js fit addon)
40, // rows
) as string;
} catch (sshErr: any) {
const errMsg = typeof sshErr === "string" ? sshErr : sshErr?.message ?? String(sshErr);
// If no credentials or auth failed, prompt for username/password
if (errMsg.includes("NO_CREDENTIALS") || errMsg.includes("unable to authenticate") || errMsg.includes("authentication")) {
// If no credentials, prompt for username/password
if (errMsg.includes("NO_CREDENTIALS") || errMsg.includes("unable to authenticate")) {
const username = prompt(`Username for ${conn.hostname}:`, "root");
if (!username) throw new Error("Connection cancelled");
const password = prompt(`Password for ${username}@${conn.hostname}:`);
if (password === null) throw new Error("Connection cancelled");
resolvedUsername = username;
sessionId = await invoke<string>("connect_ssh", {
hostname: conn.hostname,
port: conn.port,
sessionId = await Call.ByName(
`${APP}.ConnectSSHWithPassword`,
connectionId,
username,
password,
cols: 120,
rows: 40,
});
120,
40,
) as string;
} else {
throw sshErr;
}
}
// Try to get username from connection options if not already resolved
if (!resolvedUsername && conn.options) {
try {
const opts = JSON.parse(conn.options);
if (opts?.username) resolvedUsername = opts.username;
} catch {
// ignore malformed options
}
}
sessions.value.push({
id: sessionId,
connectionId,
@ -159,77 +146,13 @@ export const useSessionStore = defineStore("session", () => {
});
activeSessionId.value = sessionId;
} else if (conn.protocol === "rdp") {
let username = "";
let password = "";
let domain = "";
// Extract stored credentials from connection options JSON if present
if (conn.options) {
try {
const opts = JSON.parse(conn.options);
if (opts?.username) username = opts.username;
if (opts?.password) password = opts.password;
if (opts?.domain) domain = opts.domain;
} catch {
// ignore malformed options
}
}
let sessionId: string;
try {
sessionId = await invoke<string>("connect_rdp", {
config: {
hostname: conn.hostname,
port: conn.port,
username,
password,
domain,
width: 1920,
height: 1080,
},
});
} catch (rdpErr: unknown) {
const errMsg =
rdpErr instanceof Error
? rdpErr.message
: typeof rdpErr === "string"
? rdpErr
: String(rdpErr);
// If credentials are missing or rejected, prompt the operator
if (
errMsg.includes("NO_CREDENTIALS") ||
errMsg.includes("authentication") ||
errMsg.includes("logon failure")
) {
const promptedUsername = prompt(
`Username for ${conn.hostname}:`,
"Administrator",
);
if (!promptedUsername) throw new Error("Connection cancelled");
const promptedPassword = prompt(
`Password for ${promptedUsername}@${conn.hostname}:`,
);
if (promptedPassword === null) throw new Error("Connection cancelled");
const promptedDomain = prompt(`Domain (leave blank if none):`, "") ?? "";
username = promptedUsername;
sessionId = await invoke<string>("connect_rdp", {
config: {
hostname: conn.hostname,
port: conn.port,
username: promptedUsername,
password: promptedPassword,
domain: promptedDomain,
width: 1920,
height: 1080,
},
});
} else {
throw rdpErr;
}
}
// Call Go backend — resolves credentials, builds RDPConfig, returns sessionID
const sessionId = await Call.ByName(
`${APP}.ConnectRDP`,
connectionId,
1920, // initial width — resized by the RDP view on mount
1080, // initial height
) as string;
sessions.value.push({
id: sessionId,
@ -237,12 +160,11 @@ export const useSessionStore = defineStore("session", () => {
name: disambiguatedName(conn.name, connectionId),
protocol: "rdp",
active: true,
username,
});
activeSessionId.value = sessionId;
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : typeof err === "string" ? err : String(err);
} catch (err: any) {
const msg = typeof err === "string" ? err : err?.message ?? String(err);
console.error("Connection failed:", msg);
lastError.value = msg;
// Show error as native alert so it's visible without DevTools

19
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
}

13
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,13 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
import { resolve } from "path";
export default defineConfig({
plugins: [vue(), tailwindcss()],
resolve: {
alias: {
"@": resolve(__dirname, "src"),
},
},
});

58
go.mod Normal file
View File

@ -0,0 +1,58 @@
module github.com/vstockwell/wraith
go 1.26.1
require (
github.com/google/uuid v1.6.0
github.com/pkg/sftp v1.13.10
github.com/wailsapp/wails/v3 v3.0.0-alpha.74
golang.org/x/crypto v0.49.0
modernc.org/sqlite v1.46.2
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.7.0 // indirect
github.com/go-git/go-git/v5 v5.16.4 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/kevinburke/ssh_config v1.4.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/lmittmann/tint v1.1.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.52.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

197
go.sum Normal file
View File

@ -0,0 +1,197 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/wails/v3 v3.0.0-alpha.74 h1:wRm1EiDQtxDisXk46NtpiBH90STwfKp36NrTDwOEdxw=
github.com/wailsapp/wails/v3 v3.0.0-alpha.74/go.mod h1:4saK4A4K9970X+X7RkMwP2lyGbLogcUz54wVeq4C/V8=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.2 h1:gkXQ6R0+AjxFC/fTDaeIVLbNLNrRoOK7YYVz5BKhTcE=
modernc.org/sqlite v1.46.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

BIN
images/wraith-logo.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
images/wraith-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -1,25 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Wraith</title>
<!-- Prevent any flash of light background before Vue mounts -->
<style>
html,
body {
margin: 0;
padding: 0;
background: #0d1117;
height: 100%;
}
#app {
height: 100%;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

256
internal/ai/client.go Normal file
View File

@ -0,0 +1,256 @@
package ai
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
)
const (
defaultModel = "claude-sonnet-4-20250514"
anthropicVersion = "2023-06-01"
apiBaseURL = "https://api.anthropic.com/v1/messages"
)
// ClaudeClient is the HTTP client for the Anthropic Messages API.
type ClaudeClient struct {
oauth *OAuthManager
apiKey string // fallback for non-OAuth users
model string
httpClient *http.Client
}
// NewClaudeClient creates a client that authenticates via OAuth (primary) or API key (fallback).
func NewClaudeClient(oauth *OAuthManager, model string) *ClaudeClient {
if model == "" {
model = defaultModel
}
return &ClaudeClient{
oauth: oauth,
model: model,
httpClient: &http.Client{},
}
}
// SetAPIKey sets a fallback API key for users without Max subscriptions.
func (c *ClaudeClient) SetAPIKey(key string) {
c.apiKey = key
}
// apiRequest is the JSON body sent to the Messages API.
type apiRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
MaxTokens int `json:"max_tokens"`
Stream bool `json:"stream"`
System string `json:"system,omitempty"`
Tools []Tool `json:"tools,omitempty"`
}
// SendMessage sends a request to the Messages API with streaming enabled.
// It returns a channel of StreamEvents that the caller reads until the channel is closed.
func (c *ClaudeClient) SendMessage(messages []Message, tools []Tool, systemPrompt string) (<-chan StreamEvent, error) {
reqBody := apiRequest{
Model: c.model,
Messages: messages,
MaxTokens: 8192,
Stream: true,
System: systemPrompt,
Tools: tools,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
req, err := http.NewRequest("POST", apiBaseURL, bytes.NewReader(jsonData))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("anthropic-version", anthropicVersion)
if err := c.setAuthHeader(req); err != nil {
return nil, fmt.Errorf("set auth header: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("API returned %d: %s", resp.StatusCode, string(body))
}
ch := make(chan StreamEvent, 64)
go c.parseSSEStream(resp.Body, ch)
return ch, nil
}
// setAuthHeader sets the appropriate authorization header on the request.
func (c *ClaudeClient) setAuthHeader(req *http.Request) error {
// Try OAuth first
if c.oauth != nil && c.oauth.IsAuthenticated() {
token, err := c.oauth.GetAccessToken()
if err == nil {
req.Header.Set("Authorization", "Bearer "+token)
return nil
}
slog.Warn("oauth token failed, falling back to api key", "error", err)
}
// Fallback to API key
if c.apiKey != "" {
req.Header.Set("x-api-key", c.apiKey)
return nil
}
return fmt.Errorf("no authentication method available — log in via OAuth or set an API key")
}
// parseSSEStream reads the SSE response body and sends StreamEvents on the channel.
func (c *ClaudeClient) parseSSEStream(body io.ReadCloser, ch chan<- StreamEvent) {
defer body.Close()
defer close(ch)
scanner := bufio.NewScanner(body)
var currentEventType string
var currentToolID string
var currentToolName string
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
if strings.HasPrefix(line, "event: ") {
currentEventType = strings.TrimPrefix(line, "event: ")
continue
}
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
switch currentEventType {
case "content_block_start":
var block struct {
Index int `json:"index"`
ContentBlock struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
Text string `json:"text"`
Input string `json:"input"`
} `json:"content_block"`
}
if err := json.Unmarshal([]byte(data), &block); err != nil {
slog.Warn("failed to parse content_block_start", "error", err)
continue
}
if block.ContentBlock.Type == "tool_use" {
currentToolID = block.ContentBlock.ID
currentToolName = block.ContentBlock.Name
ch <- StreamEvent{
Type: "tool_use_start",
ToolID: currentToolID,
ToolName: currentToolName,
}
} else if block.ContentBlock.Type == "text" && block.ContentBlock.Text != "" {
ch <- StreamEvent{Type: "text_delta", Data: block.ContentBlock.Text}
}
case "content_block_delta":
var delta struct {
Delta struct {
Type string `json:"type"`
Text string `json:"text"`
PartialJSON string `json:"partial_json"`
} `json:"delta"`
}
if err := json.Unmarshal([]byte(data), &delta); err != nil {
slog.Warn("failed to parse content_block_delta", "error", err)
continue
}
if delta.Delta.Type == "text_delta" {
ch <- StreamEvent{Type: "text_delta", Data: delta.Delta.Text}
} else if delta.Delta.Type == "input_json_delta" {
ch <- StreamEvent{
Type: "tool_use_delta",
Data: delta.Delta.PartialJSON,
ToolID: currentToolID,
ToolName: currentToolName,
}
}
case "content_block_stop":
// nothing to do; tool input is accumulated by the caller
case "message_delta":
// contains stop_reason and usage; emit usage info
ch <- StreamEvent{Type: "done", Data: data}
case "message_stop":
// end of message stream
return
case "message_start":
// contains message metadata; could extract usage but we handle it at message_delta
case "ping":
// heartbeat; ignore
case "error":
ch <- StreamEvent{Type: "error", Data: data}
return
}
}
if err := scanner.Err(); err != nil {
ch <- StreamEvent{Type: "error", Data: err.Error()}
}
}
// BuildRequestBody creates the JSON request body for testing purposes.
func BuildRequestBody(messages []Message, tools []Tool, systemPrompt, model string) ([]byte, error) {
if model == "" {
model = defaultModel
}
req := apiRequest{
Model: model,
Messages: messages,
MaxTokens: 8192,
Stream: true,
System: systemPrompt,
Tools: tools,
}
return json.Marshal(req)
}
// ParseSSELine parses a single SSE data line. Returns the event type and data payload.
func ParseSSELine(line string) (eventType, data string) {
if strings.HasPrefix(line, "event: ") {
return "event", strings.TrimPrefix(line, "event: ")
}
if strings.HasPrefix(line, "data: ") {
return "data", strings.TrimPrefix(line, "data: ")
}
return "", ""
}

123
internal/ai/client_test.go Normal file
View File

@ -0,0 +1,123 @@
package ai
import (
"encoding/json"
"net/http"
"testing"
)
func TestBuildRequestBody(t *testing.T) {
messages := []Message{
{
Role: "user",
Content: []ContentBlock{
{Type: "text", Text: "Hello"},
},
},
}
tools := []Tool{
{
Name: "test_tool",
Description: "A test tool",
InputSchema: json.RawMessage(`{"type":"object","properties":{}}`),
},
}
body, err := BuildRequestBody(messages, tools, "You are helpful.", "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var parsed map[string]interface{}
if err := json.Unmarshal(body, &parsed); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
// Check model
if m, ok := parsed["model"].(string); !ok || m != defaultModel {
t.Errorf("expected model %s, got %v", defaultModel, parsed["model"])
}
// Check stream
if s, ok := parsed["stream"].(bool); !ok || !s {
t.Errorf("expected stream true, got %v", parsed["stream"])
}
// Check system prompt
if s, ok := parsed["system"].(string); !ok || s != "You are helpful." {
t.Errorf("expected system prompt, got %v", parsed["system"])
}
// Check max_tokens
if mt, ok := parsed["max_tokens"].(float64); !ok || int(mt) != 8192 {
t.Errorf("expected max_tokens 8192, got %v", parsed["max_tokens"])
}
// Check messages array exists
msgs, ok := parsed["messages"].([]interface{})
if !ok || len(msgs) != 1 {
t.Errorf("expected 1 message, got %v", parsed["messages"])
}
// Check tools array exists
tls, ok := parsed["tools"].([]interface{})
if !ok || len(tls) != 1 {
t.Errorf("expected 1 tool, got %v", parsed["tools"])
}
}
func TestParseSSELine(t *testing.T) {
tests := []struct {
input string
wantType string
wantData string
}{
{"event: content_block_delta", "event", "content_block_delta"},
{"data: {\"delta\":{\"text\":\"hello\"}}", "data", "{\"delta\":{\"text\":\"hello\"}}"},
{"", "", ""},
{"random line", "", ""},
{"event: message_stop", "event", "message_stop"},
{"data: [DONE]", "data", "[DONE]"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
gotType, gotData := ParseSSELine(tt.input)
if gotType != tt.wantType {
t.Errorf("ParseSSELine(%q) type = %q, want %q", tt.input, gotType, tt.wantType)
}
if gotData != tt.wantData {
t.Errorf("ParseSSELine(%q) data = %q, want %q", tt.input, gotData, tt.wantData)
}
})
}
}
func TestAuthHeader(t *testing.T) {
// Test with API key only (no OAuth)
client := NewClaudeClient(nil, "")
client.SetAPIKey("sk-test-12345")
req, _ := http.NewRequest("POST", "https://example.com", nil)
if err := client.setAuthHeader(req); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := req.Header.Get("x-api-key"); got != "sk-test-12345" {
t.Errorf("expected x-api-key sk-test-12345, got %q", got)
}
if got := req.Header.Get("Authorization"); got != "" {
t.Errorf("expected no Authorization header, got %q", got)
}
}
func TestAuthHeaderNoAuth(t *testing.T) {
// No OAuth, no API key — should return error
client := NewClaudeClient(nil, "")
req, _ := http.NewRequest("POST", "https://example.com", nil)
err := client.setAuthHeader(req)
if err == nil {
t.Error("expected error when no auth method available")
}
}

169
internal/ai/conversation.go Normal file
View File

@ -0,0 +1,169 @@
package ai
import (
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
)
// ConversationManager handles CRUD operations for AI conversations stored in SQLite.
type ConversationManager struct {
db *sql.DB
}
// NewConversationManager creates a manager backed by the given database.
func NewConversationManager(db *sql.DB) *ConversationManager {
return &ConversationManager{db: db}
}
// Create starts a new conversation and returns its summary.
func (m *ConversationManager) Create(model string) (*ConversationSummary, error) {
id := uuid.NewString()
now := time.Now()
_, err := m.db.Exec(
`INSERT INTO conversations (id, title, model, messages, tokens_in, tokens_out, created_at, updated_at)
VALUES (?, ?, ?, '[]', 0, 0, ?, ?)`,
id, "New conversation", model, now, now,
)
if err != nil {
return nil, fmt.Errorf("create conversation: %w", err)
}
return &ConversationSummary{
ID: id,
Title: "New conversation",
Model: model,
CreatedAt: now,
TokensIn: 0,
TokensOut: 0,
}, nil
}
// AddMessage appends a message to the conversation's message list.
func (m *ConversationManager) AddMessage(convId string, msg Message) error {
// Get existing messages
messages, err := m.GetMessages(convId)
if err != nil {
return err
}
messages = append(messages, msg)
data, err := json.Marshal(messages)
if err != nil {
return fmt.Errorf("marshal messages: %w", err)
}
_, err = m.db.Exec(
"UPDATE conversations SET messages = ?, updated_at = ? WHERE id = ?",
string(data), time.Now(), convId,
)
if err != nil {
return fmt.Errorf("update messages: %w", err)
}
// Auto-title from first user message
if len(messages) == 1 && msg.Role == "user" {
title := extractTitle(msg)
if title != "" {
m.db.Exec("UPDATE conversations SET title = ? WHERE id = ?", title, convId)
}
}
return nil
}
// GetMessages returns all messages in a conversation.
func (m *ConversationManager) GetMessages(convId string) ([]Message, error) {
var messagesJSON string
err := m.db.QueryRow("SELECT messages FROM conversations WHERE id = ?", convId).Scan(&messagesJSON)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("conversation %s not found", convId)
}
if err != nil {
return nil, fmt.Errorf("get messages: %w", err)
}
var messages []Message
if err := json.Unmarshal([]byte(messagesJSON), &messages); err != nil {
return nil, fmt.Errorf("unmarshal messages: %w", err)
}
return messages, nil
}
// List returns all conversations ordered by most recent.
func (m *ConversationManager) List() ([]ConversationSummary, error) {
rows, err := m.db.Query(
`SELECT id, title, model, tokens_in, tokens_out, created_at
FROM conversations ORDER BY updated_at DESC`,
)
if err != nil {
return nil, fmt.Errorf("list conversations: %w", err)
}
defer rows.Close()
var summaries []ConversationSummary
for rows.Next() {
var s ConversationSummary
var title sql.NullString
if err := rows.Scan(&s.ID, &title, &s.Model, &s.TokensIn, &s.TokensOut, &s.CreatedAt); err != nil {
return nil, fmt.Errorf("scan conversation: %w", err)
}
if title.Valid {
s.Title = title.String
}
summaries = append(summaries, s)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate conversations: %w", err)
}
if summaries == nil {
summaries = []ConversationSummary{}
}
return summaries, nil
}
// Delete removes a conversation and all its messages.
func (m *ConversationManager) Delete(convId string) error {
result, err := m.db.Exec("DELETE FROM conversations WHERE id = ?", convId)
if err != nil {
return fmt.Errorf("delete conversation: %w", err)
}
affected, _ := result.RowsAffected()
if affected == 0 {
return fmt.Errorf("conversation %s not found", convId)
}
return nil
}
// UpdateTokenUsage adds to the token counters for a conversation.
func (m *ConversationManager) UpdateTokenUsage(convId string, tokensIn, tokensOut int) error {
_, err := m.db.Exec(
`UPDATE conversations
SET tokens_in = tokens_in + ?, tokens_out = tokens_out + ?, updated_at = ?
WHERE id = ?`,
tokensIn, tokensOut, time.Now(), convId,
)
if err != nil {
return fmt.Errorf("update token usage: %w", err)
}
return nil
}
// extractTitle generates a title from the first user message (truncated to 80 chars).
func extractTitle(msg Message) string {
for _, block := range msg.Content {
if block.Type == "text" && block.Text != "" {
title := block.Text
if len(title) > 80 {
title = title[:77] + "..."
}
return title
}
}
return ""
}

View File

@ -0,0 +1,220 @@
package ai
import (
"path/filepath"
"testing"
"github.com/vstockwell/wraith/internal/db"
)
func setupConversationManager(t *testing.T) *ConversationManager {
t.Helper()
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.Migrate(database); err != nil {
t.Fatalf("migrate: %v", err)
}
// Create the conversations table (002 migration)
_, err = database.Exec(`CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY, title TEXT, model TEXT NOT NULL,
messages TEXT NOT NULL DEFAULT '[]',
tokens_in INTEGER DEFAULT 0, tokens_out INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP)`)
if err != nil {
t.Fatalf("create conversations table: %v", err)
}
t.Cleanup(func() { database.Close() })
return NewConversationManager(database)
}
func TestCreateConversation(t *testing.T) {
mgr := setupConversationManager(t)
conv, err := mgr.Create("claude-sonnet-4-20250514")
if err != nil {
t.Fatalf("create: %v", err)
}
if conv.ID == "" {
t.Error("expected non-empty ID")
}
if conv.Model != "claude-sonnet-4-20250514" {
t.Errorf("expected model claude-sonnet-4-20250514, got %s", conv.Model)
}
if conv.Title != "New conversation" {
t.Errorf("expected title 'New conversation', got %s", conv.Title)
}
if conv.TokensIn != 0 || conv.TokensOut != 0 {
t.Errorf("expected zero tokens, got in=%d out=%d", conv.TokensIn, conv.TokensOut)
}
}
func TestAddAndGetMessages(t *testing.T) {
mgr := setupConversationManager(t)
conv, err := mgr.Create("test-model")
if err != nil {
t.Fatalf("create: %v", err)
}
// Add a user message
userMsg := Message{
Role: "user",
Content: []ContentBlock{
{Type: "text", Text: "What is running on port 8080?"},
},
}
if err := mgr.AddMessage(conv.ID, userMsg); err != nil {
t.Fatalf("add user message: %v", err)
}
// Add an assistant message
assistantMsg := Message{
Role: "assistant",
Content: []ContentBlock{
{Type: "text", Text: "Let me check that for you."},
},
}
if err := mgr.AddMessage(conv.ID, assistantMsg); err != nil {
t.Fatalf("add assistant message: %v", err)
}
// Get messages
messages, err := mgr.GetMessages(conv.ID)
if err != nil {
t.Fatalf("get messages: %v", err)
}
if len(messages) != 2 {
t.Fatalf("expected 2 messages, got %d", len(messages))
}
if messages[0].Role != "user" {
t.Errorf("expected first message role 'user', got %s", messages[0].Role)
}
if messages[1].Role != "assistant" {
t.Errorf("expected second message role 'assistant', got %s", messages[1].Role)
}
if messages[0].Content[0].Text != "What is running on port 8080?" {
t.Errorf("unexpected message text: %s", messages[0].Content[0].Text)
}
}
func TestListConversations(t *testing.T) {
mgr := setupConversationManager(t)
// Create multiple conversations
_, err := mgr.Create("model-a")
if err != nil {
t.Fatalf("create 1: %v", err)
}
_, err = mgr.Create("model-b")
if err != nil {
t.Fatalf("create 2: %v", err)
}
list, err := mgr.List()
if err != nil {
t.Fatalf("list: %v", err)
}
if len(list) != 2 {
t.Errorf("expected 2 conversations, got %d", len(list))
}
}
func TestDeleteConversation(t *testing.T) {
mgr := setupConversationManager(t)
conv, err := mgr.Create("test-model")
if err != nil {
t.Fatalf("create: %v", err)
}
if err := mgr.Delete(conv.ID); err != nil {
t.Fatalf("delete: %v", err)
}
// Verify it's gone
list, err := mgr.List()
if err != nil {
t.Fatalf("list: %v", err)
}
if len(list) != 0 {
t.Errorf("expected 0 conversations after delete, got %d", len(list))
}
// Delete non-existent should error
if err := mgr.Delete("nonexistent"); err == nil {
t.Error("expected error deleting non-existent conversation")
}
}
func TestTokenUsageTracking(t *testing.T) {
mgr := setupConversationManager(t)
conv, err := mgr.Create("test-model")
if err != nil {
t.Fatalf("create: %v", err)
}
// Update token usage multiple times
if err := mgr.UpdateTokenUsage(conv.ID, 100, 50); err != nil {
t.Fatalf("update tokens 1: %v", err)
}
if err := mgr.UpdateTokenUsage(conv.ID, 200, 100); err != nil {
t.Fatalf("update tokens 2: %v", err)
}
// Verify totals
list, err := mgr.List()
if err != nil {
t.Fatalf("list: %v", err)
}
if len(list) != 1 {
t.Fatalf("expected 1 conversation, got %d", len(list))
}
if list[0].TokensIn != 300 {
t.Errorf("expected 300 tokens in, got %d", list[0].TokensIn)
}
if list[0].TokensOut != 150 {
t.Errorf("expected 150 tokens out, got %d", list[0].TokensOut)
}
}
func TestGetMessagesNonExistent(t *testing.T) {
mgr := setupConversationManager(t)
_, err := mgr.GetMessages("nonexistent-id")
if err == nil {
t.Error("expected error for non-existent conversation")
}
}
func TestAutoTitle(t *testing.T) {
mgr := setupConversationManager(t)
conv, err := mgr.Create("test-model")
if err != nil {
t.Fatalf("create: %v", err)
}
msg := Message{
Role: "user",
Content: []ContentBlock{
{Type: "text", Text: "Check disk usage on server-01"},
},
}
if err := mgr.AddMessage(conv.ID, msg); err != nil {
t.Fatalf("add message: %v", err)
}
// Verify the title was auto-set
list, err := mgr.List()
if err != nil {
t.Fatalf("list: %v", err)
}
if list[0].Title != "Check disk usage on server-01" {
t.Errorf("expected auto-title, got %q", list[0].Title)
}
}

473
internal/ai/oauth.go Normal file
View File

@ -0,0 +1,473 @@
package ai
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"sync"
"time"
"github.com/vstockwell/wraith/internal/settings"
"github.com/vstockwell/wraith/internal/vault"
)
const (
oauthClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
oauthAuthorizeURL = "https://claude.ai/oauth/authorize"
oauthTokenURL = "https://platform.claude.com/v1/oauth/token"
)
// OAuthManager handles OAuth PKCE authentication for Claude Max subscriptions.
type OAuthManager struct {
settings *settings.SettingsService
vault *vault.VaultService
clientID string
authorizeURL string
tokenURL string
mu sync.RWMutex
}
// tokenResponse is the JSON body returned by the token endpoint.
type tokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
}
// NewOAuthManager creates an OAuthManager wired to settings and vault services.
func NewOAuthManager(s *settings.SettingsService, v *vault.VaultService) *OAuthManager {
return &OAuthManager{
settings: s,
vault: v,
clientID: oauthClientID,
authorizeURL: oauthAuthorizeURL,
tokenURL: oauthTokenURL,
}
}
// StartLogin begins the OAuth PKCE flow. It starts a local HTTP server,
// opens the authorization URL in the user's browser, and returns a channel
// that receives nil on success or an error on failure.
func (o *OAuthManager) StartLogin(openURL func(string) error) (<-chan error, error) {
verifier, err := generateCodeVerifier()
if err != nil {
return nil, fmt.Errorf("generate code verifier: %w", err)
}
challenge := generateCodeChallenge(verifier)
state, err := generateState()
if err != nil {
return nil, fmt.Errorf("generate state: %w", err)
}
// Start a local HTTP server on a random port for the callback
listener, err := net.Listen("tcp", "localhost:0")
if err != nil {
return nil, fmt.Errorf("listen for callback: %w", err)
}
port := listener.Addr().(*net.TCPAddr).Port
redirectURI := fmt.Sprintf("http://localhost:%d/callback", port)
done := make(chan error, 1)
mux := http.NewServeMux()
server := &http.Server{Handler: mux}
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
defer func() {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
server.Shutdown(ctx)
}()
}()
// Verify state
if r.URL.Query().Get("state") != state {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, "State mismatch — please try logging in again.")
done <- fmt.Errorf("state mismatch")
return
}
if errParam := r.URL.Query().Get("error"); errParam != "" {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "Authorization error: %s", errParam)
done <- fmt.Errorf("authorization error: %s", errParam)
return
}
code := r.URL.Query().Get("code")
if code == "" {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, "No authorization code received.")
done <- fmt.Errorf("no authorization code")
return
}
// Exchange code for tokens
if err := o.exchangeCode(code, verifier, redirectURI); err != nil {
slog.Error("oauth token exchange failed", "error", err)
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, "<html><body><h2>Authentication Failed</h2><pre>%s</pre><p>Check Wraith logs for details.</p></body></html>", err.Error())
done <- fmt.Errorf("exchange code: %w", err)
return
}
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<html><body><h2>Authenticated!</h2><p>You can close this window and return to Wraith.</p></body></html>`)
done <- nil
})
go func() {
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
slog.Error("oauth callback server error", "error", err)
done <- fmt.Errorf("callback server: %w", err)
}
}()
// Build the authorize URL
params := url.Values{
"code": {"true"},
"response_type": {"code"},
"client_id": {o.clientID},
"redirect_uri": {redirectURI},
"scope": {"org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers"},
"state": {state},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
}
authURL := o.authorizeURL + "?" + params.Encode()
if openURL != nil {
if err := openURL(authURL); err != nil {
listener.Close()
return nil, fmt.Errorf("open browser: %w", err)
}
}
return done, nil
}
// exchangeCode exchanges an authorization code for access and refresh tokens.
func (o *OAuthManager) exchangeCode(code, verifier, redirectURI string) error {
// Try JSON format first (what Claude Code appears to use), fall back to form-encoded
payload := map[string]string{
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirectURI,
"client_id": o.clientID,
"code_verifier": verifier,
}
jsonBody, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal token request: %w", err)
}
slog.Info("exchanging auth code", "tokenURL", o.tokenURL, "redirectURI", redirectURI)
req, err := http.NewRequest("POST", o.tokenURL, io.NopCloser(
bytes.NewReader(jsonBody),
))
if err != nil {
return fmt.Errorf("create token request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("post token request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read token response: %w", err)
}
slog.Info("token endpoint response", "status", resp.StatusCode, "body", string(body)[:min(len(body), 500)])
if resp.StatusCode != http.StatusOK {
// If JSON failed, try form-encoded
slog.Info("JSON token exchange failed, trying form-encoded")
data := url.Values{
"grant_type": {"authorization_code"},
"code": {code},
"redirect_uri": {redirectURI},
"client_id": {o.clientID},
"code_verifier": {verifier},
}
resp2, err := http.PostForm(o.tokenURL, data)
if err != nil {
return fmt.Errorf("post form token request: %w", err)
}
defer resp2.Body.Close()
body, err = io.ReadAll(resp2.Body)
if err != nil {
return fmt.Errorf("read form token response: %w", err)
}
slog.Info("form-encoded token response", "status", resp2.StatusCode, "body", string(body)[:min(len(body), 500)])
if resp2.StatusCode != http.StatusOK {
return fmt.Errorf("token endpoint returned %d (json) and %d (form): %s", resp.StatusCode, resp2.StatusCode, string(body))
}
}
var tokens tokenResponse
if err := json.Unmarshal(body, &tokens); err != nil {
return fmt.Errorf("unmarshal token response: %w", err)
}
return o.storeTokens(tokens)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// storeTokens encrypts and persists OAuth tokens in settings.
func (o *OAuthManager) storeTokens(tokens tokenResponse) error {
o.mu.Lock()
defer o.mu.Unlock()
if o.vault == nil {
return fmt.Errorf("vault is not unlocked — cannot store tokens")
}
encAccess, err := o.vault.Encrypt(tokens.AccessToken)
if err != nil {
return fmt.Errorf("encrypt access token: %w", err)
}
if err := o.settings.Set("ai_access_token", encAccess); err != nil {
return fmt.Errorf("store access token: %w", err)
}
encRefresh, err := o.vault.Encrypt(tokens.RefreshToken)
if err != nil {
return fmt.Errorf("encrypt refresh token: %w", err)
}
if err := o.settings.Set("ai_refresh_token", encRefresh); err != nil {
return fmt.Errorf("store refresh token: %w", err)
}
expiresAt := time.Now().Add(time.Duration(tokens.ExpiresIn) * time.Second).Unix()
if err := o.settings.Set("ai_token_expires_at", strconv.FormatInt(expiresAt, 10)); err != nil {
return fmt.Errorf("store token expiry: %w", err)
}
slog.Info("oauth tokens stored successfully")
return nil
}
// GetAccessToken returns a valid access token, refreshing if expired.
func (o *OAuthManager) GetAccessToken() (string, error) {
o.mu.RLock()
vlt := o.vault
o.mu.RUnlock()
if vlt == nil {
return "", fmt.Errorf("vault is not unlocked")
}
// Check expiry
expiresStr, _ := o.settings.Get("ai_token_expires_at")
if expiresStr != "" {
expiresAt, _ := strconv.ParseInt(expiresStr, 10, 64)
if time.Now().Unix() >= expiresAt {
// Token expired, try to refresh
if err := o.refreshToken(); err != nil {
return "", fmt.Errorf("refresh expired token: %w", err)
}
}
}
encAccess, err := o.settings.Get("ai_access_token")
if err != nil || encAccess == "" {
return "", fmt.Errorf("no access token stored")
}
accessToken, err := vlt.Decrypt(encAccess)
if err != nil {
return "", fmt.Errorf("decrypt access token: %w", err)
}
return accessToken, nil
}
// refreshToken uses the stored refresh token to obtain new tokens.
func (o *OAuthManager) refreshToken() error {
o.mu.RLock()
vlt := o.vault
o.mu.RUnlock()
if vlt == nil {
return fmt.Errorf("vault is not unlocked")
}
encRefresh, err := o.settings.Get("ai_refresh_token")
if err != nil || encRefresh == "" {
return fmt.Errorf("no refresh token stored")
}
refreshTok, err := vlt.Decrypt(encRefresh)
if err != nil {
return fmt.Errorf("decrypt refresh token: %w", err)
}
data := url.Values{
"grant_type": {"refresh_token"},
"refresh_token": {refreshTok},
"client_id": {o.clientID},
}
resp, err := http.PostForm(o.tokenURL, data)
if err != nil {
return fmt.Errorf("post refresh request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("read refresh response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("token refresh returned %d: %s", resp.StatusCode, string(body))
}
var tokens tokenResponse
if err := json.Unmarshal(body, &tokens); err != nil {
return fmt.Errorf("unmarshal refresh response: %w", err)
}
// If the refresh endpoint doesn't return a new refresh token, keep the old one
if tokens.RefreshToken == "" {
tokens.RefreshToken = refreshTok
}
return o.storeTokens(tokens)
}
// IsAuthenticated checks if we have stored tokens.
func (o *OAuthManager) IsAuthenticated() bool {
encAccess, err := o.settings.Get("ai_access_token")
return err == nil && encAccess != ""
}
// Logout clears all stored OAuth tokens.
func (o *OAuthManager) Logout() error {
for _, key := range []string{"ai_access_token", "ai_refresh_token", "ai_token_expires_at"} {
if err := o.settings.Delete(key); err != nil {
return fmt.Errorf("delete %s: %w", key, err)
}
}
return nil
}
// generateCodeVerifier creates a PKCE code verifier (32 random bytes, base64url no padding).
func generateCodeVerifier() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// generateCodeChallenge creates a PKCE S256 code challenge from a verifier.
func generateCodeChallenge(verifier string) string {
h := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(h[:])
}
// generateState creates a random state parameter (32 random bytes, base64url no padding).
func generateState() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// ImportClaudeCodeToken reads the Claude Code credentials.json file and imports
// the OAuth tokens. This is the fallback when Wraith's own OAuth flow fails.
// Path: %USERPROFILE%\.claude\.credentials.json (Windows) or ~/.claude/.credentials.json (macOS/Linux)
func (o *OAuthManager) ImportClaudeCodeToken() error {
home := os.Getenv("USERPROFILE")
if home == "" {
var err error
home, err = os.UserHomeDir()
if err != nil {
return fmt.Errorf("find home directory: %w", err)
}
}
credPath := filepath.Join(home, ".claude", ".credentials.json")
data, err := os.ReadFile(credPath)
if err != nil {
return fmt.Errorf("read credentials.json at %s: %w", credPath, err)
}
var creds struct {
ClaudeAiOAuth struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpiresAt int64 `json:"expiresAt"`
} `json:"claudeAiOauth"`
}
if err := json.Unmarshal(data, &creds); err != nil {
return fmt.Errorf("parse credentials.json: %w", err)
}
if creds.ClaudeAiOAuth.AccessToken == "" {
return fmt.Errorf("no access token found in credentials.json")
}
// Convert millisecond timestamp to seconds-based ExpiresIn
expiresIn := int((creds.ClaudeAiOAuth.ExpiresAt / 1000) - time.Now().Unix())
if expiresIn < 0 {
expiresIn = 0
}
tokens := tokenResponse{
AccessToken: creds.ClaudeAiOAuth.AccessToken,
RefreshToken: creds.ClaudeAiOAuth.RefreshToken,
ExpiresIn: expiresIn,
}
slog.Info("imported Claude Code token", "expiresInSeconds", expiresIn)
return o.storeTokens(tokens)
}
// SetVault updates the vault reference (called after vault unlock).
func (o *OAuthManager) SetVault(v *vault.VaultService) {
o.mu.Lock()
defer o.mu.Unlock()
o.vault = v
}
// isBase64URL checks if a string contains only base64url characters (no padding).
func isBase64URL(s string) bool {
for _, c := range s {
if !((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_') {
return false
}
}
return true
}

91
internal/ai/oauth_test.go Normal file
View File

@ -0,0 +1,91 @@
package ai
import (
"crypto/sha256"
"encoding/base64"
"path/filepath"
"testing"
"github.com/vstockwell/wraith/internal/db"
"github.com/vstockwell/wraith/internal/settings"
"github.com/vstockwell/wraith/internal/vault"
)
func TestGenerateCodeVerifier(t *testing.T) {
v, err := generateCodeVerifier()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// 32 bytes -> 43 base64url chars (no padding)
if len(v) != 43 {
t.Errorf("expected verifier length 43, got %d", len(v))
}
if !isBase64URL(v) {
t.Errorf("verifier contains non-base64url characters: %s", v)
}
// Should be different each time
v2, _ := generateCodeVerifier()
if v == v2 {
t.Error("two generated verifiers should not be identical")
}
}
func TestGenerateCodeChallenge(t *testing.T) {
verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
challenge := generateCodeChallenge(verifier)
// Verify it matches a manually computed S256 hash
h := sha256.Sum256([]byte(verifier))
expected := base64.RawURLEncoding.EncodeToString(h[:])
if challenge != expected {
t.Errorf("expected challenge %s, got %s", expected, challenge)
}
// Same verifier should produce the same challenge (deterministic)
challenge2 := generateCodeChallenge(verifier)
if challenge != challenge2 {
t.Error("challenge should be deterministic for the same verifier")
}
}
func TestGenerateState(t *testing.T) {
s, err := generateState()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// 32 bytes -> 43 base64url chars (no padding)
if len(s) != 43 {
t.Errorf("expected state length 43, got %d", len(s))
}
if !isBase64URL(s) {
t.Errorf("state contains non-base64url characters: %s", s)
}
}
func TestIsAuthenticatedWhenNoTokens(t *testing.T) {
database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.Migrate(database); err != nil {
t.Fatalf("migrate: %v", err)
}
t.Cleanup(func() { database.Close() })
settingsSvc := settings.NewSettingsService(database)
key := vault.DeriveKey("test-password", []byte("test-salt-1234567890123456789012"))
vaultSvc := vault.NewVaultService(key)
oauth := NewOAuthManager(settingsSvc, vaultSvc)
if oauth.IsAuthenticated() {
t.Error("expected IsAuthenticated to return false when no tokens are stored")
}
}

564
internal/ai/router.go Normal file
View File

@ -0,0 +1,564 @@
package ai
import (
"encoding/json"
"fmt"
)
// ToolRouter dispatches tool calls to the appropriate service.
// Services are stored as interface{} to avoid circular imports between packages.
type ToolRouter struct {
ssh interface{} // *ssh.SSHService
sftp interface{} // *sftp.SFTPService
rdp interface{} // *rdp.RDPService
sessions interface{} // *session.Manager
connections interface{} // *connections.ConnectionService
aiService interface{} // *AIService — for terminal buffer access
}
// NewToolRouter creates an empty ToolRouter. Call SetServices to wire in backends.
func NewToolRouter() *ToolRouter {
return &ToolRouter{}
}
// SetServices wires the router to actual service implementations.
func (r *ToolRouter) SetServices(ssh, sftp, rdp, sessions, connections interface{}) {
r.ssh = ssh
r.sftp = sftp
r.rdp = rdp
r.sessions = sessions
r.connections = connections
}
// SetAIService wires the router to the AI service for terminal buffer access.
func (r *ToolRouter) SetAIService(aiService interface{}) {
r.aiService = aiService
}
// sshWriter is the interface we need from SSHService for terminal_write.
type sshWriter interface {
Write(sessionID string, data string) error
}
// sshSessionLister is the interface for listing SSH sessions.
type sshSessionLister interface {
ListSessions() interface{}
}
// sftpLister is the interface for SFTP list operations.
type sftpLister interface {
List(sessionID string, path string) (interface{}, error)
}
// sftpReader is the interface for SFTP read operations.
type sftpReader interface {
ReadFile(sessionID string, path string) (string, error)
}
// sftpWriter is the interface for SFTP write operations.
type sftpWriter interface {
WriteFile(sessionID string, path string, content string) error
}
// rdpFrameGetter is the interface for getting RDP screenshots.
type rdpFrameGetter interface {
GetFrame(sessionID string) ([]byte, error)
GetSessionInfo(sessionID string) (interface{}, error)
}
// rdpMouseSender is the interface for RDP mouse events.
type rdpMouseSender interface {
SendMouse(sessionID string, x, y int, flags uint32) error
}
// rdpKeySender is the interface for RDP key events.
type rdpKeySender interface {
SendKey(sessionID string, scancode uint32, pressed bool) error
}
// rdpClipboardSender is the interface for RDP clipboard.
type rdpClipboardSender interface {
SendClipboard(sessionID string, data string) error
}
// rdpSessionLister is the interface for listing RDP sessions.
type rdpSessionLister interface {
ListSessions() interface{}
}
// sessionLister is the interface for listing sessions.
type sessionLister interface {
List() interface{}
}
// bufferProvider provides terminal output buffers.
type bufferProvider interface {
GetBuffer(sessionId string) *TerminalBuffer
}
// Dispatch routes a tool call to the appropriate handler.
func (r *ToolRouter) Dispatch(toolName string, input json.RawMessage) (interface{}, error) {
switch toolName {
case "terminal_write":
return r.handleTerminalWrite(input)
case "terminal_read":
return r.handleTerminalRead(input)
case "terminal_cwd":
return r.handleTerminalCwd(input)
case "sftp_list":
return r.handleSFTPList(input)
case "sftp_read":
return r.handleSFTPRead(input)
case "sftp_write":
return r.handleSFTPWrite(input)
case "rdp_screenshot":
return r.handleRDPScreenshot(input)
case "rdp_click":
return r.handleRDPClick(input)
case "rdp_doubleclick":
return r.handleRDPDoubleClick(input)
case "rdp_type":
return r.handleRDPType(input)
case "rdp_keypress":
return r.handleRDPKeypress(input)
case "rdp_scroll":
return r.handleRDPScroll(input)
case "rdp_move":
return r.handleRDPMove(input)
case "list_sessions":
return r.handleListSessions(input)
case "connect_ssh":
return r.handleConnectSSH(input)
case "disconnect":
return r.handleDisconnect(input)
default:
return nil, fmt.Errorf("unknown tool: %s", toolName)
}
}
func (r *ToolRouter) handleTerminalWrite(input json.RawMessage) (interface{}, error) {
var params struct {
SessionID string `json:"sessionId"`
Text string `json:"text"`
}
if err := json.Unmarshal(input, &params); err != nil {
return nil, fmt.Errorf("parse input: %w", err)
}
w, ok := r.ssh.(sshWriter)
if !ok || r.ssh == nil {
return nil, fmt.Errorf("SSH service not available")
}
if err := w.Write(params.SessionID, params.Text); err != nil {
return nil, err
}
return map[string]string{"status": "ok"}, nil
}
func (r *ToolRouter) handleTerminalRead(input json.RawMessage) (interface{}, error) {
var params struct {
SessionID string `json:"sessionId"`
Lines int `json:"lines"`
}
if err := json.Unmarshal(input, &params); err != nil {
return nil, fmt.Errorf("parse input: %w", err)
}
if params.Lines <= 0 {
params.Lines = 50
}
bp, ok := r.aiService.(bufferProvider)
if !ok || r.aiService == nil {
return nil, fmt.Errorf("terminal buffer not available")
}
buf := bp.GetBuffer(params.SessionID)
lines := buf.ReadLast(params.Lines)
return map[string]interface{}{
"lines": lines,
"count": len(lines),
}, nil
}
func (r *ToolRouter) handleTerminalCwd(input json.RawMessage) (interface{}, error) {
var params struct {
SessionID string `json:"sessionId"`
}
if err := json.Unmarshal(input, &params); err != nil {
return nil, fmt.Errorf("parse input: %w", err)
}
// terminal_cwd works by writing "pwd" to the terminal and reading output
w, ok := r.ssh.(sshWriter)
if !ok || r.ssh == nil {
return nil, fmt.Errorf("SSH service not available")
}
if err := w.Write(params.SessionID, "pwd\n"); err != nil {
return nil, fmt.Errorf("send pwd: %w", err)
}
return map[string]string{
"status": "pwd command sent — read terminal output for result",
}, nil
}
func (r *ToolRouter) handleSFTPList(input json.RawMessage) (interface{}, error) {
var params struct {
SessionID string `json:"sessionId"`
Path string `json:"path"`
}
if err := json.Unmarshal(input, &params); err != nil {
return nil, fmt.Errorf("parse input: %w", err)
}
l, ok := r.sftp.(sftpLister)
if !ok || r.sftp == nil {
return nil, fmt.Errorf("SFTP service not available")
}
return l.List(params.SessionID, params.Path)
}
func (r *ToolRouter) handleSFTPRead(input json.RawMessage) (interface{}, error) {
var params struct {
SessionID string `json:"sessionId"`
Path string `json:"path"`
}
if err := json.Unmarshal(input, &params); err != nil {
return nil, fmt.Errorf("parse input: %w", err)
}
reader, ok := r.sftp.(sftpReader)
if !ok || r.sftp == nil {
return nil, fmt.Errorf("SFTP service not available")
}
content, err := reader.ReadFile(params.SessionID, params.Path)
if err != nil {
return nil, err
}
return map[string]string{"content": content}, nil
}
func (r *ToolRouter) handleSFTPWrite(input json.RawMessage) (interface{}, error) {
var params struct {
SessionID string `json:"sessionId"`
Path string `json:"path"`
Content string `json:"content"`
}
if err := json.Unmarshal(input, &params); err != nil {
return nil, fmt.Errorf("parse input: %w", err)
}
w, ok := r.sftp.(sftpWriter)
if !ok || r.sftp == nil {
return nil, fmt.Errorf("SFTP service not available")
}
if err := w.WriteFile(params.SessionID, params.Path, params.Content); err != nil {
return nil, err
}
return map[string]string{"status": "ok"}, nil
}
func (r *ToolRouter) handleRDPScreenshot(input json.RawMessage) (interface{}, error) {
var params struct {
SessionID string `json:"sessionId"`
}
if err := json.Unmarshal(input, &params); err != nil {
return nil, fmt.Errorf("parse input: %w", err)
}
fg, ok := r.rdp.(rdpFrameGetter)
if !ok || r.rdp == nil {
return nil, fmt.Errorf("RDP service not available")
}
frame, err := fg.GetFrame(params.SessionID)
if err != nil {
return nil, err
}
// Get session info for dimensions
info, err := fg.GetSessionInfo(params.SessionID)
if err != nil {
return nil, err
}
// Try to get dimensions from session config
type configGetter interface {
GetConfig() (int, int)
}
width, height := 1920, 1080
if cg, ok := info.(configGetter); ok {
width, height = cg.GetConfig()
}
// Encode as JPEG
jpeg, err := EncodeScreenshot(frame, width, height, 1280, 720, 75)
if err != nil {
return nil, fmt.Errorf("encode screenshot: %w", err)
}
return map[string]interface{}{
"image": jpeg,
"width": width,
"height": height,
}, nil
}
func (r *ToolRouter) handleRDPClick(input json.RawMessage) (interface{}, error) {
var params struct {
SessionID string `json:"sessionId"`
X int `json:"x"`
Y int `json:"y"`
Button string `json:"button"`
}
if err := json.Unmarshal(input, &params); err != nil {
return nil, fmt.Errorf("parse input: %w", err)
}
ms, ok := r.rdp.(rdpMouseSender)
if !ok || r.rdp == nil {
return nil, fmt.Errorf("RDP service not available")
}
var buttonFlag uint32 = 0x1000 // left
switch params.Button {
case "right":
buttonFlag = 0x2000
case "middle":
buttonFlag = 0x4000
}
// Press
if err := ms.SendMouse(params.SessionID, params.X, params.Y, buttonFlag|0x8000); err != nil {
return nil, err
}
// Release
if err := ms.SendMouse(params.SessionID, params.X, params.Y, buttonFlag); err != nil {
return nil, err
}
return map[string]string{"status": "ok"}, nil
}
func (r *ToolRouter) handleRDPDoubleClick(input json.RawMessage) (interface{}, error) {
var params struct {
SessionID string `json:"sessionId"`
X int `json:"x"`
Y int `json:"y"`
}
if err := json.Unmarshal(input, &params); err != nil {
return nil, fmt.Errorf("parse input: %w", err)
}
ms, ok := r.rdp.(rdpMouseSender)
if !ok || r.rdp == nil {
return nil, fmt.Errorf("RDP service not available")
}
// Two clicks
for i := 0; i < 2; i++ {
if err := ms.SendMouse(params.SessionID, params.X, params.Y, 0x1000|0x8000); err != nil {
return nil, err
}
if err := ms.SendMouse(params.SessionID, params.X, params.Y, 0x1000); err != nil {
return nil, err
}
}
return map[string]string{"status": "ok"}, nil
}
func (r *ToolRouter) handleRDPType(input json.RawMessage) (interface{}, error) {
var params struct {
SessionID string `json:"sessionId"`
Text string `json:"text"`
}
if err := json.Unmarshal(input, &params); err != nil {
return nil, fmt.Errorf("parse input: %w", err)
}
cs, ok := r.rdp.(rdpClipboardSender)
if !ok || r.rdp == nil {
return nil, fmt.Errorf("RDP service not available")
}
// Send text via clipboard, then simulate Ctrl+V
if err := cs.SendClipboard(params.SessionID, params.Text); err != nil {
return nil, err
}
ks, ok := r.rdp.(rdpKeySender)
if !ok {
return nil, fmt.Errorf("RDP key service not available")
}
// Ctrl down, V down, V up, Ctrl up
if err := ks.SendKey(params.SessionID, 0x001D, true); err != nil {
return nil, err
}
if err := ks.SendKey(params.SessionID, 0x002F, true); err != nil {
return nil, err
}
if err := ks.SendKey(params.SessionID, 0x002F, false); err != nil {
return nil, err
}
if err := ks.SendKey(params.SessionID, 0x001D, false); err != nil {
return nil, err
}
return map[string]string{"status": "ok"}, nil
}
func (r *ToolRouter) handleRDPKeypress(input json.RawMessage) (interface{}, error) {
var params struct {
SessionID string `json:"sessionId"`
Key string `json:"key"`
}
if err := json.Unmarshal(input, &params); err != nil {
return nil, fmt.Errorf("parse input: %w", err)
}
ks, ok := r.rdp.(rdpKeySender)
if !ok || r.rdp == nil {
return nil, fmt.Errorf("RDP service not available")
}
// Simple key name to scancode mapping for common keys
keyMap := map[string]uint32{
"Enter": 0x001C,
"Tab": 0x000F,
"Escape": 0x0001,
"Backspace": 0x000E,
"Delete": 0xE053,
"Space": 0x0039,
"Up": 0xE048,
"Down": 0xE050,
"Left": 0xE04B,
"Right": 0xE04D,
}
scancode, ok := keyMap[params.Key]
if !ok {
return nil, fmt.Errorf("unknown key: %s", params.Key)
}
if err := ks.SendKey(params.SessionID, scancode, true); err != nil {
return nil, err
}
if err := ks.SendKey(params.SessionID, scancode, false); err != nil {
return nil, err
}
return map[string]string{"status": "ok"}, nil
}
func (r *ToolRouter) handleRDPScroll(input json.RawMessage) (interface{}, error) {
var params struct {
SessionID string `json:"sessionId"`
X int `json:"x"`
Y int `json:"y"`
Direction string `json:"direction"`
Clicks int `json:"clicks"`
}
if err := json.Unmarshal(input, &params); err != nil {
return nil, fmt.Errorf("parse input: %w", err)
}
if params.Clicks <= 0 {
params.Clicks = 3
}
ms, ok := r.rdp.(rdpMouseSender)
if !ok || r.rdp == nil {
return nil, fmt.Errorf("RDP service not available")
}
var flags uint32 = 0x0200 // wheel flag
if params.Direction == "down" {
flags |= 0x0100 // negative flag
}
for i := 0; i < params.Clicks; i++ {
if err := ms.SendMouse(params.SessionID, params.X, params.Y, flags); err != nil {
return nil, err
}
}
return map[string]string{"status": "ok"}, nil
}
func (r *ToolRouter) handleRDPMove(input json.RawMessage) (interface{}, error) {
var params struct {
SessionID string `json:"sessionId"`
X int `json:"x"`
Y int `json:"y"`
}
if err := json.Unmarshal(input, &params); err != nil {
return nil, fmt.Errorf("parse input: %w", err)
}
ms, ok := r.rdp.(rdpMouseSender)
if !ok || r.rdp == nil {
return nil, fmt.Errorf("RDP service not available")
}
if err := ms.SendMouse(params.SessionID, params.X, params.Y, 0x0800); err != nil {
return nil, err
}
return map[string]string{"status": "ok"}, nil
}
func (r *ToolRouter) handleListSessions(_ json.RawMessage) (interface{}, error) {
result := map[string]interface{}{
"ssh": []interface{}{},
"rdp": []interface{}{},
}
if r.sessions != nil {
if sl, ok := r.sessions.(sessionLister); ok {
result["all"] = sl.List()
}
}
return result, nil
}
func (r *ToolRouter) handleConnectSSH(input json.RawMessage) (interface{}, error) {
var params struct {
ConnectionID int64 `json:"connectionId"`
}
if err := json.Unmarshal(input, &params); err != nil {
return nil, fmt.Errorf("parse input: %w", err)
}
// This will be wired to the app-level connect logic later
return nil, fmt.Errorf("connect_ssh requires app-level wiring — not yet available via tool dispatch")
}
func (r *ToolRouter) handleDisconnect(input json.RawMessage) (interface{}, error) {
var params struct {
SessionID string `json:"sessionId"`
}
if err := json.Unmarshal(input, &params); err != nil {
return nil, fmt.Errorf("parse input: %w", err)
}
// Try SSH first
type disconnecter interface {
Disconnect(sessionID string) error
}
if d, ok := r.ssh.(disconnecter); ok {
if err := d.Disconnect(params.SessionID); err == nil {
return map[string]string{"status": "disconnected", "protocol": "ssh"}, nil
}
}
if d, ok := r.rdp.(disconnecter); ok {
if err := d.Disconnect(params.SessionID); err == nil {
return map[string]string{"status": "disconnected", "protocol": "rdp"}, nil
}
}
return nil, fmt.Errorf("session %s not found in SSH or RDP", params.SessionID)
}

View File

@ -0,0 +1,85 @@
package ai
import (
"encoding/json"
"testing"
)
func TestDispatchUnknownTool(t *testing.T) {
router := NewToolRouter()
_, err := router.Dispatch("nonexistent_tool", json.RawMessage(`{}`))
if err == nil {
t.Error("expected error for unknown tool")
}
if err.Error() != "unknown tool: nonexistent_tool" {
t.Errorf("unexpected error message: %v", err)
}
}
func TestDispatchListSessions(t *testing.T) {
router := NewToolRouter()
// With nil services, list_sessions should return empty result
result, err := router.Dispatch("list_sessions", json.RawMessage(`{}`))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
m, ok := result.(map[string]interface{})
if !ok {
t.Fatalf("expected map result, got %T", result)
}
ssh, ok := m["ssh"].([]interface{})
if !ok {
t.Fatal("expected ssh key in result")
}
if len(ssh) != 0 {
t.Errorf("expected empty ssh list, got %d items", len(ssh))
}
rdp, ok := m["rdp"].([]interface{})
if !ok {
t.Fatal("expected rdp key in result")
}
if len(rdp) != 0 {
t.Errorf("expected empty rdp list, got %d items", len(rdp))
}
}
func TestDispatchTerminalWriteNoService(t *testing.T) {
router := NewToolRouter()
_, err := router.Dispatch("terminal_write", json.RawMessage(`{"sessionId":"abc","text":"ls\n"}`))
if err == nil {
t.Error("expected error when SSH service is nil")
}
}
func TestDispatchTerminalReadNoService(t *testing.T) {
router := NewToolRouter()
_, err := router.Dispatch("terminal_read", json.RawMessage(`{"sessionId":"abc"}`))
if err == nil {
t.Error("expected error when AI service is nil")
}
}
func TestDispatchSFTPListNoService(t *testing.T) {
router := NewToolRouter()
_, err := router.Dispatch("sftp_list", json.RawMessage(`{"sessionId":"abc","path":"/"}`))
if err == nil {
t.Error("expected error when SFTP service is nil")
}
}
func TestDispatchDisconnectNoService(t *testing.T) {
router := NewToolRouter()
_, err := router.Dispatch("disconnect", json.RawMessage(`{"sessionId":"abc"}`))
if err == nil {
t.Error("expected error when no services available")
}
}

79
internal/ai/screenshot.go Normal file
View File

@ -0,0 +1,79 @@
package ai
import (
"bytes"
"fmt"
"image"
"image/jpeg"
)
// EncodeScreenshot converts raw RGBA pixel data to a JPEG image.
// If the source dimensions exceed maxWidth x maxHeight, the image is
// downscaled using nearest-neighbor sampling (fast, no external deps).
// Returns the JPEG bytes.
func EncodeScreenshot(rgba []byte, srcWidth, srcHeight, maxWidth, maxHeight, quality int) ([]byte, error) {
expectedLen := srcWidth * srcHeight * 4
if len(rgba) < expectedLen {
return nil, fmt.Errorf("RGBA buffer too small: got %d bytes, expected %d for %dx%d", len(rgba), expectedLen, srcWidth, srcHeight)
}
if quality <= 0 || quality > 100 {
quality = 75
}
// Create source image from RGBA buffer
src := image.NewRGBA(image.Rect(0, 0, srcWidth, srcHeight))
copy(src.Pix, rgba[:expectedLen])
// Determine output dimensions
dstWidth, dstHeight := srcWidth, srcHeight
if srcWidth > maxWidth || srcHeight > maxHeight {
dstWidth, dstHeight = fitDimensions(srcWidth, srcHeight, maxWidth, maxHeight)
}
var img image.Image = src
// Downscale if needed using nearest-neighbor sampling
if dstWidth != srcWidth || dstHeight != srcHeight {
dst := image.NewRGBA(image.Rect(0, 0, dstWidth, dstHeight))
for y := 0; y < dstHeight; y++ {
srcY := y * srcHeight / dstHeight
for x := 0; x < dstWidth; x++ {
srcX := x * srcWidth / dstWidth
srcIdx := (srcY*srcWidth + srcX) * 4
dstIdx := (y*dstWidth + x) * 4
dst.Pix[dstIdx+0] = src.Pix[srcIdx+0] // R
dst.Pix[dstIdx+1] = src.Pix[srcIdx+1] // G
dst.Pix[dstIdx+2] = src.Pix[srcIdx+2] // B
dst.Pix[dstIdx+3] = src.Pix[srcIdx+3] // A
}
}
img = dst
}
// Encode to JPEG
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality}); err != nil {
return nil, fmt.Errorf("encode JPEG: %w", err)
}
return buf.Bytes(), nil
}
// fitDimensions calculates the largest dimensions that fit within max bounds
// while preserving aspect ratio.
func fitDimensions(srcW, srcH, maxW, maxH int) (int, int) {
ratio := float64(srcW) / float64(srcH)
w, h := maxW, int(float64(maxW)/ratio)
if h > maxH {
h = maxH
w = int(float64(maxH) * ratio)
}
if w <= 0 {
w = 1
}
if h <= 0 {
h = 1
}
return w, h
}

View File

@ -0,0 +1,118 @@
package ai
import (
"bytes"
"image/jpeg"
"testing"
)
func TestEncodeScreenshot(t *testing.T) {
width, height := 100, 80
// Create a test RGBA buffer (red pixels)
rgba := make([]byte, width*height*4)
for i := 0; i < len(rgba); i += 4 {
rgba[i+0] = 255 // R
rgba[i+1] = 0 // G
rgba[i+2] = 0 // B
rgba[i+3] = 255 // A
}
jpegData, err := EncodeScreenshot(rgba, width, height, 200, 200, 80)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Check JPEG magic bytes (FF D8)
if len(jpegData) < 2 {
t.Fatal("JPEG data too small")
}
if jpegData[0] != 0xFF || jpegData[1] != 0xD8 {
t.Errorf("expected JPEG magic bytes FF D8, got %02X %02X", jpegData[0], jpegData[1])
}
// Decode and verify dimensions (no downscale needed in this case)
img, err := jpeg.Decode(bytes.NewReader(jpegData))
if err != nil {
t.Fatalf("decode JPEG: %v", err)
}
bounds := img.Bounds()
if bounds.Dx() != width || bounds.Dy() != height {
t.Errorf("expected %dx%d, got %dx%d", width, height, bounds.Dx(), bounds.Dy())
}
}
func TestEncodeScreenshotDownscale(t *testing.T) {
srcWidth, srcHeight := 1920, 1080
maxWidth, maxHeight := 1280, 720
// Create a test RGBA buffer (gradient)
rgba := make([]byte, srcWidth*srcHeight*4)
for y := 0; y < srcHeight; y++ {
for x := 0; x < srcWidth; x++ {
idx := (y*srcWidth + x) * 4
rgba[idx+0] = byte(x % 256) // R
rgba[idx+1] = byte(y % 256) // G
rgba[idx+2] = byte((x + y) % 256) // B
rgba[idx+3] = 255 // A
}
}
jpegData, err := EncodeScreenshot(rgba, srcWidth, srcHeight, maxWidth, maxHeight, 75)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Check JPEG magic bytes
if jpegData[0] != 0xFF || jpegData[1] != 0xD8 {
t.Errorf("expected JPEG magic bytes FF D8, got %02X %02X", jpegData[0], jpegData[1])
}
// Decode and verify dimensions are within max bounds
img, err := jpeg.Decode(bytes.NewReader(jpegData))
if err != nil {
t.Fatalf("decode JPEG: %v", err)
}
bounds := img.Bounds()
if bounds.Dx() > maxWidth {
t.Errorf("output width %d exceeds max %d", bounds.Dx(), maxWidth)
}
if bounds.Dy() > maxHeight {
t.Errorf("output height %d exceeds max %d", bounds.Dy(), maxHeight)
}
// Should maintain 16:9 ratio approximately
expectedWidth := 1280
expectedHeight := 720
if bounds.Dx() != expectedWidth || bounds.Dy() != expectedHeight {
t.Errorf("expected %dx%d, got %dx%d", expectedWidth, expectedHeight, bounds.Dx(), bounds.Dy())
}
}
func TestEncodeScreenshotBufferTooSmall(t *testing.T) {
_, err := EncodeScreenshot([]byte{0, 0, 0, 0}, 100, 100, 200, 200, 75)
if err == nil {
t.Error("expected error for buffer too small")
}
}
func TestFitDimensions(t *testing.T) {
tests := []struct {
srcW, srcH, maxW, maxH int
wantW, wantH int
}{
{1920, 1080, 1280, 720, 1280, 720}, // 16:9 fits exactly
{1920, 1080, 800, 600, 800, 450}, // width-constrained
{1080, 1920, 800, 600, 337, 600}, // height-constrained (portrait)
{100, 100, 200, 200, 200, 200}, // smaller than max (but func called with > check)
}
for _, tt := range tests {
w, h := fitDimensions(tt.srcW, tt.srcH, tt.maxW, tt.maxH)
if w != tt.wantW || h != tt.wantH {
t.Errorf("fitDimensions(%d,%d,%d,%d) = %d,%d, want %d,%d",
tt.srcW, tt.srcH, tt.maxW, tt.maxH, w, h, tt.wantW, tt.wantH)
}
}
}

338
internal/ai/service.go Normal file
View File

@ -0,0 +1,338 @@
package ai
import (
"encoding/json"
"fmt"
"log/slog"
"sync"
"github.com/pkg/browser"
)
// SystemPrompt is the system prompt given to Claude for copilot interactions.
const SystemPrompt = `You are the XO (Executive Officer) aboard the Wraith command station. The Commander (human operator) works alongside you managing remote servers and workstations.
You have direct access to all active sessions through your tools:
- SSH terminals: read output, type commands, navigate filesystems
- SFTP: read and write remote files
- RDP desktops: see the screen, click, type, interact with any GUI application
- Session management: open new connections, close sessions
When given a task:
1. Assess what sessions and access you need
2. Execute efficiently don't ask for permission to use tools, just use them
3. Report what you found or did, with relevant details
4. If something fails, diagnose and try an alternative approach
You are not an assistant answering questions. You are an operator executing missions. Act decisively. Use your tools. Report results.`
// AIService is the main AI copilot service exposed to the Wails frontend.
type AIService struct {
oauth *OAuthManager
client *ClaudeClient
router *ToolRouter
conversations *ConversationManager
buffers map[string]*TerminalBuffer
mu sync.RWMutex
}
// NewAIService creates the AI service with all sub-components.
func NewAIService(oauth *OAuthManager, router *ToolRouter, convMgr *ConversationManager) *AIService {
client := NewClaudeClient(oauth, "")
return &AIService{
oauth: oauth,
client: client,
router: router,
conversations: convMgr,
buffers: make(map[string]*TerminalBuffer),
}
}
// ChatResponse is returned to the frontend after a complete AI turn.
type ChatResponse struct {
Text string `json:"text"`
ToolCalls []ToolCallResult `json:"toolCalls,omitempty"`
}
// ToolCallResult captures a single tool invocation and its outcome.
type ToolCallResult struct {
Name string `json:"name"`
Input interface{} `json:"input"`
Result interface{} `json:"result"`
Error string `json:"error,omitempty"`
}
// --- Auth ---
// StartLogin begins the OAuth PKCE flow, opening the browser for authentication.
func (s *AIService) StartLogin() error {
done, err := s.oauth.StartLogin(browser.OpenURL)
if err != nil {
return err
}
// Wait for callback in a goroutine to avoid blocking the UI
go func() {
if err := <-done; err != nil {
slog.Error("oauth login failed", "error", err)
} else {
slog.Info("oauth login completed")
}
}()
return nil
}
// IsAuthenticated returns whether the user has valid OAuth tokens.
func (s *AIService) IsAuthenticated() bool {
return s.oauth.IsAuthenticated()
}
// Logout clears stored OAuth tokens.
func (s *AIService) Logout() error {
return s.oauth.Logout()
}
// --- Conversations ---
// NewConversation creates a new AI conversation and returns its ID.
func (s *AIService) NewConversation() (string, error) {
conv, err := s.conversations.Create(s.client.model)
if err != nil {
return "", err
}
return conv.ID, nil
}
// ListConversations returns all conversations.
func (s *AIService) ListConversations() ([]ConversationSummary, error) {
return s.conversations.List()
}
// DeleteConversation removes a conversation.
func (s *AIService) DeleteConversation(id string) error {
return s.conversations.Delete(id)
}
// --- Chat ---
// SendMessage sends a user message in a conversation and processes the AI response.
// Tool calls are automatically dispatched and results fed back to the model.
// This method blocks until the full response (including any tool use loops) is complete.
// It returns the aggregated text and tool-call results from all iterations.
func (s *AIService) SendMessage(conversationId, text string) (*ChatResponse, error) {
// Add user message to conversation
userMsg := Message{
Role: "user",
Content: []ContentBlock{
{Type: "text", Text: text},
},
}
if err := s.conversations.AddMessage(conversationId, userMsg); err != nil {
return nil, fmt.Errorf("store user message: %w", err)
}
// Run the message loop (handles tool use)
return s.messageLoop(conversationId)
}
// messageLoop sends the conversation to Claude and handles tool use loops.
// It returns the aggregated ChatResponse containing all text and tool-call results.
func (s *AIService) messageLoop(conversationId string) (*ChatResponse, error) {
resp := &ChatResponse{}
for iterations := 0; iterations < 20; iterations++ { // safety limit
messages, err := s.conversations.GetMessages(conversationId)
if err != nil {
return nil, err
}
ch, err := s.client.SendMessage(messages, CopilotTools, SystemPrompt)
if err != nil {
return nil, fmt.Errorf("send to claude: %w", err)
}
// Collect the response
var textParts []string
var toolCalls []ContentBlock
var currentToolID string
var currentToolName string
var currentToolInput string
for event := range ch {
switch event.Type {
case "text_delta":
textParts = append(textParts, event.Data)
case "tool_use_start":
// Finalize any previous tool call
if currentToolID != "" {
toolCalls = append(toolCalls, ContentBlock{
Type: "tool_use",
ID: currentToolID,
Name: currentToolName,
Input: json.RawMessage(currentToolInput),
})
}
currentToolID = event.ToolID
currentToolName = event.ToolName
currentToolInput = ""
case "tool_use_delta":
currentToolInput += event.Data
case "done":
// Finalize any in-progress tool call
if currentToolID != "" {
toolCalls = append(toolCalls, ContentBlock{
Type: "tool_use",
ID: currentToolID,
Name: currentToolName,
Input: json.RawMessage(currentToolInput),
})
currentToolID = ""
currentToolName = ""
currentToolInput = ""
}
// Parse usage if available
var delta struct {
Usage Usage `json:"usage"`
}
if json.Unmarshal([]byte(event.Data), &delta) == nil && delta.Usage.OutputTokens > 0 {
s.conversations.UpdateTokenUsage(conversationId, delta.Usage.InputTokens, delta.Usage.OutputTokens)
}
case "error":
return nil, fmt.Errorf("stream error: %s", event.Data)
}
}
// Finalize any trailing tool call (if stream ended without a "done" event)
if currentToolID != "" {
toolCalls = append(toolCalls, ContentBlock{
Type: "tool_use",
ID: currentToolID,
Name: currentToolName,
Input: json.RawMessage(currentToolInput),
})
}
// Build the assistant message
var assistantContent []ContentBlock
fullText := ""
for _, p := range textParts {
fullText += p
}
if fullText != "" {
assistantContent = append(assistantContent, ContentBlock{
Type: "text",
Text: fullText,
})
resp.Text += fullText
}
for _, tc := range toolCalls {
assistantContent = append(assistantContent, tc)
}
if len(assistantContent) == 0 {
return resp, nil // empty response
}
// Store assistant message
assistantMsg := Message{
Role: "assistant",
Content: assistantContent,
}
if err := s.conversations.AddMessage(conversationId, assistantMsg); err != nil {
return nil, fmt.Errorf("store assistant message: %w", err)
}
// If there were tool calls, dispatch them and continue the loop
hasToolUse := false
for _, block := range assistantContent {
if block.Type == "tool_use" {
hasToolUse = true
break
}
}
if !hasToolUse {
return resp, nil // done, no tool use to process
}
// Dispatch tool calls and create tool_result message
var toolResults []ContentBlock
for _, block := range assistantContent {
if block.Type != "tool_use" {
continue
}
result, dispatchErr := s.router.Dispatch(block.Name, block.Input)
resultBlock := ContentBlock{
Type: "tool_result",
ToolUseID: block.ID,
}
tcResult := ToolCallResult{
Name: block.Name,
Input: block.Input,
}
if dispatchErr != nil {
resultBlock.IsError = true
resultBlock.Content = []ContentBlock{
{Type: "text", Text: dispatchErr.Error()},
}
tcResult.Error = dispatchErr.Error()
} else {
resultJSON, _ := json.Marshal(result)
resultBlock.Content = []ContentBlock{
{Type: "text", Text: string(resultJSON)},
}
tcResult.Result = result
}
toolResults = append(toolResults, resultBlock)
resp.ToolCalls = append(resp.ToolCalls, tcResult)
}
toolResultMsg := Message{
Role: "user",
Content: toolResults,
}
if err := s.conversations.AddMessage(conversationId, toolResultMsg); err != nil {
return nil, fmt.Errorf("store tool results: %w", err)
}
// Continue the loop to let Claude process tool results
}
return nil, fmt.Errorf("exceeded maximum tool use iterations")
}
// --- Model ---
// GetModel returns the current Claude model identifier.
func (s *AIService) GetModel() string {
return s.client.model
}
// SetModel changes the Claude model used for subsequent requests.
func (s *AIService) SetModel(model string) {
s.client.model = model
}
// --- Terminal Buffer Management ---
// GetBuffer returns the terminal output buffer for a session, creating it if needed.
func (s *AIService) GetBuffer(sessionId string) *TerminalBuffer {
s.mu.Lock()
defer s.mu.Unlock()
buf, ok := s.buffers[sessionId]
if !ok {
buf = NewTerminalBuffer(200)
s.buffers[sessionId] = buf
}
return buf
}
// RemoveBuffer removes the terminal buffer for a session.
func (s *AIService) RemoveBuffer(sessionId string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.buffers, sessionId)
}

View File

@ -0,0 +1,101 @@
package ai
import (
"strings"
"sync"
)
// TerminalBuffer is a thread-safe ring buffer that captures terminal output lines.
// It is written to by SSH read loops and read by the AI tool dispatch for terminal_read.
type TerminalBuffer struct {
lines []string
mu sync.RWMutex
max int
partial string // accumulates data that doesn't end with \n
}
// NewTerminalBuffer creates a buffer that retains at most maxLines lines.
func NewTerminalBuffer(maxLines int) *TerminalBuffer {
if maxLines <= 0 {
maxLines = 200
}
return &TerminalBuffer{
lines: make([]string, 0, maxLines),
max: maxLines,
}
}
// Write ingests raw terminal output, splitting on newlines and appending complete lines.
// Partial lines (data without a trailing newline) are accumulated until the next Write.
func (b *TerminalBuffer) Write(data []byte) {
b.mu.Lock()
defer b.mu.Unlock()
text := b.partial + string(data)
b.partial = ""
parts := strings.Split(text, "\n")
// The last element of Split is always either:
// - empty string if text ends with \n (discard it)
// - a partial line if text doesn't end with \n (save as partial)
last := parts[len(parts)-1]
parts = parts[:len(parts)-1]
if last != "" {
b.partial = last
}
for _, line := range parts {
b.lines = append(b.lines, line)
}
// Trim to max
if len(b.lines) > b.max {
excess := len(b.lines) - b.max
b.lines = b.lines[excess:]
}
}
// ReadLast returns the last n lines from the buffer.
// If fewer than n lines are available, all lines are returned.
func (b *TerminalBuffer) ReadLast(n int) []string {
b.mu.RLock()
defer b.mu.RUnlock()
total := len(b.lines)
if n > total {
n = total
}
if n <= 0 {
return []string{}
}
result := make([]string, n)
copy(result, b.lines[total-n:])
return result
}
// ReadAll returns all lines currently in the buffer.
func (b *TerminalBuffer) ReadAll() []string {
b.mu.RLock()
defer b.mu.RUnlock()
result := make([]string, len(b.lines))
copy(result, b.lines)
return result
}
// Clear removes all lines from the buffer.
func (b *TerminalBuffer) Clear() {
b.mu.Lock()
defer b.mu.Unlock()
b.lines = b.lines[:0]
b.partial = ""
}
// Len returns the number of complete lines in the buffer.
func (b *TerminalBuffer) Len() int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.lines)
}

View File

@ -0,0 +1,175 @@
package ai
import (
"fmt"
"sync"
"testing"
)
func TestWriteAndRead(t *testing.T) {
buf := NewTerminalBuffer(100)
buf.Write([]byte("line1\nline2\nline3\n"))
all := buf.ReadAll()
if len(all) != 3 {
t.Fatalf("expected 3 lines, got %d", len(all))
}
if all[0] != "line1" || all[1] != "line2" || all[2] != "line3" {
t.Errorf("unexpected lines: %v", all)
}
}
func TestRingBufferOverflow(t *testing.T) {
buf := NewTerminalBuffer(200)
// Write 300 lines
for i := 0; i < 300; i++ {
buf.Write([]byte(fmt.Sprintf("line %d\n", i)))
}
all := buf.ReadAll()
if len(all) != 200 {
t.Fatalf("expected 200 lines (ring buffer), got %d", len(all))
}
// First line should be "line 100" (oldest retained)
if all[0] != "line 100" {
t.Errorf("expected first line 'line 100', got %q", all[0])
}
// Last line should be "line 299"
if all[199] != "line 299" {
t.Errorf("expected last line 'line 299', got %q", all[199])
}
}
func TestReadLastSubset(t *testing.T) {
buf := NewTerminalBuffer(100)
buf.Write([]byte("a\nb\nc\nd\ne\n"))
last3 := buf.ReadLast(3)
if len(last3) != 3 {
t.Fatalf("expected 3 lines, got %d", len(last3))
}
if last3[0] != "c" || last3[1] != "d" || last3[2] != "e" {
t.Errorf("unexpected lines: %v", last3)
}
// Request more than available
last10 := buf.ReadLast(10)
if len(last10) != 5 {
t.Fatalf("expected 5 lines (all available), got %d", len(last10))
}
}
func TestConcurrentAccess(t *testing.T) {
buf := NewTerminalBuffer(1000)
var wg sync.WaitGroup
// Spawn 10 writers
for w := 0; w < 10; w++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for i := 0; i < 100; i++ {
buf.Write([]byte(fmt.Sprintf("writer %d line %d\n", id, i)))
}
}(w)
}
// Spawn 5 readers
for r := 0; r < 5; r++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 100; i++ {
_ = buf.ReadLast(10)
_ = buf.Len()
}
}()
}
wg.Wait()
total := buf.Len()
if total != 1000 {
t.Errorf("expected 1000 lines (10 writers * 100), got %d", total)
}
}
func TestWritePartialLines(t *testing.T) {
buf := NewTerminalBuffer(100)
// Write data without trailing newline
buf.Write([]byte("partial"))
// Should have no complete lines yet
if buf.Len() != 0 {
t.Errorf("expected 0 lines for partial write, got %d", buf.Len())
}
// Complete the line
buf.Write([]byte(" line\n"))
if buf.Len() != 1 {
t.Fatalf("expected 1 line after completing partial, got %d", buf.Len())
}
all := buf.ReadAll()
if all[0] != "partial line" {
t.Errorf("expected 'partial line', got %q", all[0])
}
}
func TestWriteMultiplePartials(t *testing.T) {
buf := NewTerminalBuffer(100)
buf.Write([]byte("hello "))
buf.Write([]byte("world"))
buf.Write([]byte("!\nfoo\n"))
all := buf.ReadAll()
if len(all) != 2 {
t.Fatalf("expected 2 lines, got %d: %v", len(all), all)
}
if all[0] != "hello world!" {
t.Errorf("expected 'hello world!', got %q", all[0])
}
if all[1] != "foo" {
t.Errorf("expected 'foo', got %q", all[1])
}
}
func TestClear(t *testing.T) {
buf := NewTerminalBuffer(100)
buf.Write([]byte("line1\nline2\n"))
buf.Clear()
if buf.Len() != 0 {
t.Errorf("expected 0 lines after clear, got %d", buf.Len())
}
}
func TestReadLastZero(t *testing.T) {
buf := NewTerminalBuffer(100)
buf.Write([]byte("line\n"))
result := buf.ReadLast(0)
if len(result) != 0 {
t.Errorf("expected empty result for ReadLast(0), got %d lines", len(result))
}
}
func TestReadLastNegative(t *testing.T) {
buf := NewTerminalBuffer(100)
buf.Write([]byte("line\n"))
result := buf.ReadLast(-1)
if len(result) != 0 {
t.Errorf("expected empty result for ReadLast(-1), got %d lines", len(result))
}
}

207
internal/ai/tools.go Normal file
View File

@ -0,0 +1,207 @@
package ai
import "encoding/json"
// CopilotTools defines all tools available to the AI copilot.
var CopilotTools = []Tool{
// ── SSH Terminal Tools ────────────────────────────────────────
{
Name: "terminal_write",
Description: "Type text into an active SSH terminal session. Use this to execute commands by including a trailing newline character.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"sessionId": {"type": "string", "description": "The SSH session ID"},
"text": {"type": "string", "description": "Text to type into the terminal (include \\n for Enter)"}
},
"required": ["sessionId", "text"]
}`),
},
{
Name: "terminal_read",
Description: "Read recent output from an SSH terminal session. Returns the last N lines from the terminal buffer.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"sessionId": {"type": "string", "description": "The SSH session ID"},
"lines": {"type": "integer", "description": "Number of recent lines to return (default 50)", "default": 50}
},
"required": ["sessionId"]
}`),
},
{
Name: "terminal_cwd",
Description: "Get the current working directory of an SSH terminal session by executing pwd.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"sessionId": {"type": "string", "description": "The SSH session ID"}
},
"required": ["sessionId"]
}`),
},
// ── SFTP Tools ───────────────────────────────────────────────
{
Name: "sftp_list",
Description: "List files and directories at a remote path via SFTP.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"sessionId": {"type": "string", "description": "The SSH/SFTP session ID"},
"path": {"type": "string", "description": "Remote directory path to list"}
},
"required": ["sessionId", "path"]
}`),
},
{
Name: "sftp_read",
Description: "Read the contents of a remote file via SFTP. Limited to files under 5MB.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"sessionId": {"type": "string", "description": "The SSH/SFTP session ID"},
"path": {"type": "string", "description": "Remote file path to read"}
},
"required": ["sessionId", "path"]
}`),
},
{
Name: "sftp_write",
Description: "Write content to a remote file via SFTP. Creates or overwrites the file.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"sessionId": {"type": "string", "description": "The SSH/SFTP session ID"},
"path": {"type": "string", "description": "Remote file path to write"},
"content": {"type": "string", "description": "File content to write"}
},
"required": ["sessionId", "path", "content"]
}`),
},
// ── RDP Tools ────────────────────────────────────────────────
{
Name: "rdp_screenshot",
Description: "Capture a screenshot of the current RDP desktop. Returns a base64 JPEG image.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"sessionId": {"type": "string", "description": "The RDP session ID"}
},
"required": ["sessionId"]
}`),
},
{
Name: "rdp_click",
Description: "Click at a specific position on the RDP desktop.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"sessionId": {"type": "string", "description": "The RDP session ID"},
"x": {"type": "integer", "description": "X coordinate in pixels"},
"y": {"type": "integer", "description": "Y coordinate in pixels"},
"button": {"type": "string", "enum": ["left", "right", "middle"], "default": "left", "description": "Mouse button to click"}
},
"required": ["sessionId", "x", "y"]
}`),
},
{
Name: "rdp_doubleclick",
Description: "Double-click at a specific position on the RDP desktop.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"sessionId": {"type": "string", "description": "The RDP session ID"},
"x": {"type": "integer", "description": "X coordinate in pixels"},
"y": {"type": "integer", "description": "Y coordinate in pixels"}
},
"required": ["sessionId", "x", "y"]
}`),
},
{
Name: "rdp_type",
Description: "Type text into the focused element on the RDP desktop. Uses clipboard paste for reliability.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"sessionId": {"type": "string", "description": "The RDP session ID"},
"text": {"type": "string", "description": "Text to type"}
},
"required": ["sessionId", "text"]
}`),
},
{
Name: "rdp_keypress",
Description: "Press a key or key combination on the RDP desktop (e.g., 'Enter', 'Tab', 'Ctrl+C').",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"sessionId": {"type": "string", "description": "The RDP session ID"},
"key": {"type": "string", "description": "Key name or combination (e.g., 'Enter', 'Ctrl+C', 'Alt+F4')"}
},
"required": ["sessionId", "key"]
}`),
},
{
Name: "rdp_scroll",
Description: "Scroll the mouse wheel at a position on the RDP desktop.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"sessionId": {"type": "string", "description": "The RDP session ID"},
"x": {"type": "integer", "description": "X coordinate in pixels"},
"y": {"type": "integer", "description": "Y coordinate in pixels"},
"direction": {"type": "string", "enum": ["up", "down"], "description": "Scroll direction"},
"clicks": {"type": "integer", "description": "Number of scroll clicks (default 3)", "default": 3}
},
"required": ["sessionId", "x", "y", "direction"]
}`),
},
{
Name: "rdp_move",
Description: "Move the mouse cursor to a position on the RDP desktop without clicking.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"sessionId": {"type": "string", "description": "The RDP session ID"},
"x": {"type": "integer", "description": "X coordinate in pixels"},
"y": {"type": "integer", "description": "Y coordinate in pixels"}
},
"required": ["sessionId", "x", "y"]
}`),
},
// ── Session Management Tools ─────────────────────────────────
{
Name: "list_sessions",
Description: "List all active SSH and RDP sessions with their IDs, connection info, and state.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {},
"required": []
}`),
},
{
Name: "connect_ssh",
Description: "Open a new SSH connection to a saved connection by its ID.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"connectionId": {"type": "integer", "description": "The saved connection ID from the connection manager"}
},
"required": ["connectionId"]
}`),
},
{
Name: "disconnect",
Description: "Disconnect an active session.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"sessionId": {"type": "string", "description": "The session ID to disconnect"}
},
"required": ["sessionId"]
}`),
},
}

75
internal/ai/types.go Normal file
View File

@ -0,0 +1,75 @@
package ai
import (
"encoding/json"
"time"
)
// Message represents a single message in a conversation with Claude.
type Message struct {
Role string `json:"role"` // "user" or "assistant"
Content []ContentBlock `json:"content"` // one or more content blocks
}
// ContentBlock is a polymorphic block within a Message.
// Only one of the content fields will be populated depending on Type.
type ContentBlock struct {
Type string `json:"type"` // "text", "image", "tool_use", "tool_result"
// text
Text string `json:"text,omitempty"`
// image (base64 source)
Source *ImageSource `json:"source,omitempty"`
// tool_use
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
// tool_result
ToolUseID string `json:"tool_use_id,omitempty"`
Content []ContentBlock `json:"content,omitempty"`
IsError bool `json:"is_error,omitempty"`
}
// ImageSource holds a base64-encoded image for vision requests.
type ImageSource struct {
Type string `json:"type"` // "base64"
MediaType string `json:"media_type"` // "image/jpeg", "image/png", etc.
Data string `json:"data"` // base64-encoded image data
}
// Tool describes a tool available to the model.
type Tool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema json.RawMessage `json:"input_schema"`
}
// StreamEvent represents a single event from the SSE stream.
type StreamEvent struct {
Type string `json:"type"` // "text_delta", "tool_use_start", "tool_use_delta", "tool_result", "done", "error"
Data string `json:"data"` // event payload
// Populated for tool_use_start events
ToolName string `json:"tool_name,omitempty"`
ToolID string `json:"tool_id,omitempty"`
ToolInput string `json:"tool_input,omitempty"`
}
// Usage tracks token consumption for a request.
type Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
}
// ConversationSummary is a lightweight view of a conversation for listing.
type ConversationSummary struct {
ID string `json:"id"`
Title string `json:"title"`
Model string `json:"model"`
CreatedAt time.Time `json:"createdAt"`
TokensIn int `json:"tokensIn"`
TokensOut int `json:"tokensOut"`
}

722
internal/app/app.go Normal file
View File

@ -0,0 +1,722 @@
package app
import (
"database/sql"
"encoding/hex"
"fmt"
"log/slog"
"os"
"path/filepath"
"encoding/base64"
"github.com/vstockwell/wraith/internal/ai"
"github.com/vstockwell/wraith/internal/connections"
"github.com/vstockwell/wraith/internal/credentials"
"github.com/vstockwell/wraith/internal/db"
"github.com/vstockwell/wraith/internal/importer"
"github.com/vstockwell/wraith/internal/plugin"
"github.com/vstockwell/wraith/internal/rdp"
"github.com/vstockwell/wraith/internal/session"
"github.com/vstockwell/wraith/internal/settings"
"github.com/vstockwell/wraith/internal/sftp"
"github.com/vstockwell/wraith/internal/ssh"
"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
// and exposes vault management methods to the frontend via Wails bindings.
type WraithApp struct {
db *sql.DB
Vault *vault.VaultService
Settings *settings.SettingsService
Connections *connections.ConnectionService
Themes *theme.ThemeService
Sessions *session.Manager
Plugins *plugin.Registry
SSH *ssh.SSHService
SFTP *sftp.SFTPService
RDP *rdp.RDPService
Credentials *credentials.CredentialService
AI *ai.AIService
Updater *updater.UpdateService
Workspace *WorkspaceService
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
// auto-updater to check for newer releases.
func New(version string) (*WraithApp, error) {
dataDir := dataDirectory()
dbPath := filepath.Join(dataDir, "wraith.db")
slog.Info("opening database", "path", dbPath)
database, err := db.Open(dbPath)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
if err := db.Migrate(database); err != nil {
return nil, fmt.Errorf("run migrations: %w", err)
}
settingsSvc := settings.NewSettingsService(database)
connSvc := connections.NewConnectionService(database)
themeSvc := theme.NewThemeService(database)
sessionMgr := session.NewManager()
pluginReg := plugin.NewRegistry()
pluginReg.RegisterImporter(&importer.MobaConfImporter{})
// Host key store — persists SSH host key fingerprints for TOFU verification
hostKeyStore := ssh.NewHostKeyStore(database)
// 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
sshOutputHandler := func(sessionID string, data []byte) {
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))
}
}
// CWD handler — emits Wails events when the remote working directory changes
sshCWDHandler := func(sessionID string, path string) {
if app != nil && app.wailsApp != nil {
app.wailsApp.Event.Emit("ssh:cwd:"+sessionID, path)
}
}
sshSvc := ssh.NewSSHService(database, hostKeyStore, sshOutputHandler, sshCWDHandler)
sftpSvc := sftp.NewSFTPService()
// RDP service with platform-aware backend factory.
// On Windows the factory returns a FreeRDPBackend backed by libfreerdp3.dll;
// on other platforms it falls back to MockBackend for development.
rdpSvc := rdp.NewRDPService(func() rdp.RDPBackend {
return rdp.NewProductionBackend()
})
// CredentialService requires the vault to be unlocked, so it starts nil.
// It is created lazily after the vault is unlocked via initCredentials().
// AI copilot services — OAuthManager starts without a vault reference;
// it will be wired once the vault is unlocked.
oauthMgr := ai.NewOAuthManager(settingsSvc, nil)
toolRouter := ai.NewToolRouter()
toolRouter.SetServices(sshSvc, sftpSvc, rdpSvc, sessionMgr, connSvc)
convMgr := ai.NewConversationManager(database)
aiSvc := ai.NewAIService(oauthMgr, toolRouter, convMgr)
toolRouter.SetAIService(aiSvc)
// Seed built-in themes on every startup (INSERT OR IGNORE keeps it idempotent)
if err := themeSvc.SeedBuiltins(); err != nil {
slog.Warn("failed to seed themes", "error", err)
}
updaterSvc := updater.NewUpdateService(version)
workspaceSvc := NewWorkspaceService(settingsSvc)
// Clear the clean shutdown flag on startup — it will be re-set on clean exit.
// If it wasn't set, the previous run crashed and the workspace can be restored.
wasClean := workspaceSvc.WasCleanShutdown()
if err := workspaceSvc.ClearCleanShutdown(); err != nil {
slog.Warn("failed to clear clean shutdown flag", "error", err)
}
if !wasClean {
slog.Info("previous shutdown was not clean — workspace restore available")
}
app = &WraithApp{
db: database,
Settings: settingsSvc,
Connections: connSvc,
Themes: themeSvc,
Sessions: sessionMgr,
Plugins: pluginReg,
SSH: sshSvc,
SFTP: sftpSvc,
RDP: rdpSvc,
AI: aiSvc,
Updater: updaterSvc,
Workspace: workspaceSvc,
oauthMgr: oauthMgr,
}
return app, nil
}
// dataDirectory returns the path where Wraith stores its data.
// On Windows with APPDATA set, it uses %APPDATA%\Wraith.
// On macOS/Linux with XDG_DATA_HOME or HOME, it uses the appropriate path.
// Falls back to the current working directory for development.
func dataDirectory() string {
// Windows
if appData := os.Getenv("APPDATA"); appData != "" {
return filepath.Join(appData, "Wraith")
}
// macOS / Linux: use XDG_DATA_HOME or fallback to ~/.local/share
if home, err := os.UserHomeDir(); err == nil {
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
return filepath.Join(xdg, "wraith")
}
return filepath.Join(home, ".local", "share", "wraith")
}
// Dev fallback
return "."
}
// IsFirstRun checks whether the vault has been set up by looking for vault_salt in settings.
func (a *WraithApp) IsFirstRun() bool {
salt, _ := a.Settings.Get("vault_salt")
return salt == ""
}
// CreateVault sets up the vault with a master password. It generates a salt,
// derives an encryption key, and stores the salt and a check value in settings.
func (a *WraithApp) CreateVault(password string) error {
salt, err := vault.GenerateSalt()
if err != nil {
return fmt.Errorf("generate salt: %w", err)
}
key := vault.DeriveKey(password, salt)
a.Vault = vault.NewVaultService(key)
// Store salt as hex in settings
if err := a.Settings.Set("vault_salt", hex.EncodeToString(salt)); err != nil {
return fmt.Errorf("store salt: %w", err)
}
// Encrypt a known check value — used to verify the password on unlock
check, err := a.Vault.Encrypt("wraith-vault-check")
if err != nil {
return fmt.Errorf("encrypt check value: %w", err)
}
if err := a.Settings.Set("vault_check", check); err != nil {
return fmt.Errorf("store check value: %w", err)
}
a.unlocked = true
a.initCredentials()
slog.Info("vault created successfully")
return nil
}
// Unlock verifies the master password against the stored check value and
// initializes the vault service for decryption.
func (a *WraithApp) Unlock(password string) error {
saltHex, err := a.Settings.Get("vault_salt")
if err != nil || saltHex == "" {
return fmt.Errorf("vault not set up — call CreateVault first")
}
salt, err := hex.DecodeString(saltHex)
if err != nil {
return fmt.Errorf("decode salt: %w", err)
}
key := vault.DeriveKey(password, salt)
vs := vault.NewVaultService(key)
// Verify by decrypting the stored check value
checkEncrypted, err := a.Settings.Get("vault_check")
if err != nil || checkEncrypted == "" {
return fmt.Errorf("vault check value missing")
}
checkPlain, err := vs.Decrypt(checkEncrypted)
if err != nil {
return fmt.Errorf("incorrect master password")
}
if checkPlain != "wraith-vault-check" {
return fmt.Errorf("incorrect master password")
}
a.Vault = vs
a.unlocked = true
a.initCredentials()
slog.Info("vault unlocked successfully")
return nil
}
// IsUnlocked returns whether the vault is currently unlocked.
func (a *WraithApp) IsUnlocked() bool {
return a.unlocked
}
// initCredentials creates the CredentialService after the vault is unlocked
// and wires the vault to services that need it (OAuth, etc.).
func (a *WraithApp) initCredentials() {
if a.Vault != nil {
a.Credentials = credentials.NewCredentialService(a.db, a.Vault)
if a.oauthMgr != nil {
a.oauthMgr.SetVault(a.Vault)
}
}
}
// 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
slog.Info("ConnectSSH resolving auth",
"connectionID", connectionID,
"host", conn.Hostname,
"credentialID", conn.CredentialID,
"vaultUnlocked", a.Credentials != nil,
)
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 {
slog.Info("credential loaded", "name", cred.Name, "type", cred.Type, "username", cred.Username, "sshKeyID", cred.SSHKeyID)
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))
}
}
}
}
}
}
// If no stored credentials, return an error telling the frontend to prompt
if len(authMethods) == 0 {
return "", fmt.Errorf("NO_CREDENTIALS: no credentials configured for this connection — assign a credential or use ConnectSSHWithPassword")
}
slog.Info("SSH auth configured", "username", username, "authMethods", len(authMethods))
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)
}
// Register with session manager using the SSH session's own UUID
if _, err := a.Sessions.CreateWithID(sessionID, connectionID, "ssh"); err != nil {
slog.Warn("failed to register SSH session in manager", "error", err)
} else {
_ = a.Sessions.SetState(sessionID, session.StateConnected)
}
// Save workspace state after session change
a.saveWorkspaceState()
slog.Info("SSH session started", "sessionID", sessionID, "host", conn.Hostname, "user", username)
return sessionID, nil
}
// ConnectSSHWithPassword opens an SSH session using an ad-hoc username/password.
// Used when no stored credential exists — the frontend prompts and passes creds directly.
func (a *WraithApp) ConnectSSHWithPassword(connectionID int64, username, password string, cols, rows int) (string, error) {
conn, err := a.Connections.GetConnection(connectionID)
if err != nil {
return "", fmt.Errorf("connection not found: %w", err)
}
authMethods := []gossh.AuthMethod{
gossh.Password(password),
gossh.KeyboardInteractive(func(name, instruction string, questions []string, echos []bool) ([]string, error) {
answers := make([]string, len(questions))
for i := range answers {
answers[i] = password
}
return answers, 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
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)
} else {
a.SFTP.RegisterClient(sessionID, sftpClient)
}
}
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)
}
// Register with session manager
if _, err := a.Sessions.CreateWithID(sessionID, connectionID, "ssh"); err != nil {
slog.Warn("failed to register SSH session in manager", "error", err)
} else {
_ = a.Sessions.SetState(sessionID, session.StateConnected)
}
// Save workspace state after session change
a.saveWorkspaceState()
slog.Info("SSH session started (ad-hoc password)", "sessionID", sessionID, "host", conn.Hostname, "user", username)
return sessionID, nil
}
// GetVersion returns the build-time version string.
func (a *WraithApp) GetVersion() string {
return a.Updater.CurrentVersion()
}
// DisconnectSession closes an active SSH session and its SFTP client,
// and removes it from the session manager.
func (a *WraithApp) DisconnectSession(sessionID string) error {
a.SFTP.RemoveClient(sessionID)
a.Sessions.Remove(sessionID)
a.saveWorkspaceState()
return a.SSH.Disconnect(sessionID)
}
// ConnectRDP opens an RDP session to the given connection ID.
// It resolves credentials from the vault, builds an RDPConfig, and returns a session ID.
// width and height are the initial desktop dimensions in pixels.
func (a *WraithApp) ConnectRDP(connectionID int64, width, height int) (string, error) {
conn, err := a.Connections.GetConnection(connectionID)
if err != nil {
return "", fmt.Errorf("connection not found: %w", err)
}
config := rdp.RDPConfig{
Hostname: conn.Hostname,
Port: conn.Port,
Width: width,
Height: height,
}
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 != "" {
config.Username = cred.Username
}
if cred.Domain != "" {
config.Domain = cred.Domain
}
if cred.Type == "password" {
pw, err := a.Credentials.DecryptPassword(cred.ID)
if err != nil {
slog.Warn("failed to decrypt password", "error", err)
} else {
config.Password = pw
}
}
}
}
sessionID, err := a.RDP.Connect(config, connectionID)
if err != nil {
return "", fmt.Errorf("RDP connect failed: %w", err)
}
// 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)
}
// Register with session manager
if _, err := a.Sessions.CreateWithID(sessionID, connectionID, "rdp"); err != nil {
slog.Warn("failed to register RDP session in manager", "error", err)
} else {
_ = a.Sessions.SetState(sessionID, session.StateConnected)
}
// Save workspace state after session change
a.saveWorkspaceState()
slog.Info("RDP session started", "sessionID", sessionID, "host", conn.Hostname)
return sessionID, nil
}
// RDPGetFrame returns the current frame for an RDP session as a base64-encoded
// string. Go []byte is serialised by Wails as a base64 string over the JSON
// bridge, so the frontend decodes it with atob() to recover the raw RGBA bytes.
func (a *WraithApp) RDPGetFrame(sessionID string) (string, error) {
raw, err := a.RDP.GetFrame(sessionID)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(raw), nil
}
// RDPSendMouse forwards a mouse event to an RDP session.
// flags uses the RDP mouse-event flag constants defined in internal/rdp/input.go.
func (a *WraithApp) RDPSendMouse(sessionID string, x, y int, flags uint32) error {
return a.RDP.SendMouse(sessionID, x, y, flags)
}
// RDPSendKey forwards a key event (scancode + press/release) to an RDP session.
func (a *WraithApp) RDPSendKey(sessionID string, scancode uint32, pressed bool) error {
return a.RDP.SendKey(sessionID, scancode, pressed)
}
// RDPSendClipboard forwards clipboard text to an RDP session.
func (a *WraithApp) RDPSendClipboard(sessionID string, text string) error {
return a.RDP.SendClipboard(sessionID, text)
}
// RDPDisconnect tears down an RDP session and removes it from the session manager.
func (a *WraithApp) RDPDisconnect(sessionID string) error {
a.Sessions.Remove(sessionID)
a.saveWorkspaceState()
return a.RDP.Disconnect(sessionID)
}
// ---------- Credential proxy methods ----------
// CredentialService is nil until the vault is unlocked. These proxies expose
// it via WraithApp (which IS registered as a Wails service at startup).
// ListCredentials returns all stored credentials (no encrypted values).
func (a *WraithApp) ListCredentials() ([]credentials.Credential, error) {
if a.Credentials == nil {
return nil, fmt.Errorf("vault is locked")
}
return a.Credentials.ListCredentials()
}
// CreatePassword creates a password credential encrypted via the vault.
func (a *WraithApp) CreatePassword(name, username, password, domain string) (*credentials.Credential, error) {
if a.Credentials == nil {
return nil, fmt.Errorf("vault is locked")
}
return a.Credentials.CreatePassword(name, username, password, domain)
}
// CreateSSHKeyCredential imports an SSH private key and creates a Credential
// record referencing it. privateKeyPEM is the raw PEM string (NOT base64 encoded).
func (a *WraithApp) CreateSSHKeyCredential(name, username string, privateKeyPEM string, passphrase string) (*credentials.Credential, error) {
if a.Credentials == nil {
return nil, fmt.Errorf("vault is locked")
}
return a.Credentials.CreateSSHKeyCredential(name, username, []byte(privateKeyPEM), passphrase)
}
// DeleteCredential removes a credential by ID.
func (a *WraithApp) DeleteCredential(id int64) error {
if a.Credentials == nil {
return fmt.Errorf("vault is locked")
}
return a.Credentials.DeleteCredential(id)
}
// ImportClaudeCodeToken reads the local Claude Code credentials.json and imports
// the OAuth token into Wraith's vault. Fallback for when Wraith's own OAuth fails.
func (a *WraithApp) ImportClaudeCodeToken() error {
if a.oauthMgr == nil {
return fmt.Errorf("OAuth manager not initialized")
}
return a.oauthMgr.ImportClaudeCodeToken()
}
// 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) {
imp := &importer.MobaConfImporter{}
result, err := imp.Parse([]byte(fileContent))
if err != nil {
return nil, fmt.Errorf("parse mobaconf: %w", err)
}
// Create groups and track name -> ID mapping
groupMap := make(map[string]int64)
for _, g := range result.Groups {
created, err := a.Connections.CreateGroup(g.Name, nil)
if err != nil {
slog.Warn("failed to create group during import", "name", g.Name, "error", err)
continue
}
groupMap[g.Name] = created.ID
}
// Create connections
for _, c := range result.Connections {
var groupID *int64
if id, ok := groupMap[c.GroupName]; ok {
groupID = &id
}
_, err := a.Connections.CreateConnection(connections.CreateConnectionInput{
Name: c.Name,
Hostname: c.Hostname,
Port: c.Port,
Protocol: c.Protocol,
GroupID: groupID,
Notes: c.Notes,
})
if err != nil {
slog.Warn("failed to create connection during import", "name", c.Name, "error", err)
}
}
// Store host keys
for _, hk := range result.HostKeys {
_, err := a.db.Exec(
`INSERT OR IGNORE INTO host_keys (hostname, port, key_type, fingerprint) VALUES (?, ?, ?, ?)`,
hk.Hostname, hk.Port, hk.KeyType, hk.Fingerprint,
)
if err != nil {
slog.Warn("failed to store host key during import", "hostname", hk.Hostname, "error", err)
}
}
slog.Info("mobaconf import complete",
"groups", len(result.Groups),
"connections", len(result.Connections),
"hostKeys", len(result.HostKeys),
)
return result, nil
}
// ---------- Workspace proxy methods ----------
// saveWorkspaceState builds a workspace snapshot from the current session manager
// state and persists it. Called automatically on session open/close.
func (a *WraithApp) saveWorkspaceState() {
if a.Workspace == nil {
return
}
sessions := a.Sessions.List()
tabs := make([]WorkspaceTab, 0, len(sessions))
for _, s := range sessions {
tabs = append(tabs, WorkspaceTab{
ConnectionID: s.ConnectionID,
Protocol: s.Protocol,
Position: s.TabPosition,
})
}
snapshot := &WorkspaceSnapshot{
Tabs: tabs,
}
if err := a.Workspace.Save(snapshot); err != nil {
slog.Warn("failed to save workspace state", "error", err)
}
}
// SaveWorkspace explicitly saves the current workspace snapshot.
// Exposed to the frontend for manual save triggers.
func (a *WraithApp) SaveWorkspace(snapshot *WorkspaceSnapshot) error {
if a.Workspace == nil {
return fmt.Errorf("workspace service not initialized")
}
return a.Workspace.Save(snapshot)
}
// LoadWorkspace returns the last saved workspace snapshot, or nil if none exists.
func (a *WraithApp) LoadWorkspace() (*WorkspaceSnapshot, error) {
if a.Workspace == nil {
return nil, fmt.Errorf("workspace service not initialized")
}
return a.Workspace.Load()
}
// MarkCleanShutdown records that the app is shutting down cleanly.
// Called before app exit so the next startup knows whether to restore workspace.
func (a *WraithApp) MarkCleanShutdown() error {
if a.Workspace == nil {
return nil
}
return a.Workspace.MarkCleanShutdown()
}
// WasCleanShutdown returns whether the previous app exit was clean.
func (a *WraithApp) WasCleanShutdown() bool {
if a.Workspace == nil {
return true
}
return a.Workspace.WasCleanShutdown()
}
// DeleteHostKey removes stored host keys for a hostname:port, allowing
// reconnection after a legitimate server re-key.
func (a *WraithApp) DeleteHostKey(hostname string, port int) error {
store := ssh.NewHostKeyStore(a.db)
return store.Delete(hostname, port)
}
// GetSessionCWD returns the current tracked working directory for an SSH session.
func (a *WraithApp) GetSessionCWD(sessionID string) string {
sess, ok := a.SSH.GetSession(sessionID)
if !ok || sess.CWDTracker == nil {
return ""
}
return sess.CWDTracker.GetCWD()
}

66
internal/app/workspace.go Normal file
View File

@ -0,0 +1,66 @@
package app
import (
"encoding/json"
"github.com/vstockwell/wraith/internal/settings"
)
type WorkspaceSnapshot struct {
Tabs []WorkspaceTab `json:"tabs"`
SidebarWidth int `json:"sidebarWidth"`
SidebarMode string `json:"sidebarMode"`
ActiveTab int `json:"activeTab"`
}
type WorkspaceTab struct {
ConnectionID int64 `json:"connectionId"`
Protocol string `json:"protocol"`
Position int `json:"position"`
}
type WorkspaceService struct {
settings *settings.SettingsService
}
func NewWorkspaceService(s *settings.SettingsService) *WorkspaceService {
return &WorkspaceService{settings: s}
}
// Save serializes the workspace snapshot to settings
func (w *WorkspaceService) Save(snapshot *WorkspaceSnapshot) error {
data, err := json.Marshal(snapshot)
if err != nil {
return err
}
return w.settings.Set("workspace_snapshot", string(data))
}
// Load reads the last saved workspace snapshot
func (w *WorkspaceService) Load() (*WorkspaceSnapshot, error) {
data, err := w.settings.Get("workspace_snapshot")
if err != nil || data == "" {
return nil, nil // no saved workspace
}
var snapshot WorkspaceSnapshot
if err := json.Unmarshal([]byte(data), &snapshot); err != nil {
return nil, err
}
return &snapshot, nil
}
// MarkCleanShutdown saves a flag indicating clean exit
func (w *WorkspaceService) MarkCleanShutdown() error {
return w.settings.Set("clean_shutdown", "true")
}
// WasCleanShutdown checks if last exit was clean
func (w *WorkspaceService) WasCleanShutdown() bool {
val, _ := w.settings.Get("clean_shutdown")
return val == "true"
}
// ClearCleanShutdown removes the clean shutdown flag (called on startup)
func (w *WorkspaceService) ClearCleanShutdown() error {
return w.settings.Delete("clean_shutdown")
}

View File

@ -0,0 +1,74 @@
package app
import (
"path/filepath"
"testing"
"github.com/vstockwell/wraith/internal/db"
"github.com/vstockwell/wraith/internal/settings"
)
func setupWorkspaceService(t *testing.T) *WorkspaceService {
t.Helper()
d, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
if err := db.Migrate(d); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { d.Close() })
return NewWorkspaceService(settings.NewSettingsService(d))
}
func TestSaveAndLoadWorkspace(t *testing.T) {
svc := setupWorkspaceService(t)
snapshot := &WorkspaceSnapshot{
Tabs: []WorkspaceTab{
{ConnectionID: 1, Protocol: "ssh", Position: 0},
{ConnectionID: 5, Protocol: "rdp", Position: 1},
},
SidebarWidth: 240,
SidebarMode: "connections",
ActiveTab: 0,
}
if err := svc.Save(snapshot); err != nil {
t.Fatal(err)
}
loaded, err := svc.Load()
if err != nil {
t.Fatal(err)
}
if len(loaded.Tabs) != 2 {
t.Errorf("len(Tabs) = %d, want 2", len(loaded.Tabs))
}
if loaded.SidebarWidth != 240 {
t.Errorf("SidebarWidth = %d, want 240", loaded.SidebarWidth)
}
}
func TestLoadEmptyWorkspace(t *testing.T) {
svc := setupWorkspaceService(t)
loaded, err := svc.Load()
if err != nil {
t.Fatal(err)
}
if loaded != nil {
t.Error("should return nil for empty workspace")
}
}
func TestCleanShutdownFlag(t *testing.T) {
svc := setupWorkspaceService(t)
if svc.WasCleanShutdown() {
t.Error("should not be clean initially")
}
svc.MarkCleanShutdown()
if !svc.WasCleanShutdown() {
t.Error("should be clean after marking")
}
svc.ClearCleanShutdown()
if svc.WasCleanShutdown() {
t.Error("should not be clean after clearing")
}
}

View File

@ -0,0 +1,40 @@
package connections
import "fmt"
func (s *ConnectionService) Search(query string) ([]Connection, error) {
like := "%" + query + "%"
rows, err := s.db.Query(
`SELECT id, name, hostname, port, protocol, group_id, credential_id,
COALESCE(color,''), tags, COALESCE(notes,''), COALESCE(options,'{}'),
sort_order, last_connected, created_at, updated_at
FROM connections
WHERE name LIKE ? COLLATE NOCASE
OR hostname LIKE ? COLLATE NOCASE
OR tags LIKE ? COLLATE NOCASE
OR notes LIKE ? COLLATE NOCASE
ORDER BY last_connected DESC NULLS LAST, name`,
like, like, like, like,
)
if err != nil {
return nil, fmt.Errorf("search connections: %w", err)
}
defer rows.Close()
return scanConnections(rows)
}
func (s *ConnectionService) FilterByTag(tag string) ([]Connection, error) {
rows, err := s.db.Query(
`SELECT c.id, c.name, c.hostname, c.port, c.protocol, c.group_id, c.credential_id,
COALESCE(c.color,''), c.tags, COALESCE(c.notes,''), COALESCE(c.options,'{}'),
c.sort_order, c.last_connected, c.created_at, c.updated_at
FROM connections c, json_each(c.tags) AS t
WHERE t.value = ?
ORDER BY c.name`, tag,
)
if err != nil {
return nil, fmt.Errorf("filter by tag: %w", err)
}
defer rows.Close()
return scanConnections(rows)
}

View File

@ -0,0 +1,53 @@
package connections
import "testing"
func TestSearchByName(t *testing.T) {
svc := setupTestService(t)
svc.CreateConnection(CreateConnectionInput{Name: "Asgard", Hostname: "192.168.1.4", Port: 22, Protocol: "ssh"})
svc.CreateConnection(CreateConnectionInput{Name: "Docker", Hostname: "155.254.29.221", Port: 22, Protocol: "ssh"})
results, err := svc.Search("asg")
if err != nil {
t.Fatalf("Search() error: %v", err)
}
if len(results) != 1 {
t.Fatalf("len(results) = %d, want 1", len(results))
}
if results[0].Name != "Asgard" {
t.Errorf("Name = %q, want %q", results[0].Name, "Asgard")
}
}
func TestSearchByHostname(t *testing.T) {
svc := setupTestService(t)
svc.CreateConnection(CreateConnectionInput{Name: "Asgard", Hostname: "192.168.1.4", Port: 22, Protocol: "ssh"})
results, _ := svc.Search("192.168")
if len(results) != 1 {
t.Errorf("len(results) = %d, want 1", len(results))
}
}
func TestSearchByTag(t *testing.T) {
svc := setupTestService(t)
svc.CreateConnection(CreateConnectionInput{Name: "ProdServer", Hostname: "10.0.0.1", Port: 22, Protocol: "ssh", Tags: []string{"Prod", "Linux"}})
svc.CreateConnection(CreateConnectionInput{Name: "DevServer", Hostname: "10.0.0.2", Port: 22, Protocol: "ssh", Tags: []string{"Dev", "Linux"}})
results, _ := svc.Search("Prod")
if len(results) != 1 {
t.Errorf("len(results) = %d, want 1", len(results))
}
}
func TestFilterByTag(t *testing.T) {
svc := setupTestService(t)
svc.CreateConnection(CreateConnectionInput{Name: "A", Hostname: "10.0.0.1", Port: 22, Protocol: "ssh", Tags: []string{"Prod"}})
svc.CreateConnection(CreateConnectionInput{Name: "B", Hostname: "10.0.0.2", Port: 22, Protocol: "ssh", Tags: []string{"Dev"}})
svc.CreateConnection(CreateConnectionInput{Name: "C", Hostname: "10.0.0.3", Port: 22, Protocol: "ssh", Tags: []string{"Prod", "Linux"}})
results, _ := svc.FilterByTag("Prod")
if len(results) != 2 {
t.Errorf("len(results) = %d, want 2", len(results))
}
}

View File

@ -0,0 +1,380 @@
package connections
import (
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
)
type Group struct {
ID int64 `json:"id"`
Name string `json:"name"`
ParentID *int64 `json:"parentId"`
SortOrder int `json:"sortOrder"`
Icon string `json:"icon"`
CreatedAt time.Time `json:"createdAt"`
Children []Group `json:"children,omitempty"`
}
type Connection struct {
ID int64 `json:"id"`
Name string `json:"name"`
Hostname string `json:"hostname"`
Port int `json:"port"`
Protocol string `json:"protocol"`
GroupID *int64 `json:"groupId"`
CredentialID *int64 `json:"credentialId"`
Color string `json:"color"`
Tags []string `json:"tags"`
Notes string `json:"notes"`
Options string `json:"options"`
SortOrder int `json:"sortOrder"`
LastConnected *time.Time `json:"lastConnected"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type CreateConnectionInput struct {
Name string `json:"name"`
Hostname string `json:"hostname"`
Port int `json:"port"`
Protocol string `json:"protocol"`
GroupID *int64 `json:"groupId"`
CredentialID *int64 `json:"credentialId"`
Color string `json:"color"`
Tags []string `json:"tags"`
Notes string `json:"notes"`
Options string `json:"options"`
}
type UpdateConnectionInput struct {
Name *string `json:"name"`
Hostname *string `json:"hostname"`
Port *int `json:"port"`
GroupID *int64 `json:"groupId"`
CredentialID *int64 `json:"credentialId"`
Color *string `json:"color"`
Tags []string `json:"tags"`
Notes *string `json:"notes"`
Options *string `json:"options"`
}
type ConnectionService struct {
db *sql.DB
}
func NewConnectionService(db *sql.DB) *ConnectionService {
return &ConnectionService{db: db}
}
// ---------- Group CRUD ----------
func (s *ConnectionService) CreateGroup(name string, parentID *int64) (*Group, error) {
result, err := s.db.Exec(
"INSERT INTO groups (name, parent_id) VALUES (?, ?)",
name, parentID,
)
if err != nil {
return nil, fmt.Errorf("create group: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return nil, fmt.Errorf("get group id: %w", err)
}
var g Group
var icon sql.NullString
err = s.db.QueryRow(
"SELECT id, name, parent_id, sort_order, icon, created_at FROM groups WHERE id = ?", id,
).Scan(&g.ID, &g.Name, &g.ParentID, &g.SortOrder, &icon, &g.CreatedAt)
if err != nil {
return nil, fmt.Errorf("get created group: %w", err)
}
if icon.Valid {
g.Icon = icon.String
}
return &g, nil
}
func (s *ConnectionService) ListGroups() ([]Group, error) {
rows, err := s.db.Query(
"SELECT id, name, parent_id, sort_order, icon, created_at FROM groups ORDER BY sort_order, name",
)
if err != nil {
return nil, fmt.Errorf("list groups: %w", err)
}
defer rows.Close()
groupMap := make(map[int64]*Group)
var allGroups []*Group
for rows.Next() {
var g Group
var icon sql.NullString
if err := rows.Scan(&g.ID, &g.Name, &g.ParentID, &g.SortOrder, &icon, &g.CreatedAt); err != nil {
return nil, fmt.Errorf("scan group: %w", err)
}
if icon.Valid {
g.Icon = icon.String
}
groupMap[g.ID] = &g
allGroups = append(allGroups, &g)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate groups: %w", err)
}
// Build tree: attach children to parents, collect roots
var roots []Group
for _, g := range allGroups {
if g.ParentID != nil {
if parent, ok := groupMap[*g.ParentID]; ok {
parent.Children = append(parent.Children, *g)
}
} else {
roots = append(roots, *g)
}
}
// Re-attach children to root copies (since we copied into roots)
for i := range roots {
if orig, ok := groupMap[roots[i].ID]; ok {
roots[i].Children = orig.Children
}
}
if roots == nil {
roots = []Group{}
}
return roots, nil
}
func (s *ConnectionService) DeleteGroup(id int64) error {
_, err := s.db.Exec("DELETE FROM groups WHERE id = ?", id)
if err != nil {
return fmt.Errorf("delete group: %w", err)
}
return nil
}
func (s *ConnectionService) RenameGroup(id int64, name string) error {
_, err := s.db.Exec("UPDATE groups SET name = ? WHERE id = ?", name, id)
if err != nil {
return fmt.Errorf("rename group: %w", err)
}
return nil
}
// ---------- Connection CRUD ----------
func (s *ConnectionService) CreateConnection(input CreateConnectionInput) (*Connection, error) {
tags, err := json.Marshal(input.Tags)
if err != nil {
return nil, fmt.Errorf("marshal tags: %w", err)
}
if input.Tags == nil {
tags = []byte("[]")
}
options := input.Options
if options == "" {
options = "{}"
}
result, err := s.db.Exec(
`INSERT INTO connections (name, hostname, port, protocol, group_id, credential_id, color, tags, notes, options)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
input.Name, input.Hostname, input.Port, input.Protocol,
input.GroupID, input.CredentialID, input.Color,
string(tags), input.Notes, options,
)
if err != nil {
return nil, fmt.Errorf("create connection: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return nil, fmt.Errorf("get connection id: %w", err)
}
return s.GetConnection(id)
}
func (s *ConnectionService) GetConnection(id int64) (*Connection, error) {
row := s.db.QueryRow(
`SELECT id, name, hostname, port, protocol, group_id, credential_id,
color, tags, notes, options, sort_order, last_connected, created_at, updated_at
FROM connections WHERE id = ?`, id,
)
var c Connection
var tagsJSON string
var color, notes, options sql.NullString
var lastConnected sql.NullTime
err := row.Scan(
&c.ID, &c.Name, &c.Hostname, &c.Port, &c.Protocol,
&c.GroupID, &c.CredentialID,
&color, &tagsJSON, &notes, &options,
&c.SortOrder, &lastConnected, &c.CreatedAt, &c.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("get connection: %w", err)
}
if color.Valid {
c.Color = color.String
}
if notes.Valid {
c.Notes = notes.String
}
if options.Valid {
c.Options = options.String
}
if lastConnected.Valid {
c.LastConnected = &lastConnected.Time
}
if err := json.Unmarshal([]byte(tagsJSON), &c.Tags); err != nil {
c.Tags = []string{}
}
if c.Tags == nil {
c.Tags = []string{}
}
return &c, nil
}
func (s *ConnectionService) ListConnections() ([]Connection, error) {
rows, err := s.db.Query(
`SELECT id, name, hostname, port, protocol, group_id, credential_id,
color, tags, notes, options, sort_order, last_connected, created_at, updated_at
FROM connections ORDER BY sort_order, name`,
)
if err != nil {
return nil, fmt.Errorf("list connections: %w", err)
}
defer rows.Close()
return scanConnections(rows)
}
// scanConnections is a shared helper used by ListConnections and (later) Search.
func scanConnections(rows *sql.Rows) ([]Connection, error) {
var conns []Connection
for rows.Next() {
var c Connection
var tagsJSON string
var color, notes, options sql.NullString
var lastConnected sql.NullTime
if err := rows.Scan(
&c.ID, &c.Name, &c.Hostname, &c.Port, &c.Protocol,
&c.GroupID, &c.CredentialID,
&color, &tagsJSON, &notes, &options,
&c.SortOrder, &lastConnected, &c.CreatedAt, &c.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan connection: %w", err)
}
if color.Valid {
c.Color = color.String
}
if notes.Valid {
c.Notes = notes.String
}
if options.Valid {
c.Options = options.String
}
if lastConnected.Valid {
c.LastConnected = &lastConnected.Time
}
if err := json.Unmarshal([]byte(tagsJSON), &c.Tags); err != nil {
c.Tags = []string{}
}
if c.Tags == nil {
c.Tags = []string{}
}
conns = append(conns, c)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate connections: %w", err)
}
if conns == nil {
conns = []Connection{}
}
return conns, nil
}
func (s *ConnectionService) UpdateConnection(id int64, input UpdateConnectionInput) (*Connection, error) {
slog.Info("UpdateConnection called",
"id", id,
"name", input.Name,
"credentialID", input.CredentialID,
"groupID", input.GroupID,
)
setClauses := []string{"updated_at = CURRENT_TIMESTAMP"}
args := []interface{}{}
if input.Name != nil {
setClauses = append(setClauses, "name = ?")
args = append(args, *input.Name)
}
if input.Hostname != nil {
setClauses = append(setClauses, "hostname = ?")
args = append(args, *input.Hostname)
}
if input.Port != nil {
setClauses = append(setClauses, "port = ?")
args = append(args, *input.Port)
}
if input.GroupID != nil {
setClauses = append(setClauses, "group_id = ?")
args = append(args, *input.GroupID)
}
// Always update credential_id — nil clears it, non-nil sets it.
// Unlike other fields, credential assignment is explicit on every save.
setClauses = append(setClauses, "credential_id = ?")
if input.CredentialID != nil {
args = append(args, *input.CredentialID)
} else {
args = append(args, nil)
}
if input.Tags != nil {
tags, _ := json.Marshal(input.Tags)
setClauses = append(setClauses, "tags = ?")
args = append(args, string(tags))
}
if input.Notes != nil {
setClauses = append(setClauses, "notes = ?")
args = append(args, *input.Notes)
}
if input.Color != nil {
setClauses = append(setClauses, "color = ?")
args = append(args, *input.Color)
}
if input.Options != nil {
setClauses = append(setClauses, "options = ?")
args = append(args, *input.Options)
}
args = append(args, id)
query := fmt.Sprintf("UPDATE connections SET %s WHERE id = ?", strings.Join(setClauses, ", "))
if _, err := s.db.Exec(query, args...); err != nil {
return nil, fmt.Errorf("update connection: %w", err)
}
return s.GetConnection(id)
}
func (s *ConnectionService) DeleteConnection(id int64) error {
_, err := s.db.Exec("DELETE FROM connections WHERE id = ?", id)
if err != nil {
return fmt.Errorf("delete connection: %w", err)
}
return nil
}

View File

@ -0,0 +1,234 @@
package connections
import (
"path/filepath"
"testing"
"github.com/vstockwell/wraith/internal/db"
)
func strPtr(s string) *string { return &s }
func setupTestService(t *testing.T) *ConnectionService {
t.Helper()
dir := t.TempDir()
database, err := db.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("db.Open() error: %v", err)
}
if err := db.Migrate(database); err != nil {
t.Fatalf("db.Migrate() error: %v", err)
}
t.Cleanup(func() { database.Close() })
return NewConnectionService(database)
}
func TestCreateGroup(t *testing.T) {
svc := setupTestService(t)
g, err := svc.CreateGroup("Servers", nil)
if err != nil {
t.Fatalf("CreateGroup() error: %v", err)
}
if g.ID == 0 {
t.Error("expected non-zero ID")
}
if g.Name != "Servers" {
t.Errorf("Name = %q, want %q", g.Name, "Servers")
}
if g.ParentID != nil {
t.Errorf("ParentID = %v, want nil", g.ParentID)
}
}
func TestCreateSubGroup(t *testing.T) {
svc := setupTestService(t)
parent, err := svc.CreateGroup("Servers", nil)
if err != nil {
t.Fatalf("CreateGroup(parent) error: %v", err)
}
child, err := svc.CreateGroup("Production", &parent.ID)
if err != nil {
t.Fatalf("CreateGroup(child) error: %v", err)
}
if child.ParentID == nil {
t.Fatal("expected non-nil ParentID")
}
if *child.ParentID != parent.ID {
t.Errorf("ParentID = %d, want %d", *child.ParentID, parent.ID)
}
}
func TestListGroups(t *testing.T) {
svc := setupTestService(t)
parent, err := svc.CreateGroup("Servers", nil)
if err != nil {
t.Fatalf("CreateGroup(parent) error: %v", err)
}
if _, err := svc.CreateGroup("Production", &parent.ID); err != nil {
t.Fatalf("CreateGroup(child) error: %v", err)
}
groups, err := svc.ListGroups()
if err != nil {
t.Fatalf("ListGroups() error: %v", err)
}
if len(groups) != 1 {
t.Fatalf("len(groups) = %d, want 1 (only root groups)", len(groups))
}
if groups[0].Name != "Servers" {
t.Errorf("groups[0].Name = %q, want %q", groups[0].Name, "Servers")
}
if len(groups[0].Children) != 1 {
t.Fatalf("len(children) = %d, want 1", len(groups[0].Children))
}
if groups[0].Children[0].Name != "Production" {
t.Errorf("child name = %q, want %q", groups[0].Children[0].Name, "Production")
}
}
func TestDeleteGroup(t *testing.T) {
svc := setupTestService(t)
g, err := svc.CreateGroup("ToDelete", nil)
if err != nil {
t.Fatalf("CreateGroup() error: %v", err)
}
if err := svc.DeleteGroup(g.ID); err != nil {
t.Fatalf("DeleteGroup() error: %v", err)
}
groups, err := svc.ListGroups()
if err != nil {
t.Fatalf("ListGroups() error: %v", err)
}
if len(groups) != 0 {
t.Errorf("len(groups) = %d, want 0 after delete", len(groups))
}
}
func TestCreateConnection(t *testing.T) {
svc := setupTestService(t)
conn, err := svc.CreateConnection(CreateConnectionInput{
Name: "Web Server",
Hostname: "10.0.0.1",
Port: 22,
Protocol: "ssh",
Tags: []string{"Prod", "Linux"},
Options: `{"keepAliveInterval": 60}`,
})
if err != nil {
t.Fatalf("CreateConnection() error: %v", err)
}
if conn.ID == 0 {
t.Error("expected non-zero ID")
}
if conn.Name != "Web Server" {
t.Errorf("Name = %q, want %q", conn.Name, "Web Server")
}
if len(conn.Tags) != 2 {
t.Fatalf("len(Tags) = %d, want 2", len(conn.Tags))
}
if conn.Tags[0] != "Prod" || conn.Tags[1] != "Linux" {
t.Errorf("Tags = %v, want [Prod Linux]", conn.Tags)
}
if conn.Options != `{"keepAliveInterval": 60}` {
t.Errorf("Options = %q, want JSON blob", conn.Options)
}
}
func TestListConnections(t *testing.T) {
svc := setupTestService(t)
if _, err := svc.CreateConnection(CreateConnectionInput{
Name: "Server A",
Hostname: "10.0.0.1",
Port: 22,
Protocol: "ssh",
}); err != nil {
t.Fatalf("CreateConnection(A) error: %v", err)
}
if _, err := svc.CreateConnection(CreateConnectionInput{
Name: "Server B",
Hostname: "10.0.0.2",
Port: 3389,
Protocol: "rdp",
}); err != nil {
t.Fatalf("CreateConnection(B) error: %v", err)
}
conns, err := svc.ListConnections()
if err != nil {
t.Fatalf("ListConnections() error: %v", err)
}
if len(conns) != 2 {
t.Fatalf("len(conns) = %d, want 2", len(conns))
}
}
func TestUpdateConnection(t *testing.T) {
svc := setupTestService(t)
conn, err := svc.CreateConnection(CreateConnectionInput{
Name: "Old Name",
Hostname: "10.0.0.1",
Port: 22,
Protocol: "ssh",
Tags: []string{"Dev"},
})
if err != nil {
t.Fatalf("CreateConnection() error: %v", err)
}
updated, err := svc.UpdateConnection(conn.ID, UpdateConnectionInput{
Name: strPtr("New Name"),
Tags: []string{"Prod", "Linux"},
})
if err != nil {
t.Fatalf("UpdateConnection() error: %v", err)
}
if updated.Name != "New Name" {
t.Errorf("Name = %q, want %q", updated.Name, "New Name")
}
if len(updated.Tags) != 2 {
t.Fatalf("len(Tags) = %d, want 2", len(updated.Tags))
}
if updated.Tags[0] != "Prod" {
t.Errorf("Tags[0] = %q, want %q", updated.Tags[0], "Prod")
}
// Hostname should remain unchanged
if updated.Hostname != "10.0.0.1" {
t.Errorf("Hostname = %q, want %q (unchanged)", updated.Hostname, "10.0.0.1")
}
}
func TestDeleteConnection(t *testing.T) {
svc := setupTestService(t)
conn, err := svc.CreateConnection(CreateConnectionInput{
Name: "ToDelete",
Hostname: "10.0.0.1",
Port: 22,
Protocol: "ssh",
})
if err != nil {
t.Fatalf("CreateConnection() error: %v", err)
}
if err := svc.DeleteConnection(conn.ID); err != nil {
t.Fatalf("DeleteConnection() error: %v", err)
}
conns, err := svc.ListConnections()
if err != nil {
t.Fatalf("ListConnections() error: %v", err)
}
if len(conns) != 0 {
t.Errorf("len(conns) = %d, want 0 after delete", len(conns))
}
}

View File

@ -0,0 +1,441 @@
package credentials
import (
"crypto/ed25519"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/x509"
"database/sql"
"encoding/base64"
"encoding/pem"
"fmt"
"github.com/vstockwell/wraith/internal/vault"
"golang.org/x/crypto/ssh"
)
// Credential represents a stored credential (password or SSH key reference).
type Credential struct {
ID int64 `json:"id"`
Name string `json:"name"`
Username string `json:"username"`
Domain string `json:"domain"`
Type string `json:"type"` // "password" or "ssh_key"
SSHKeyID *int64 `json:"sshKeyId"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
// SSHKey represents a stored SSH key.
type SSHKey struct {
ID int64 `json:"id"`
Name string `json:"name"`
KeyType string `json:"keyType"`
Fingerprint string `json:"fingerprint"`
PublicKey string `json:"publicKey"`
CreatedAt string `json:"createdAt"`
}
// CredentialService provides CRUD for credentials with vault encryption.
type CredentialService struct {
db *sql.DB
vault *vault.VaultService
}
// NewCredentialService creates a new CredentialService.
func NewCredentialService(db *sql.DB, vault *vault.VaultService) *CredentialService {
return &CredentialService{db: db, vault: vault}
}
// CreatePassword creates a password credential (password encrypted via vault).
func (s *CredentialService) CreatePassword(name, username, password, domain string) (*Credential, error) {
encrypted, err := s.vault.Encrypt(password)
if err != nil {
return nil, fmt.Errorf("encrypt password: %w", err)
}
result, err := s.db.Exec(
`INSERT INTO credentials (name, username, domain, type, encrypted_value)
VALUES (?, ?, ?, 'password', ?)`,
name, username, domain, encrypted,
)
if err != nil {
return nil, fmt.Errorf("insert credential: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return nil, fmt.Errorf("get credential id: %w", err)
}
return s.getCredential(id)
}
// CreateSSHKey imports an SSH key (private key encrypted via vault).
func (s *CredentialService) CreateSSHKey(name string, privateKeyPEM []byte, passphrase string) (*SSHKey, error) {
// Parse the private key to detect type and extract public key
keyType := DetectKeyType(privateKeyPEM)
// Parse the key to get the public key for fingerprinting.
// Try without passphrase first (handles unencrypted keys even when a
// passphrase is provided for storage), then fall back to using the
// passphrase for encrypted PEM keys.
var signer ssh.Signer
var err error
signer, err = ssh.ParsePrivateKey(privateKeyPEM)
if err != nil && passphrase != "" {
signer, err = ssh.ParsePrivateKeyWithPassphrase(privateKeyPEM, []byte(passphrase))
}
if err != nil {
return nil, fmt.Errorf("parse private key: %w", err)
}
pubKey := signer.PublicKey()
fingerprint := ssh.FingerprintSHA256(pubKey)
publicKeyStr := string(ssh.MarshalAuthorizedKey(pubKey))
// Encrypt private key via vault
encryptedKey, err := s.vault.Encrypt(string(privateKeyPEM))
if err != nil {
return nil, fmt.Errorf("encrypt private key: %w", err)
}
// Encrypt passphrase via vault (if provided)
var encryptedPassphrase sql.NullString
if passphrase != "" {
ep, err := s.vault.Encrypt(passphrase)
if err != nil {
return nil, fmt.Errorf("encrypt passphrase: %w", err)
}
encryptedPassphrase = sql.NullString{String: ep, Valid: true}
}
result, err := s.db.Exec(
`INSERT INTO ssh_keys (name, key_type, fingerprint, public_key, encrypted_private_key, passphrase_encrypted)
VALUES (?, ?, ?, ?, ?, ?)`,
name, keyType, fingerprint, publicKeyStr, encryptedKey, encryptedPassphrase,
)
if err != nil {
return nil, fmt.Errorf("insert ssh key: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return nil, fmt.Errorf("get ssh key id: %w", err)
}
return s.getSSHKey(id)
}
// ListCredentials returns all credentials WITHOUT encrypted values.
func (s *CredentialService) ListCredentials() ([]Credential, error) {
rows, err := s.db.Query(
`SELECT id, name, username, domain, type, ssh_key_id, created_at, updated_at
FROM credentials ORDER BY name`,
)
if err != nil {
return nil, fmt.Errorf("list credentials: %w", err)
}
defer rows.Close()
var creds []Credential
for rows.Next() {
var c Credential
var username, domain sql.NullString
if err := rows.Scan(&c.ID, &c.Name, &username, &domain, &c.Type, &c.SSHKeyID, &c.CreatedAt, &c.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan credential: %w", err)
}
if username.Valid {
c.Username = username.String
}
if domain.Valid {
c.Domain = domain.String
}
creds = append(creds, c)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate credentials: %w", err)
}
if creds == nil {
creds = []Credential{}
}
return creds, nil
}
// ListSSHKeys returns all SSH keys WITHOUT private key data.
func (s *CredentialService) ListSSHKeys() ([]SSHKey, error) {
rows, err := s.db.Query(
`SELECT id, name, key_type, fingerprint, public_key, created_at
FROM ssh_keys ORDER BY name`,
)
if err != nil {
return nil, fmt.Errorf("list ssh keys: %w", err)
}
defer rows.Close()
var keys []SSHKey
for rows.Next() {
var k SSHKey
var keyType, fingerprint, publicKey sql.NullString
if err := rows.Scan(&k.ID, &k.Name, &keyType, &fingerprint, &publicKey, &k.CreatedAt); err != nil {
return nil, fmt.Errorf("scan ssh key: %w", err)
}
if keyType.Valid {
k.KeyType = keyType.String
}
if fingerprint.Valid {
k.Fingerprint = fingerprint.String
}
if publicKey.Valid {
k.PublicKey = publicKey.String
}
keys = append(keys, k)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate ssh keys: %w", err)
}
if keys == nil {
keys = []SSHKey{}
}
return keys, nil
}
// DecryptPassword returns the decrypted password for a credential.
func (s *CredentialService) DecryptPassword(credentialID int64) (string, error) {
var encrypted sql.NullString
err := s.db.QueryRow(
"SELECT encrypted_value FROM credentials WHERE id = ? AND type = 'password'",
credentialID,
).Scan(&encrypted)
if err != nil {
return "", fmt.Errorf("get encrypted password: %w", err)
}
if !encrypted.Valid {
return "", fmt.Errorf("no encrypted value for credential %d", credentialID)
}
password, err := s.vault.Decrypt(encrypted.String)
if err != nil {
return "", fmt.Errorf("decrypt password: %w", err)
}
return password, nil
}
// DecryptSSHKey returns the decrypted private key + passphrase.
func (s *CredentialService) DecryptSSHKey(sshKeyID int64) (privateKey []byte, passphrase string, err error) {
var encryptedKey string
var encryptedPassphrase sql.NullString
err = s.db.QueryRow(
"SELECT encrypted_private_key, passphrase_encrypted FROM ssh_keys WHERE id = ?",
sshKeyID,
).Scan(&encryptedKey, &encryptedPassphrase)
if err != nil {
return nil, "", fmt.Errorf("get encrypted ssh key: %w", err)
}
decryptedKey, err := s.vault.Decrypt(encryptedKey)
if err != nil {
return nil, "", fmt.Errorf("decrypt private key: %w", err)
}
if encryptedPassphrase.Valid {
passphrase, err = s.vault.Decrypt(encryptedPassphrase.String)
if err != nil {
return nil, "", fmt.Errorf("decrypt passphrase: %w", err)
}
}
return []byte(decryptedKey), passphrase, nil
}
// CreateSSHKeyCredential imports an SSH key and creates a matching credentials
// row in a single transaction, returning the Credential record that the
// frontend can immediately use as a credentialId on a connection.
func (s *CredentialService) CreateSSHKeyCredential(name, username string, privateKeyPEM []byte, passphrase string) (*Credential, error) {
sshKey, err := s.CreateSSHKey(name, privateKeyPEM, passphrase)
if err != nil {
return nil, err
}
result, err := s.db.Exec(
`INSERT INTO credentials (name, username, type, ssh_key_id)
VALUES (?, ?, 'ssh_key', ?)`,
name, username, sshKey.ID,
)
if err != nil {
// Best-effort cleanup of the orphaned ssh_key row
_, _ = s.db.Exec("DELETE FROM ssh_keys WHERE id = ?", sshKey.ID)
return nil, fmt.Errorf("insert ssh_key credential: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return nil, fmt.Errorf("get credential id: %w", err)
}
return s.getCredential(id)
}
// DeleteCredential removes a credential.
func (s *CredentialService) DeleteCredential(id int64) error {
_, err := s.db.Exec("DELETE FROM credentials WHERE id = ?", id)
if err != nil {
return fmt.Errorf("delete credential: %w", err)
}
return nil
}
// DeleteSSHKey removes an SSH key.
func (s *CredentialService) DeleteSSHKey(id int64) error {
_, err := s.db.Exec("DELETE FROM ssh_keys WHERE id = ?", id)
if err != nil {
return fmt.Errorf("delete ssh key: %w", err)
}
return nil
}
// DetectKeyType parses a PEM key and returns its type (rsa, ed25519, ecdsa).
func DetectKeyType(pemData []byte) string {
block, _ := pem.Decode(pemData)
if block == nil {
return "unknown"
}
// Try OpenSSH format first (ssh.MarshalPrivateKey produces OPENSSH PRIVATE KEY blocks)
if block.Type == "OPENSSH PRIVATE KEY" {
return detectOpenSSHKeyType(block.Bytes)
}
// Try PKCS8 format
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
switch key.(type) {
case *rsa.PrivateKey:
return "rsa"
case ed25519.PrivateKey:
return "ed25519"
case *ecdsa.PrivateKey:
return "ecdsa"
}
}
// Try RSA PKCS1
if _, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
return "rsa"
}
// Try EC
if _, err := x509.ParseECPrivateKey(block.Bytes); err == nil {
return "ecdsa"
}
return "unknown"
}
// detectOpenSSHKeyType parses the OpenSSH private key format to determine key type.
func detectOpenSSHKeyType(data []byte) string {
// OpenSSH private key format: "openssh-key-v1\0" magic, then fields.
// We parse the key using ssh package to determine the type.
// Re-encode to PEM to use ssh.ParsePrivateKey which gives us the signer.
pemBlock := &pem.Block{
Type: "OPENSSH PRIVATE KEY",
Bytes: data,
}
pemBytes := pem.EncodeToMemory(pemBlock)
signer, err := ssh.ParsePrivateKey(pemBytes)
if err != nil {
return "unknown"
}
return classifyPublicKey(signer.PublicKey())
}
// classifyPublicKey determines the key type from an ssh.PublicKey.
func classifyPublicKey(pub ssh.PublicKey) string {
keyType := pub.Type()
switch keyType {
case "ssh-rsa":
return "rsa"
case "ssh-ed25519":
return "ed25519"
case "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521":
return "ecdsa"
default:
return keyType
}
}
// 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
err := s.db.QueryRow(
`SELECT id, name, username, domain, type, ssh_key_id, created_at, updated_at
FROM credentials WHERE id = ?`, id,
).Scan(&c.ID, &c.Name, &username, &domain, &c.Type, &c.SSHKeyID, &c.CreatedAt, &c.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("get credential: %w", err)
}
if username.Valid {
c.Username = username.String
}
if domain.Valid {
c.Domain = domain.String
}
return &c, nil
}
// getSSHKey retrieves a single SSH key by ID (without private key data).
func (s *CredentialService) getSSHKey(id int64) (*SSHKey, error) {
var k SSHKey
var keyType, fingerprint, publicKey sql.NullString
err := s.db.QueryRow(
`SELECT id, name, key_type, fingerprint, public_key, created_at
FROM ssh_keys WHERE id = ?`, id,
).Scan(&k.ID, &k.Name, &keyType, &fingerprint, &publicKey, &k.CreatedAt)
if err != nil {
return nil, fmt.Errorf("get ssh key: %w", err)
}
if keyType.Valid {
k.KeyType = keyType.String
}
if fingerprint.Valid {
k.Fingerprint = fingerprint.String
}
if publicKey.Valid {
k.PublicKey = publicKey.String
}
return &k, nil
}
// generateFingerprint generates an SSH fingerprint string from a public key.
func generateFingerprint(pubKey ssh.PublicKey) string {
return ssh.FingerprintSHA256(pubKey)
}
// marshalPublicKey returns the authorized_keys format of an SSH public key.
func marshalPublicKey(pubKey ssh.PublicKey) string {
return base64.StdEncoding.EncodeToString(pubKey.Marshal())
}
// ecdsaCurveName returns the name for an ECDSA curve.
func ecdsaCurveName(curve elliptic.Curve) string {
switch curve {
case elliptic.P256():
return "nistp256"
case elliptic.P384():
return "nistp384"
case elliptic.P521():
return "nistp521"
default:
return "unknown"
}
}

View File

@ -0,0 +1,176 @@
package credentials
import (
"crypto/ed25519"
"crypto/rand"
"encoding/pem"
"path/filepath"
"testing"
"github.com/vstockwell/wraith/internal/db"
"github.com/vstockwell/wraith/internal/vault"
"golang.org/x/crypto/ssh"
)
func setupCredentialService(t *testing.T) *CredentialService {
t.Helper()
d, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
if err := db.Migrate(d); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { d.Close() })
salt := []byte("test-salt-exactly-32-bytes-long!")
key := vault.DeriveKey("testpassword", salt)
vs := vault.NewVaultService(key)
return NewCredentialService(d, vs)
}
func TestCreatePasswordCredential(t *testing.T) {
svc := setupCredentialService(t)
cred, err := svc.CreatePassword("Test Cred", "admin", "secret123", "")
if err != nil {
t.Fatal(err)
}
if cred.Name != "Test Cred" {
t.Error("wrong name")
}
if cred.Type != "password" {
t.Error("wrong type")
}
}
func TestDecryptPassword(t *testing.T) {
svc := setupCredentialService(t)
cred, _ := svc.CreatePassword("Test", "admin", "mypassword", "")
password, err := svc.DecryptPassword(cred.ID)
if err != nil {
t.Fatal(err)
}
if password != "mypassword" {
t.Errorf("got %q, want mypassword", password)
}
}
func TestListCredentialsExcludesSecrets(t *testing.T) {
svc := setupCredentialService(t)
svc.CreatePassword("Cred1", "user1", "pass1", "")
svc.CreatePassword("Cred2", "user2", "pass2", "")
creds, err := svc.ListCredentials()
if err != nil {
t.Fatal(err)
}
if len(creds) != 2 {
t.Errorf("got %d, want 2", len(creds))
}
}
func TestCreateSSHKey(t *testing.T) {
svc := setupCredentialService(t)
// Generate a test key
_, priv, _ := ed25519.GenerateKey(rand.Reader)
pemBlock, _ := ssh.MarshalPrivateKey(priv, "")
keyPEM := pem.EncodeToMemory(pemBlock)
key, err := svc.CreateSSHKey("My Key", keyPEM, "")
if err != nil {
t.Fatal(err)
}
if key.KeyType != "ed25519" {
t.Errorf("KeyType = %q, want ed25519", key.KeyType)
}
if key.Fingerprint == "" {
t.Error("fingerprint should not be empty")
}
}
func TestDecryptSSHKey(t *testing.T) {
svc := setupCredentialService(t)
_, priv, _ := ed25519.GenerateKey(rand.Reader)
pemBlock, _ := ssh.MarshalPrivateKey(priv, "")
keyPEM := pem.EncodeToMemory(pemBlock)
key, _ := svc.CreateSSHKey("My Key", keyPEM, "testpass")
decryptedKey, passphrase, err := svc.DecryptSSHKey(key.ID)
if err != nil {
t.Fatal(err)
}
if len(decryptedKey) == 0 {
t.Error("decrypted key should not be empty")
}
if passphrase != "testpass" {
t.Errorf("passphrase = %q, want testpass", passphrase)
}
}
func TestDetectKeyType(t *testing.T) {
_, priv, _ := ed25519.GenerateKey(rand.Reader)
pemBlock, _ := ssh.MarshalPrivateKey(priv, "")
keyPEM := pem.EncodeToMemory(pemBlock)
if got := DetectKeyType(keyPEM); got != "ed25519" {
t.Errorf("got %q", got)
}
}
func TestDeleteCredential(t *testing.T) {
svc := setupCredentialService(t)
cred, _ := svc.CreatePassword("ToDelete", "user", "pass", "")
err := svc.DeleteCredential(cred.ID)
if err != nil {
t.Fatal(err)
}
creds, _ := svc.ListCredentials()
if len(creds) != 0 {
t.Errorf("got %d credentials, want 0", len(creds))
}
}
func TestDeleteSSHKey(t *testing.T) {
svc := setupCredentialService(t)
_, priv, _ := ed25519.GenerateKey(rand.Reader)
pemBlock, _ := ssh.MarshalPrivateKey(priv, "")
keyPEM := pem.EncodeToMemory(pemBlock)
key, _ := svc.CreateSSHKey("ToDelete", keyPEM, "")
err := svc.DeleteSSHKey(key.ID)
if err != nil {
t.Fatal(err)
}
keys, _ := svc.ListSSHKeys()
if len(keys) != 0 {
t.Errorf("got %d keys, want 0", len(keys))
}
}
func TestListSSHKeys(t *testing.T) {
svc := setupCredentialService(t)
_, priv, _ := ed25519.GenerateKey(rand.Reader)
pemBlock, _ := ssh.MarshalPrivateKey(priv, "")
keyPEM := pem.EncodeToMemory(pemBlock)
svc.CreateSSHKey("Key1", keyPEM, "")
svc.CreateSSHKey("Key2", keyPEM, "")
keys, err := svc.ListSSHKeys()
if err != nil {
t.Fatal(err)
}
if len(keys) != 2 {
t.Errorf("got %d keys, want 2", len(keys))
}
}
func TestCreatePasswordWithDomain(t *testing.T) {
svc := setupCredentialService(t)
cred, err := svc.CreatePassword("Domain Cred", "admin", "secret", "example.com")
if err != nil {
t.Fatal(err)
}
if cred.Domain != "example.com" {
t.Errorf("domain = %q, want example.com", cred.Domain)
}
}

34
internal/db/migrations.go Normal file
View File

@ -0,0 +1,34 @@
package db
import (
"database/sql"
"embed"
"fmt"
"sort"
)
//go:embed migrations/*.sql
var migrationFiles embed.FS
func Migrate(db *sql.DB) error {
entries, err := migrationFiles.ReadDir("migrations")
if err != nil {
return fmt.Errorf("read migrations: %w", err)
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name() < entries[j].Name()
})
for _, entry := range entries {
content, err := migrationFiles.ReadFile("migrations/" + entry.Name())
if err != nil {
return fmt.Errorf("read migration %s: %w", entry.Name(), err)
}
if _, err := db.Exec(string(content)); err != nil {
return fmt.Errorf("execute migration %s: %w", entry.Name(), err)
}
}
return nil
}

View File

@ -0,0 +1,101 @@
-- 001_initial.sql
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
parent_id INTEGER REFERENCES groups(id) ON DELETE SET NULL,
sort_order INTEGER DEFAULT 0,
icon TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS ssh_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
key_type TEXT,
fingerprint TEXT,
public_key TEXT,
encrypted_private_key TEXT NOT NULL,
passphrase_encrypted TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
username TEXT,
domain TEXT,
type TEXT NOT NULL CHECK(type IN ('password','ssh_key')),
encrypted_value TEXT,
ssh_key_id INTEGER REFERENCES ssh_keys(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS connections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
hostname TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 22,
protocol TEXT NOT NULL CHECK(protocol IN ('ssh','rdp')),
group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL,
credential_id INTEGER REFERENCES credentials(id) ON DELETE SET NULL,
color TEXT,
tags TEXT DEFAULT '[]',
notes TEXT,
options TEXT DEFAULT '{}',
sort_order INTEGER DEFAULT 0,
last_connected DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS themes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
foreground TEXT NOT NULL,
background TEXT NOT NULL,
cursor TEXT NOT NULL,
black TEXT NOT NULL,
red TEXT NOT NULL,
green TEXT NOT NULL,
yellow TEXT NOT NULL,
blue TEXT NOT NULL,
magenta TEXT NOT NULL,
cyan TEXT NOT NULL,
white TEXT NOT NULL,
bright_black TEXT NOT NULL,
bright_red TEXT NOT NULL,
bright_green TEXT NOT NULL,
bright_yellow TEXT NOT NULL,
bright_blue TEXT NOT NULL,
bright_magenta TEXT NOT NULL,
bright_cyan TEXT NOT NULL,
bright_white TEXT NOT NULL,
selection_bg TEXT,
selection_fg TEXT,
is_builtin BOOLEAN DEFAULT 0
);
CREATE TABLE IF NOT EXISTS connection_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
connection_id INTEGER NOT NULL REFERENCES connections(id) ON DELETE CASCADE,
protocol TEXT NOT NULL,
connected_at DATETIME DEFAULT CURRENT_TIMESTAMP,
disconnected_at DATETIME,
duration_secs INTEGER
);
CREATE TABLE IF NOT EXISTS host_keys (
hostname TEXT NOT NULL,
port INTEGER NOT NULL,
key_type TEXT NOT NULL,
fingerprint TEXT NOT NULL,
raw_key TEXT,
first_seen DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (hostname, port, key_type)
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);

View File

@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
title TEXT,
model TEXT NOT NULL,
messages TEXT NOT NULL DEFAULT '[]',
tokens_in INTEGER DEFAULT 0,
tokens_out INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

Some files were not shown because too many files have changed in this diff Show More