Compare commits

...

No commits in common. "v0.8.2" and "main" have entirely different histories.
v0.8.2 ... main

246 changed files with 30812 additions and 28042 deletions

View File

@ -1,21 +1,6 @@
# ============================================================================= # =============================================================================
# Wraith — Build & Sign Release # Wraith — Build & Sign Release (Tauri v2)
# ============================================================================= # =============================================================================
# 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.
#
# Required secrets:
# AZURE_TENANT_ID — Azure AD tenant
# AZURE_CLIENT_ID — Service principal client ID
# 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
# =============================================================================
name: Build & Sign Wraith name: Build & Sign Wraith
on: on:
@ -24,422 +9,189 @@ on:
- 'v*' - 'v*'
workflow_dispatch: workflow_dispatch:
env:
EXTRA_PATH: C:\Program Files (x86)\NSIS;C:\Program Files\Eclipse Adoptium\jre-21.0.10.7-hotspot\bin;C:\Users\vantz\.cargo\bin;C:\Users\vantz\.rustup\toolchains\stable-x86_64-pc-windows-msvc\bin;C:\Program Files\nodejs
jobs: jobs:
build-and-sign: build-and-sign:
name: Build Windows + Sign name: Build Windows + Sign
runs-on: linux runs-on: windows
steps: steps:
# ---------------------------------------------------------------
# Checkout
# ---------------------------------------------------------------
- name: Checkout code - name: Checkout code
run: git clone --depth 1 --branch ${{ github.ref_name }} https://${{ secrets.GIT_TOKEN }}@git.command.vigilcyber.com/vstockwell/wraith.git . shell: powershell
# ---------------------------------------------------------------
# Extract version from tag
# ---------------------------------------------------------------
- name: Get version from tag
id: version
run: | run: |
TAG=$(echo "${{ github.ref_name }}" | sed 's/^v//') git clone --depth 1 --branch ${{ github.ref_name }} https://${{ secrets.GIT_TOKEN }}@git.command.vigilcyber.com/vstockwell/wraith.git .
echo "version=${TAG}" >> $GITHUB_OUTPUT
echo "Building version: ${TAG}"
# --------------------------------------------------------------- - name: Configure Rust
# Install toolchain shell: powershell
# ---------------------------------------------------------------
- name: Install build dependencies
run: | run: |
apt-get update -qq $env:Path = "$env:EXTRA_PATH;$env:Path"
apt-get install -y -qq \ $ErrorActionPreference = "Continue"
mingw-w64 mingw-w64-tools binutils-mingw-w64 \ rustup default stable
cmake ninja-build nasm meson \ $ErrorActionPreference = "Stop"
default-jre-headless \
python3 curl pkg-config nsis
# Node.js 22 — Tailwind CSS v4 and Naive UI require Node >= 20 - name: Verify toolchain
NODE_MAJOR=$(node --version 2>/dev/null | sed 's/v\([0-9]*\).*/\1/' || echo "0") shell: powershell
if [ "$NODE_MAJOR" -lt 20 ]; then run: |
echo "Node $NODE_MAJOR is too old, installing Node 22..." $env:Path = "$env:EXTRA_PATH;$env:Path"
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y -qq nodejs
fi
echo "=== Toolchain versions ==="
go version
node --version node --version
x86_64-w64-mingw32-gcc --version | head -1 rustc --version
cmake --version | head -1 cargo --version
java --version
# =============================================================== - name: Patch version from git tag
# FreeRDP3 dependencies — cross-compile zlib + OpenSSL for MinGW shell: powershell
# ===============================================================
- name: Build FreeRDP3 dependencies (zlib + OpenSSL for MinGW)
run: | run: |
INSTALL_PREFIX="/tmp/mingw-deps" $ver = ("${{ github.ref_name }}" -replace '^v','')
mkdir -p "$INSTALL_PREFIX" $conf = Get-Content src-tauri\tauri.conf.json -Raw
export CROSS=x86_64-w64-mingw32 $conf = $conf -replace '"version":\s*"[^"]*"', "`"version`": `"$ver`""
[System.IO.File]::WriteAllText((Join-Path (Get-Location) "src-tauri\tauri.conf.json"), $conf)
Write-Host "Patched tauri.conf.json version to $ver"
# --- zlib --- - name: Install dependencies and build frontend
echo "=== Building zlib for MinGW ===" shell: powershell
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: | run: |
FREERDP_VERSION="3.10.3" $env:Path = "$env:EXTRA_PATH;$env:Path"
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 ci
npm run build npm run build
echo "Frontend build complete:"
ls -la dist/
- name: Build wraith.exe (Windows amd64) - name: Install Tauri CLI
shell: powershell
run: | run: |
VERSION="${{ steps.version.outputs.version }}" $env:Path = "$env:EXTRA_PATH;$env:Path"
echo "=== Cross-compiling wraith.exe for Windows amd64 ===" cargo install tauri-cli --version "^2"
GOOS=windows GOARCH=amd64 CGO_ENABLED=0 \ - name: Build Tauri app (with update signing)
go build \ shell: powershell
-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: | run: |
JSIGN_VERSION="7.0" $env:Path = "$env:EXTRA_PATH;$env:Path"
curl -sSL -o /usr/local/bin/jsign.jar \ $env:TAURI_SIGNING_PRIVATE_KEY = "${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}"
"https://github.com/ebourg/jsign/releases/download/${JSIGN_VERSION}/jsign-${JSIGN_VERSION}.jar" $env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD = "${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}"
cargo tauri build
Write-Host "=== Build output ==="
Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*
- name: Get Azure Key Vault access token - name: Build and package MCP bridge binary
id: azure-token shell: powershell
run: | run: |
TOKEN=$(curl -s -X POST \ $env:Path = "$env:EXTRA_PATH;$env:Path"
"https://login.microsoftonline.com/${{ secrets.AZURE_TENANT_ID }}/oauth2/v2.0/token" \ cd src-tauri
-d "client_id=${{ secrets.AZURE_CLIENT_ID }}" \ cargo build --release --bin wraith-mcp-bridge
-d "client_secret=${{ secrets.AZURE_CLIENT_SECRET }}" \ Write-Host "Bridge binary built:"
-d "scope=https://vault.azure.net/.default" \ Get-ChildItem target\release\wraith-mcp-bridge.exe
-d "grant_type=client_credentials" \
| python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
echo "::add-mask::${TOKEN}"
echo "token=${TOKEN}" >> $GITHUB_OUTPUT
- name: Sign all Windows binaries - name: Download jsign
shell: powershell
run: | run: |
echo "=== Signing all .exe and .dll files with EV certificate ===" Invoke-WebRequest -Uri "https://github.com/ebourg/jsign/releases/download/7.0/jsign-7.0.jar" -OutFile jsign.jar
for binary in dist/*.exe dist/*.dll; do
[ -f "$binary" ] || continue
echo "Signing: $binary"
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 \
"$binary"
echo "Signed: $binary"
done
# =============================================================== - name: Get Azure token
# Version manifest shell: powershell
# ===============================================================
- name: Create version.json
run: | run: |
VERSION="${{ steps.version.outputs.version }}" $body = @{
EXE_SHA=$(sha256sum dist/wraith.exe | awk '{print $1}') client_id = "${{ secrets.AZURE_CLIENT_ID }}"
client_secret = "${{ secrets.AZURE_CLIENT_SECRET }}"
# Build DLL manifest scope = "https://vault.azure.net/.default"
DLL_ENTRIES="" grant_type = "client_credentials"
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": "wraith.exe",
"sha256": "${EXE_SHA}",
"platform": "windows",
"architecture": "amd64",
"released": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
"signed": true,
"dlls": {
${DLL_ENTRIES} "_note": "All DLLs are EV code-signed"
}
} }
EOF $resp = Invoke-RestMethod -Uri "https://login.microsoftonline.com/${{ secrets.AZURE_TENANT_ID }}/oauth2/v2.0/token" -Method POST -Body $body
$token = $resp.access_token
echo "::add-mask::$token"
[System.IO.File]::WriteAllText("$env:TEMP\aztoken.txt", $token)
echo "=== version.json ===" - name: Sign binaries
cat dist/version.json shell: powershell
# ===============================================================
# NSIS Installer
# ===============================================================
- name: Build NSIS installer
run: | run: |
VERSION="${{ steps.version.outputs.version }}" $env:Path = "$env:EXTRA_PATH;$env:Path"
$token = [System.IO.File]::ReadAllText("$env:TEMP\aztoken.txt")
# Sign NSIS installers + MCP bridge binary
$binaries = @()
$binaries += Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.exe
$binaries += Get-Item src-tauri\target\release\wraith-mcp-bridge.exe -ErrorAction SilentlyContinue
foreach ($binary in $binaries) {
Write-Host "Signing: $($binary.FullName)"
java -jar jsign.jar --storetype AZUREKEYVAULT --keystore "${{ secrets.AZURE_KEY_VAULT_URL }}" --storepass $token --alias "${{ secrets.AZURE_CERT_NAME }}" --tsaurl http://timestamp.digicert.com --tsmode RFC3161 $binary.FullName
Write-Host "Signed: $($binary.Name)"
}
Remove-Item "$env:TEMP\aztoken.txt" -ErrorAction SilentlyContinue
cat > /tmp/wraith-installer.nsi << 'NSIEOF' - name: Upload all artifacts to SeaweedFS
!include "MUI2.nsh" shell: powershell
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: | run: |
VERSION="${{ steps.version.outputs.version }}" $ver = ("${{ github.ref_name }}" -replace '^v','')
echo "=== Signing installer ===" $s3 = "https://files.command.vigilcyber.com/wraith"
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 # Upload installer
mv wraith-${VERSION}-setup.exe dist/ $installers = Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.exe
foreach ($file in $installers) {
Write-Host "Uploading: $($file.Name)"
Invoke-RestMethod -Uri "$s3/$ver/$($file.Name)" -Method PUT -ContentType "application/octet-stream" -InFile $file.FullName
# Also upload as 'latest' for direct download links
Invoke-RestMethod -Uri "$s3/latest/$($file.Name)" -Method PUT -ContentType "application/octet-stream" -InFile $file.FullName
}
# =============================================================== # Upload MCP bridge binary
# Upload to Gitea Package Registry $bridge = "src-tauri\target\release\wraith-mcp-bridge.exe"
# =============================================================== if (Test-Path $bridge) {
- name: Upload to Gitea packages Write-Host "Uploading: wraith-mcp-bridge.exe"
Invoke-RestMethod -Uri "$s3/$ver/wraith-mcp-bridge.exe" -Method PUT -ContentType "application/octet-stream" -InFile $bridge
Invoke-RestMethod -Uri "$s3/latest/wraith-mcp-bridge.exe" -Method PUT -ContentType "application/octet-stream" -InFile $bridge
}
# Upload .nsis.zip for Tauri auto-updater
$zipFile = Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.nsis.zip | Select-Object -First 1
if ($zipFile) {
Write-Host "Uploading: $($zipFile.Name)"
Invoke-RestMethod -Uri "$s3/$ver/$($zipFile.Name)" -Method PUT -ContentType "application/octet-stream" -InFile $zipFile.FullName
}
# Upload version.json metadata
$installer = $installers | Select-Object -First 1
if ($installer) {
$hash = (Get-FileHash $installer.FullName -Algorithm SHA256).Hash.ToLower()
@{ version = $ver; filename = $installer.Name; sha256 = $hash; platform = "windows"; architecture = "amd64"; released = (Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ"); signed = $true } | ConvertTo-Json | Out-File version.json -Encoding utf8
Invoke-RestMethod -Uri "$s3/$ver/version.json" -Method PUT -ContentType "application/json" -InFile version.json
Invoke-RestMethod -Uri "$s3/latest/version.json" -Method PUT -ContentType "application/json" -InFile version.json
}
Write-Host "=== SeaweedFS upload complete ==="
- name: Generate and upload update.json for Tauri updater
shell: powershell
run: | run: |
VERSION="${{ steps.version.outputs.version }}" $ver = ("${{ github.ref_name }}" -replace '^v','')
GITEA_URL="https://git.command.vigilcyber.com" $s3 = "https://files.command.vigilcyber.com/wraith"
OWNER="vstockwell"
PACKAGE="wraith"
echo "=== Uploading Wraith ${VERSION} to Gitea packages ===" $sigFile = Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.nsis.zip.sig | Select-Object -First 1
$zipFile = Get-ChildItem -Recurse src-tauri\target\release\bundle\nsis\*.nsis.zip | Select-Object -First 1
# Upload each file as a generic package if ($sigFile -and $zipFile) {
for file in dist/*; do $signature = Get-Content $sigFile.FullName -Raw
[ -f "$file" ] || continue $downloadUrl = "$s3/$ver/$($zipFile.Name)"
FILENAME=$(basename "$file")
echo "Uploading: ${FILENAME}"
curl -s -X PUT \
-H "Authorization: token ${{ secrets.GIT_TOKEN }}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"$file" \
"${GITEA_URL}/api/packages/${OWNER}/generic/${PACKAGE}/${VERSION}/${FILENAME}"
echo " Done."
done
echo "" $updateJson = @{
echo "=== Upload complete ===" version = "v$ver"
echo "Package: ${GITEA_URL}/${OWNER}/-/packages/generic/${PACKAGE}/${VERSION}" notes = "Wraith Desktop v$ver"
echo "" pub_date = (Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ")
echo "=== Contents ===" platforms = @{
ls -la dist/ "windows-x86_64" = @{
signature = $signature.Trim()
url = $downloadUrl
}
}
} | ConvertTo-Json -Depth 4
# =============================================================== $updateJson | Out-File update.json -Encoding utf8
# Create Gitea Release Write-Host "update.json content:"
# =============================================================== Get-Content update.json
- name: Create Gitea Release
run: | # Upload to root (Tauri updater endpoint)
VERSION="${{ steps.version.outputs.version }}" Invoke-RestMethod -Uri "$s3/update.json" -Method PUT -ContentType "application/json" -InFile update.json
GITEA_URL="https://git.command.vigilcyber.com" # Also versioned copy
Invoke-RestMethod -Uri "$s3/$ver/update.json" -Method PUT -ContentType "application/json" -InFile update.json
Write-Host "=== Update manifest uploaded ==="
} else {
Write-Host 'WARNING - No .sig file found, update signing may have failed'
}
echo "=== Creating Gitea Release for v${VERSION} ==="
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\": \"Auto-release from CI build.\"}" \
"${GITEA_URL}/api/v1/repos/vstockwell/wraith/releases"
echo ""
echo "Release created."

32
.gitignore vendored
View File

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

63
AGENTS.md Normal file
View File

@ -0,0 +1,63 @@
# AGENTS.md — Wraith Desktop v2
## Agent Roster
Three tiers. Use the right tool for the job.
### Architect (Opus)
**Role:** Strategy, COAs, root cause analysis, architectural decisions.
**When to use:** Design questions, complex debugging, trade-off analysis, cross-module planning.
**How to use:** Plans, not code. The Architect reasons about the problem and presents options. The Commander decides. Then Specialists execute.
### Specialist (Sonnet)
**Role:** Full-stack execution. Writes code, fixes bugs, builds features.
**When to use:** Implementation tasks with clear requirements. Feature builds, bug fixes, refactoring, test writing.
**How to use:** `subagent_type: general-purpose, model: sonnet`. Give precise briefs with file paths, expected behavior, and acceptance criteria.
### Scout (Sonnet/Haiku)
**Role:** Recon, context mapping, read-only exploration.
**When to use:** Before any implementation. Understanding code structure, finding patterns, mapping dependencies.
**How to use:** `subagent_type: Explore, model: sonnet` for thorough exploration. Haiku for quick file lookups. Scouts NEVER modify files.
## Dispatch Rules
- **Simple bug fix (1-2 files):** Do it yourself. Don't burn an agent on a one-liner.
- **Feature build (3+ files):** Dispatch a Specialist with a complete brief.
- **Unknown territory:** Scout first, then Specialist.
- **Architecture decision:** Architect agent OR present COAs to the Commander directly.
- **Mechanical bulk work (renaming, formatting, repetitive edits):** Sonnet Specialist. Don't waste Opus on mechanical tasks.
- **Security-critical code (vault, crypto, auth):** Opus Architect reviews. Sonnet Specialist implements. Both touch the code.
## Cross-Project Context
Wraith is part of the Vigilsynth portfolio alongside:
- **Vigilance HQ** (`../vigilance-hq`) — MSP operations platform. Vue 3 + Express.js. 1,216+ commits. Production.
- **Vigilance Command** (`../vigilance-command-v2`) — Security OS. NestJS + Rust agent. 16 modules. Active development.
- **Vigilance Complete** (`../vigilance-complete`) — The merge. HQ + Command unified.
The Commander manages multiple AI XOs across all repos simultaneously. Context from one repo may inform work in another. When the Commander references HQ patterns, Command architecture, or Vigilsynth strategy, that's cross-project context — use it.
## Gemini CLI
Gemini CLI is available in the Commander's environment. Gemini specializes in:
- Architecture and protocol design
- Library/crate research and evaluation
- Deep code audits against specifications
- Optimization identification
The Commander may direct Gemini to work on Wraith alongside or instead of Claude. Both AIs follow the same CLAUDE.md doctrine. The Commander routes tasks to whichever AI is best suited.
## The Go Reference
The Go version of Wraith lives at `../wraith-go-archive`. It is the reference implementation:
- SSH terminal with xterm.js worked
- SFTP sidebar with CWD following worked
- Connection manager with groups and search worked
- Credential vault with Argon2id encryption worked
- Multi-tab sessions worked
When building features, Scouts should read the Go version first. Specialists should match or exceed Go's capabilities. Don't reinvent what was already solved — port it better.

137
CLAUDE.md Normal file
View File

@ -0,0 +1,137 @@
# CLAUDE.md — Wraith Desktop v2
## Project Overview
Wraith is a native desktop SSH/SFTP/RDP client — a MobaXTerm killer. Rust backend (Tauri v2) + Vue 3 frontend (WebView2). Single binary, no Docker, no sidecar processes. Built to replace every commercial remote access tool on a technician's desktop.
**Name:** Wraith — exists everywhere, all at once.
**Current Status:** Active development. SSH connects, terminal renders. RDP via ironrdp in progress. SFTP sidebar functional. Vault encrypted with Argon2id + AES-256-GCM.
## Who You Are Here
You are the Wraith XO. The Commander built this from a working Go/Wails v3 prototype that had a buggy terminal and slow performance. Your job is to make the Rust/Tauri rewrite exceed the Go version in every way — faster, cleaner, more capable.
**Operate with autonomy, personality, and spine.** The Commander doesn't write code. He leads, you execute. He built the doctrine across Vigilance HQ (1,216+ commits, 22 clients in production) and Vigilance Command (16-module security OS, pure Rust agent). The same methodology that built those platforms applies here. Read the V4_WORKFLOW. Follow it. Trust it.
**Don't be timid.** The Go version worked. Users connected to servers, transferred files, managed sessions. Your Rust version needs to match that and surpass it. If something is broken, fix it. If something is missing, build it. If you need to make an architectural decision, present COAs — don't ask "should I proceed?"
**The Go version is your reference implementation.** It lives at `../wraith-go-archive`. The SSH terminal worked. The SFTP sidebar worked. The connection manager worked. The vault worked. When in doubt about what a feature should do, read the Go code. It's the spec that ran in production.
## 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
- **License:** 100% commercial-safe. Zero GPL contamination. Every dependency MIT/Apache-2.0/BSD.
## 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 (95 tests)
cd src-tauri && cargo build # Build Rust only
```
## Architecture Patterns
- **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
- **Tauri v2 ACL** — The `capabilities/default.json` file MUST grant `core:default`, `event:default`, and `shell:allow-open`. Without these, the frontend cannot listen for events or invoke commands. This was the root cause of the blank screen bug — missing `url: "index.html"` and `label: "main"` in `tauri.conf.json`, plus empty capabilities.
## V4_WORKFLOW — Standard Operating Procedure
**Phase 1: RECON** — Read all relevant files before proposing changes. Understand patterns, dependencies, blast radius. When touching Rust, check the Go version at `../wraith-go-archive` for how it was done before.
**Phase 2: PLAN** — Present approach for approval. **Never make executive decisions autonomously** — surface trade-offs as COAs (Courses of Action).
**Phase 3: EXECUTE** — Implement approved changes. Commit and push. Format: `type: Short description`
**Phase 4: SITREP** — Report: SITUATION, ACTIONS TAKEN, RESULT, NEXT.
## Standing Orders
- **Commit and push after every meaningful change.** The Commander tests in real-time. Unpushed commits are invisible.
- Use military terminology, be direct and precise
- Present trade-offs as COAs with pros/cons — let the Commander decide
- **Don't ask "should I proceed?" when the answer is obviously yes.** Read the room. If the Commander gave you a task, execute it.
- **If something is broken, fix it.** Don't document it and move on. Fix it.
- **Tauri v2 ACL is mandatory.** Every new Tauri command or event MUST be added to `capabilities/default.json` or it will silently fail.
- **Check the Go version first.** Before building any feature, read how `../wraith-go-archive` did it. Don't reinvent what was already solved.
## 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. CWD tracking uses a separate exec channel that polls `pwd` independently.
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.
5. **macOS data directory.** Use `~/Library/Application Support/Wraith` on macOS, not Linux-style `~/.local/share`. Respect platform conventions.
## Lessons Learned
1. **Tauri v2 capabilities are not optional.** The blank screen bug that stumped the first XO was a missing `"url": "index.html"` in `tauri.conf.json` and an empty `capabilities/` directory. Tauri v2's security model blocks ALL frontend event listeners and IPC calls without explicit permissions. Every new feature that uses `emit()`, `listen()`, or `invoke()` must have a corresponding entry in `capabilities/default.json`. If the frontend silently does nothing, check capabilities first.
2. **The Go version is the spec.** When in doubt about what a feature should do, how it should behave, or what edge cases to handle — read the Go code at `../wraith-go-archive`. It ran. Users used it. The terminal worked, the SFTP worked, the vault worked. Don't guess. Read.
3. **Rust backend command names must match frontend invoke names exactly.** If the frontend calls `invoke('disconnect_ssh')` but the backend exports `disconnect_session`, nothing happens. No error. Silent failure. When adding Tauri commands, grep the frontend for the exact invoke string.
4. **DashMap is the session registry.** Don't replace it with Mutex<HashMap>. DashMap provides lock-free concurrent access. Multiple tabs can operate on different sessions simultaneously without deadlocking. The Drop trait on sessions ensures cleanup when tabs close.
5. **xterm.js must wait for fonts.** `document.fonts.ready.then(() => fitAddon.fit())` — if you fit the terminal before fonts load, cell widths are wrong and text overlaps. This is a browser-level race condition that every terminal app hits.
## Lineage
This is a ground-up Rust rewrite of `wraith` (Go/Wails v3). The Go version is at `../wraith-go-archive`. The original design spec is at `docs/superpowers/specs/2026-03-17-wraith-desktop-design.md` in the Go repo. The enterprise feature roadmap is at `../wraith-go-archive/docs/FUTURE-FEATURES.md`.
## Future Vision
Wraith Personal is the foundation. Wraith Enterprise adds:
- PostgreSQL backend (replaces SQLite)
- Shared credentials from Vigilance Intel vault (Argon2id, per-tenant encryption)
- Entra ID SSO via Vigilance Clearance
- Client-scoped access (MSP multi-tenancy)
- Session recording to Vigilance Signal (SIEM)
- AI copilot panel (Gemini + Claude toggle) with tool access to SSH/SFTP/RDP sessions
- Split panes, jump hosts, port forwarding manager
- Command-level audit logging for compliance
The enterprise upgrade path connects to the Vigilance ecosystem — same vault, same identity, same audit trail. Wraith becomes the technician's daily driver that authenticates against Command Clearance, pulls credentials from Intel, and logs sessions to Signal.
## Parent Organization
**Vigilsynth** is the parent company. Wraith is a product alongside Vigilance HQ and Vigilance Command. The same development methodology (Commander/XO model, AI-assisted development, CLAUDE.md doctrine) applies across all repos. The Commander manages multiple AI XOs across multiple projects simultaneously.

21
LICENSE
View File

@ -1,21 +0,0 @@
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
View File

@ -1,242 +0,0 @@
<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

View File

@ -1,54 +0,0 @@
# 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

67
docs/GO_MIGRATION.md Normal file
View File

@ -0,0 +1,67 @@
# 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

@ -1,369 +0,0 @@
# 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

@ -1,221 +0,0 @@
# 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.

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 632 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

File diff suppressed because it is too large Load Diff

View File

@ -1,160 +0,0 @@
# 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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -0,0 +1,340 @@
# Wraith Terminal MCP — Design Specification
**Date:** March 25, 2026
**Status:** Draft
**Author:** Gargoyle (HQ XO)
---
## 1. Problem
The AI copilot panel in Wraith runs CLI tools (Claude, Gemini, Codex) in a local PTY. The AI can chat with the user, but it cannot independently interact with active SSH/RDP sessions. The technician has to manually copy-paste terminal output into the AI and relay commands back.
The goal: let the AI **drive** the terminal. Read output. Execute commands. Take screenshots. React to errors. All through a standardized protocol.
---
## 2. Solution: Wraith Terminal MCP Server
Implement an MCP (Model Context Protocol) server inside Wraith's Rust backend that exposes active sessions as tools and resources. The AI CLI running in the copilot panel connects to this MCP server and gains programmatic access to every open session.
### Architecture
```
AI CLI (claude/gemini)
|
+-- MCP Client (built into the CLI)
|
+-- connects to localhost:PORT or Unix socket
|
v
Wraith MCP Server (Rust, runs inside Tauri)
|
+-- Tool: terminal_execute(session_id, command)
+-- Tool: terminal_read(session_id, lines?)
+-- Tool: terminal_screenshot(session_id) [RDP only]
+-- Tool: sftp_list(session_id, path)
+-- Tool: sftp_read(session_id, path)
+-- Tool: sftp_write(session_id, path, content)
+-- Resource: sessions://list
+-- Resource: sessions://{id}/info
+-- Resource: sessions://{id}/scrollback
```
---
## 3. MCP Server Implementation
### 3.1 Transport
Two options for how the AI CLI connects to the MCP server:
**Option A: stdio (Recommended for v1)**
- The copilot panel spawns the AI CLI with `--mcp-server` flag pointing to a Wraith helper binary
- The helper binary communicates with Wraith's Tauri backend via Tauri commands
- Simple, no port management, no firewall issues
- Pattern: AI CLI → stdio → wraith-mcp-bridge → Tauri invoke → session data
**Option B: HTTP/SSE (Future)**
- Wraith runs an HTTP server on localhost:random-port
- AI CLI connects via `--mcp-server http://localhost:PORT`
- More flexible (multiple AI CLIs can connect), but requires port management
- Pattern: AI CLI → HTTP → Wraith MCP HTTP handler → session data
### 3.2 Rust Implementation
```
src-tauri/src/mcp/
mod.rs — MCP server lifecycle, transport handling
tools.rs — Tool definitions (terminal_execute, screenshot, etc.)
resources.rs — Resource definitions (session list, scrollback)
bridge.rs — Bridge between MCP protocol and existing services
```
The MCP server reuses existing services:
- `SshService` — for terminal_execute, terminal_read on SSH sessions
- `RdpService` — for terminal_screenshot on RDP sessions
- `SftpService` — for sftp_list, sftp_read, sftp_write
- `PtyService` — for local shell access
- `SessionStore` (DashMap) — for session enumeration
---
## 4. Tools
### 4.1 terminal_execute
Execute a command in an active SSH or local PTY session and return the output.
```json
{
"name": "terminal_execute",
"description": "Execute a command in a terminal session and return output",
"parameters": {
"session_id": "string — the active session ID",
"command": "string — the command to run (newline appended automatically)",
"timeout_ms": "number — max wait for output (default: 5000)"
},
"returns": "string — captured terminal output after command execution"
}
```
**Implementation:** Write command + `\n` to the session's writer. Start capturing output from the session's reader. Wait for a shell prompt pattern or timeout. Return captured bytes as UTF-8 string.
**Challenge:** Detecting when command output is "done" — shell prompt detection is fragile. Options:
- **Marker approach:** Send `echo __WRAITH_DONE__` after the command, capture until marker appears
- **Timeout approach:** Wait N ms after last output byte, assume done
- **Prompt regex:** Configurable prompt pattern (default: `$ `, `# `, `> `, `PS>`)
Recommend: marker approach for SSH, timeout approach for PTY (since local shells have predictable prompt timing).
### 4.2 terminal_read
Read the current scrollback or recent output from a session without executing anything.
```json
{
"name": "terminal_read",
"description": "Read recent terminal output from a session",
"parameters": {
"session_id": "string",
"lines": "number — last N lines (default: 50)"
},
"returns": "string — terminal scrollback content (ANSI stripped)"
}
```
**Implementation:** Maintain a circular buffer of recent output per session (last 10KB). On read, return the last N lines with ANSI escape codes stripped.
**Note:** The buffer exists in the Rust backend, not xterm.js. The AI doesn't need to scrape the DOM — it reads from the same data stream that feeds the terminal.
### 4.3 terminal_screenshot
Capture the current frame of an RDP session as a base64-encoded PNG.
```json
{
"name": "terminal_screenshot",
"description": "Capture a screenshot of an RDP session",
"parameters": {
"session_id": "string — must be an RDP session"
},
"returns": "string — base64-encoded PNG image"
}
```
**Implementation:** The RDP frame buffer is already maintained by `RdpService`. Encode the current frame as PNG (using the `image` crate), base64 encode, return. The AI CLI passes this to the multimodal AI provider for visual analysis.
**Use case:** "Screenshot the error on screen. What can you tell me about it?"
### 4.4 sftp_list
List files in a directory on the remote host via the session's SFTP channel.
```json
{
"name": "sftp_list",
"description": "List files in a remote directory",
"parameters": {
"session_id": "string",
"path": "string — remote directory path"
},
"returns": "array of { name, size, modified, is_dir }"
}
```
### 4.5 sftp_read
Read a file from the remote host.
```json
{
"name": "sftp_read",
"description": "Read a file from the remote host",
"parameters": {
"session_id": "string",
"path": "string — remote file path",
"max_bytes": "number — limit (default: 1MB)"
},
"returns": "string — file content (UTF-8) or base64 for binary"
}
```
### 4.6 sftp_write
Write a file to the remote host.
```json
{
"name": "sftp_write",
"description": "Write content to a file on the remote host",
"parameters": {
"session_id": "string",
"path": "string — remote file path",
"content": "string — file content"
}
}
```
---
## 5. Resources
### 5.1 sessions://list
Returns all active sessions with their type, connection info, and status.
```json
[
{
"id": "ssh-abc123",
"type": "ssh",
"name": "prod-web-01",
"host": "10.0.1.50",
"username": "admin",
"status": "connected"
},
{
"id": "rdp-def456",
"type": "rdp",
"name": "dc-01",
"host": "10.0.1.10",
"status": "connected"
}
]
```
### 5.2 sessions://{id}/info
Detailed info about a specific session — connection parameters, uptime, bytes transferred.
### 5.3 sessions://{id}/scrollback
Full scrollback buffer for a terminal session (last 10KB, ANSI stripped).
---
## 6. Security
- **MCP server only binds to localhost** — no remote access, no network exposure
- **Session access inherits Wraith's auth** — if the user is logged into Wraith, the MCP server trusts the connection
- **No credential exposure** — the MCP tools execute commands through existing authenticated sessions. The AI never sees passwords or SSH keys.
- **Audit trail** — every MCP tool invocation logged with timestamp, session ID, command, and result size
- **Read-only option** — sessions can be marked read-only in connection settings, preventing terminal_execute and sftp_write
---
## 7. AI CLI Integration
### 7.1 Claude Code
Claude Code already supports MCP servers via `--mcp-server` flag or `.claude/settings.json`. Configuration:
```json
{
"mcpServers": {
"wraith": {
"command": "wraith-mcp-bridge",
"args": []
}
}
}
```
The `wraith-mcp-bridge` is a small binary that Wraith ships alongside the main app. It communicates with the running Wraith instance via Tauri's IPC.
### 7.2 Gemini CLI
Gemini CLI supports MCP servers similarly. Same bridge binary, same configuration pattern.
### 7.3 Auto-Configuration
When the copilot panel launches an AI CLI, Wraith can auto-inject the MCP server configuration via environment variables or command-line flags, so the user doesn't have to manually configure anything.
```rust
// When spawning the AI CLI in the PTY:
let mut cmd = CommandBuilder::new(shell_path);
cmd.env("CLAUDE_MCP_SERVERS", r#"{"wraith":{"command":"wraith-mcp-bridge"}}"#);
```
---
## 8. Data Flow Example
**User says to Claude in copilot panel:** "Check disk space on the server I'm connected to"
1. Claude's MCP client calls `sessions://list` → gets `[{id: "ssh-abc", name: "prod-web-01", ...}]`
2. Claude calls `terminal_execute(session_id: "ssh-abc", command: "df -h")`
3. Wraith MCP bridge → Tauri invoke → SshService.write("ssh-abc", "df -h\n")
4. Wraith captures output until prompt marker
5. Returns: `/dev/sda1 50G 45G 5G 90% /`
6. Claude analyzes: "Your root partition is at 90%. You should clean up /var/log or expand the disk."
**User says:** "Screenshot the RDP session, what's that error?"
1. Claude calls `terminal_screenshot(session_id: "rdp-def")`
2. Wraith MCP bridge → RdpService.get_frame("rdp-def") → PNG encode → base64
3. Returns 200KB base64 PNG
4. Claude (multimodal) analyzes the image: "That's a Windows Event Viewer showing Event ID 1001 — application crash in outlook.exe. The faulting module is mso.dll. This is a known Office corruption issue. Run `sfc /scannow` or repair Office from Control Panel."
---
## 9. Implementation Phases
### Phase 1: Bridge + Basic Tools (MVP)
- `wraith-mcp-bridge` binary (stdio transport)
- `terminal_execute` tool (marker-based output capture)
- `terminal_read` tool (scrollback buffer)
- `sessions://list` resource
- Auto-configuration when spawning AI CLI
### Phase 2: SFTP + Screenshot
- `sftp_list`, `sftp_read`, `sftp_write` tools
- `terminal_screenshot` tool (RDP frame capture)
- Session info resource
### Phase 3: Advanced
- HTTP/SSE transport for multi-client access
- Read-only session enforcement
- Audit trail logging
- AI-initiated session creation ("Connect me to prod-web-01")
---
## 10. Dependencies
| Component | Crate/Tool | License |
|---|---|---|
| MCP protocol | Custom implementation (JSON-RPC over stdio) | Proprietary |
| PNG encoding | `image` crate | MIT/Apache-2.0 |
| Base64 | `base64` crate (already in deps) | MIT/Apache-2.0 |
| ANSI stripping | `strip-ansi-escapes` crate | MIT/Apache-2.0 |
| Bridge binary | Rust, ships alongside Wraith | Proprietary |
---
## 11. Black Binder Note
An MCP server embedded in a remote access client that gives AI tools programmatic access to live SSH, RDP, and SFTP sessions is, to the company's knowledge, a novel integration. No competing SSH/RDP client ships with an MCP server that allows AI assistants to interact with active remote sessions.
The combination of terminal command execution, RDP screenshot analysis, and SFTP file operations through a standardized AI tool protocol represents a new category of AI-augmented remote access.

View File

@ -1,129 +0,0 @@
# 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

@ -1,171 +0,0 @@
# 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

File diff suppressed because it is too large Load Diff

View File

@ -1,487 +0,0 @@
# 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,780 @@
# Local PTY Copilot Panel — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the Gemini API stub with a local PTY terminal in the sidebar where users run CLI tools (claude, gemini, codex) directly.
**Architecture:** New `PtyService` module mirrors `SshService` patterns — DashMap session registry, `portable-pty` for cross-platform PTY spawn, `spawn_blocking` output loop emitting Tauri events. Frontend reuses existing `useTerminal` composable with a new `backend` parameter. Gemini stub deleted entirely.
**Tech Stack:** portable-pty (Rust PTY), xterm.js (existing), Tauri v2 events (existing)
**Spec:** `docs/superpowers/specs/2026-03-24-local-pty-copilot-design.md`
---
### Task 1: Add portable-pty dependency
**Files:**
- Modify: `src-tauri/Cargo.toml`
- [ ] **Step 1: Add portable-pty to Cargo.toml**
Add under the existing dependencies:
```toml
portable-pty = "0.8"
```
- [ ] **Step 2: Verify it resolves**
Run: `cd src-tauri && cargo check`
Expected: compiles with no errors
- [ ] **Step 3: Commit**
```bash
git add src-tauri/Cargo.toml src-tauri/Cargo.lock
git commit -m "deps: add portable-pty for local PTY support"
```
---
### Task 2: Create PtyService backend module
**Files:**
- Create: `src-tauri/src/pty/mod.rs`
- [ ] **Step 1: Create the pty module with PtyService, PtySession, ShellInfo, list_shells**
```rust
//! Local PTY service — spawns shells for the AI copilot panel.
use std::io::{Read, Write};
use std::sync::{Arc, Mutex};
use base64::Engine;
use dashmap::DashMap;
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
use serde::Serialize;
use tauri::{AppHandle, Emitter};
#[derive(Debug, Serialize, Clone)]
pub struct ShellInfo {
pub name: String,
pub path: String,
}
pub struct PtySession {
pub id: String,
pub shell_path: String,
writer: Mutex<Box<dyn Write + Send>>,
master: Mutex<Box<dyn MasterPty + Send>>,
child: Mutex<Box<dyn Child + Send + Sync>>,
}
pub struct PtyService {
sessions: DashMap<String, Arc<PtySession>>,
}
impl PtyService {
pub fn new() -> Self {
Self { sessions: DashMap::new() }
}
/// Detect available shells on the system.
pub fn list_shells(&self) -> Vec<ShellInfo> {
let mut shells = Vec::new();
#[cfg(unix)]
{
// Check $SHELL first (user's default)
if let Ok(user_shell) = std::env::var("SHELL") {
if std::path::Path::new(&user_shell).exists() {
let name = std::path::Path::new(&user_shell)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("shell")
.to_string();
shells.push(ShellInfo { name, path: user_shell });
}
}
for (name, path) in [("bash", "/bin/bash"), ("zsh", "/bin/zsh"), ("sh", "/bin/sh")] {
if std::path::Path::new(path).exists() && !shells.iter().any(|s| s.path == path) {
shells.push(ShellInfo { name: name.to_string(), path: path.to_string() });
}
}
}
#[cfg(windows)]
{
shells.push(ShellInfo { name: "PowerShell".to_string(), path: "powershell.exe".to_string() });
shells.push(ShellInfo { name: "CMD".to_string(), path: "cmd.exe".to_string() });
for git_bash in [
r"C:\Program Files\Git\bin\bash.exe",
r"C:\Program Files (x86)\Git\bin\bash.exe",
] {
if std::path::Path::new(git_bash).exists() {
shells.push(ShellInfo { name: "Git Bash".to_string(), path: git_bash.to_string() });
break;
}
}
}
shells
}
/// Spawn a local shell and start reading its output.
pub fn spawn(
&self,
shell_path: &str,
cols: u16,
rows: u16,
app_handle: AppHandle,
) -> Result<String, String> {
let session_id = uuid::Uuid::new_v4().to_string();
let pty_system = native_pty_system();
let pair = pty_system
.openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
.map_err(|e| format!("Failed to open PTY: {}", e))?;
let mut cmd = CommandBuilder::new(shell_path);
// Inherit parent environment so PATH includes CLI tools
// CommandBuilder inherits env by default — no action needed
let child = pair.slave
.spawn_command(cmd)
.map_err(|e| format!("Failed to spawn shell '{}': {}", shell_path, e))?;
let reader = pair.master
.try_clone_reader()
.map_err(|e| format!("Failed to clone PTY reader: {}", e))?;
let writer = pair.master
.take_writer()
.map_err(|e| format!("Failed to take PTY writer: {}", e))?;
let session = Arc::new(PtySession {
id: session_id.clone(),
shell_path: shell_path.to_string(),
writer: Mutex::new(writer),
master: Mutex::new(pair.master),
child: Mutex::new(child),
});
self.sessions.insert(session_id.clone(), session);
// Output reader loop — runs in a blocking thread because
// portable-pty's reader is synchronous (std::io::Read).
let sid = session_id.clone();
let app = app_handle;
tokio::task::spawn_blocking(move || {
let mut reader = std::io::BufReader::new(reader);
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf) {
Ok(0) => {
let _ = app.emit(&format!("pty:close:{}", sid), ());
break;
}
Ok(n) => {
let encoded = base64::engine::general_purpose::STANDARD.encode(&buf[..n]);
let _ = app.emit(&format!("pty:data:{}", sid), encoded);
}
Err(_) => {
let _ = app.emit(&format!("pty:close:{}", sid), ());
break;
}
}
}
});
Ok(session_id)
}
/// Write data to a PTY session's stdin.
pub fn write(&self, session_id: &str, data: &[u8]) -> Result<(), String> {
let session = self.sessions.get(session_id)
.ok_or_else(|| format!("PTY session {} not found", session_id))?;
let mut writer = session.writer.lock()
.map_err(|e| format!("Failed to lock PTY writer: {}", e))?;
writer.write_all(data)
.map_err(|e| format!("Failed to write to PTY {}: {}", session_id, e))
}
/// Resize a PTY session.
pub fn resize(&self, session_id: &str, cols: u16, rows: u16) -> Result<(), String> {
let session = self.sessions.get(session_id)
.ok_or_else(|| format!("PTY session {} not found", session_id))?;
let master = session.master.lock()
.map_err(|e| format!("Failed to lock PTY master: {}", e))?;
master.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
.map_err(|e| format!("Failed to resize PTY {}: {}", session_id, e))
}
/// Kill and remove a PTY session.
pub fn disconnect(&self, session_id: &str) -> Result<(), String> {
let (_, session) = self.sessions.remove(session_id)
.ok_or_else(|| format!("PTY session {} not found", session_id))?;
if let Ok(mut child) = session.child.lock() {
let _ = child.kill();
}
Ok(())
}
}
```
- [ ] **Step 2: Verify it compiles**
Add `pub mod pty;` to `src-tauri/src/lib.rs` temporarily (just the module declaration, full AppState wiring comes in Task 4).
Run: `cd src-tauri && cargo check`
Expected: compiles (warnings about unused code are fine here)
- [ ] **Step 3: Commit**
```bash
git add src-tauri/src/pty/mod.rs src-tauri/src/lib.rs
git commit -m "feat: PtyService — local PTY spawn, write, resize, disconnect"
```
---
### Task 3: Create PTY Tauri commands
**Files:**
- Create: `src-tauri/src/commands/pty_commands.rs`
- Modify: `src-tauri/src/commands/mod.rs`
- [ ] **Step 1: Create pty_commands.rs**
```rust
//! Tauri commands for local PTY session management.
use tauri::{AppHandle, State};
use crate::pty::ShellInfo;
use crate::AppState;
#[tauri::command]
pub fn list_available_shells(state: State<'_, AppState>) -> Vec<ShellInfo> {
state.pty.list_shells()
}
#[tauri::command]
pub fn spawn_local_shell(
shell_path: String,
cols: u32,
rows: u32,
app_handle: AppHandle,
state: State<'_, AppState>,
) -> Result<String, String> {
state.pty.spawn(&shell_path, cols as u16, rows as u16, app_handle)
}
#[tauri::command]
pub fn pty_write(
session_id: String,
data: String,
state: State<'_, AppState>,
) -> Result<(), String> {
state.pty.write(&session_id, data.as_bytes())
}
#[tauri::command]
pub fn pty_resize(
session_id: String,
cols: u32,
rows: u32,
state: State<'_, AppState>,
) -> Result<(), String> {
state.pty.resize(&session_id, cols as u16, rows as u16)
}
#[tauri::command]
pub fn disconnect_pty(
session_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
state.pty.disconnect(&session_id)
}
```
- [ ] **Step 2: Add `pub mod pty_commands;` to `src-tauri/src/commands/mod.rs`**
Replace the `ai_commands` line:
```rust
pub mod vault;
pub mod settings;
pub mod connections;
pub mod credentials;
pub mod ssh_commands;
pub mod sftp_commands;
pub mod rdp_commands;
pub mod theme_commands;
pub mod pty_commands;
```
- [ ] **Step 3: Commit**
```bash
git add src-tauri/src/commands/pty_commands.rs src-tauri/src/commands/mod.rs
git commit -m "feat: PTY Tauri commands — spawn, write, resize, disconnect, list shells"
```
---
### Task 4: Wire PtyService into AppState and delete Gemini stub
**Files:**
- Modify: `src-tauri/src/lib.rs`
- Delete: `src-tauri/src/ai/mod.rs`
- Delete: `src-tauri/src/commands/ai_commands.rs`
- [ ] **Step 1: Update lib.rs**
Full replacement of `lib.rs`:
Changes:
1. Replace `pub mod ai;` with `pub mod pty;`
2. Replace `use` for ai with `use pty::PtyService;`
3. Replace `gemini: Mutex<Option<ai::GeminiClient>>` with `pub pty: PtyService`
4. Replace `gemini: Mutex::new(None)` with `pty: PtyService::new()`
5. Replace AI command registrations with PTY command registrations in `generate_handler!`
The `generate_handler!` line 110 should change from:
```
commands::ai_commands::set_gemini_auth, commands::ai_commands::gemini_chat, commands::ai_commands::is_gemini_authenticated,
```
to:
```
commands::pty_commands::list_available_shells, commands::pty_commands::spawn_local_shell, commands::pty_commands::pty_write, commands::pty_commands::pty_resize, commands::pty_commands::disconnect_pty,
```
- [ ] **Step 2: Delete Gemini files**
```bash
rm src-tauri/src/ai/mod.rs
rmdir src-tauri/src/ai
rm src-tauri/src/commands/ai_commands.rs
```
- [ ] **Step 3: Verify build**
Run: `cd src-tauri && cargo build`
Expected: compiles with zero warnings
- [ ] **Step 4: Run tests**
Run: `cd src-tauri && cargo test`
Expected: 82 tests pass (existing tests unaffected)
- [ ] **Step 5: Commit**
```bash
git add -A
git commit -m "refactor: replace Gemini stub with PtyService in AppState"
```
---
### Task 5: Parameterize useTerminal composable
**Files:**
- Modify: `src/composables/useTerminal.ts`
- [ ] **Step 1: Add backend parameter**
Change the function signature from:
```typescript
export function useTerminal(sessionId: string): UseTerminalReturn {
```
to:
```typescript
export function useTerminal(sessionId: string, backend: 'ssh' | 'pty' = 'ssh'): UseTerminalReturn {
```
- [ ] **Step 2: Derive command/event names from backend**
Add at the top of the function body (after the addons, before the Terminal constructor):
```typescript
const writeCmd = backend === 'ssh' ? 'ssh_write' : 'pty_write';
const resizeCmd = backend === 'ssh' ? 'ssh_resize' : 'pty_resize';
const dataEvent = backend === 'ssh' ? `ssh:data:${sessionId}` : `pty:data:${sessionId}`;
```
- [ ] **Step 3: Set convertEol based on backend**
In the Terminal constructor options, change:
```typescript
convertEol: true,
```
to:
```typescript
convertEol: backend === 'ssh',
```
- [ ] **Step 4: Replace hardcoded command names**
Replace all `invoke("ssh_write"` with `invoke(writeCmd` (3 occurrences: onData handler, right-click paste handler).
Replace `invoke("ssh_resize"` with `invoke(resizeCmd` (1 occurrence: onResize handler).
Replace `` `ssh:data:${sessionId}` `` with `dataEvent` (1 occurrence: listen call in mount).
Replace error log strings: `"SSH write error:"``"Write error:"`, `"SSH resize error:"``"Resize error:"`.
- [ ] **Step 5: Verify existing SSH path still works**
Run: `npx vue-tsc --noEmit` — should compile clean. Existing callers pass no second argument, so they default to `'ssh'`.
- [ ] **Step 6: Commit**
```bash
git add src/composables/useTerminal.ts
git commit -m "refactor: parameterize useTerminal for ssh/pty backends"
```
---
### Task 6: Create CopilotPanel.vue
**Files:**
- Create: `src/components/ai/CopilotPanel.vue`
- [ ] **Step 1: Create the component**
```vue
<template>
<div class="flex flex-col h-full bg-[var(--wraith-bg-secondary)] border-l border-[var(--wraith-border)] w-80">
<!-- Header -->
<div class="p-3 border-b border-[var(--wraith-border)] flex items-center justify-between gap-2">
<span class="text-xs font-bold tracking-widest text-[var(--wraith-accent-blue)]">AI COPILOT</span>
<div class="flex items-center gap-1.5">
<select
v-model="selectedShell"
class="bg-[var(--wraith-bg-tertiary)] border border-[var(--wraith-border)] rounded px-1.5 py-0.5 text-[10px] text-[var(--wraith-text-secondary)] outline-none"
:disabled="connected"
>
<option v-for="shell in shells" :key="shell.path" :value="shell.path">
{{ shell.name }}
</option>
</select>
<button
v-if="!connected"
class="px-2 py-0.5 text-[10px] font-bold rounded bg-[var(--wraith-accent-blue)] text-black cursor-pointer"
:disabled="!selectedShell"
@click="launch"
>
Launch
</button>
<button
v-else
class="px-2 py-0.5 text-[10px] font-bold rounded bg-[var(--wraith-accent-red,#f85149)] text-white cursor-pointer"
@click="kill"
>
Kill
</button>
</div>
</div>
<!-- Terminal area -->
<div v-if="connected" ref="containerRef" class="flex-1 min-h-0" />
<!-- Session ended prompt -->
<div v-else-if="sessionEnded" class="flex-1 flex flex-col items-center justify-center gap-3 p-4">
<p class="text-xs text-[var(--wraith-text-muted)]">Session ended</p>
<button
class="px-3 py-1.5 text-xs rounded bg-[var(--wraith-accent-blue)] text-black font-bold cursor-pointer"
@click="launch"
>
Relaunch
</button>
</div>
<!-- Empty state -->
<div v-else class="flex-1 flex flex-col items-center justify-center gap-2 p-4">
<p class="text-xs text-[var(--wraith-text-muted)] text-center">
Select a shell and click Launch to start a local terminal.
</p>
<p class="text-[10px] text-[var(--wraith-text-muted)] text-center">
Run <code class="text-[var(--wraith-accent-blue)]">claude</code>,
<code class="text-[var(--wraith-accent-blue)]">gemini</code>, or
<code class="text-[var(--wraith-accent-blue)]">codex</code> here.
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick, onMounted, onBeforeUnmount } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { useTerminal } from "@/composables/useTerminal";
interface ShellInfo { name: string; path: string; }
const shells = ref<ShellInfo[]>([]);
const selectedShell = ref("");
const connected = ref(false);
const sessionEnded = ref(false);
const containerRef = ref<HTMLElement | null>(null);
let sessionId = "";
let terminalInstance: ReturnType<typeof useTerminal> | null = null;
let closeUnlisten: UnlistenFn | null = null;
async function loadShells(): Promise<void> {
try {
shells.value = await invoke<ShellInfo[]>("list_available_shells");
if (shells.value.length > 0 && !selectedShell.value) {
selectedShell.value = shells.value[0].path;
}
} catch (err) {
console.error("Failed to list shells:", err);
}
}
async function launch(): Promise<void> {
if (!selectedShell.value) return;
sessionEnded.value = false;
// Use defaults until terminal is mounted and measured
const cols = 80;
const rows = 24;
try {
sessionId = await invoke<string>("spawn_local_shell", {
shellPath: selectedShell.value,
cols,
rows,
});
connected.value = true;
// Wait for DOM update so containerRef is available
await nextTick();
if (containerRef.value) {
terminalInstance = useTerminal(sessionId, "pty");
terminalInstance.mount(containerRef.value);
// Fit after mount to get real dimensions, then resize the PTY
setTimeout(() => {
if (terminalInstance) {
terminalInstance.fit();
const term = terminalInstance.terminal;
invoke("pty_resize", {
sessionId,
cols: term.cols,
rows: term.rows,
}).catch(() => {});
}
}, 50);
}
// Listen for shell exit
closeUnlisten = await listen(`pty:close:${sessionId}`, () => {
cleanup();
sessionEnded.value = true;
});
} catch (err) {
console.error("Failed to spawn shell:", err);
connected.value = false;
}
}
function kill(): void {
if (sessionId) {
invoke("disconnect_pty", { sessionId }).catch(() => {});
}
cleanup();
}
function cleanup(): void {
if (terminalInstance) {
terminalInstance.destroy();
terminalInstance = null;
}
if (closeUnlisten) {
closeUnlisten();
closeUnlisten = null;
}
connected.value = false;
sessionId = "";
}
onMounted(loadShells);
onBeforeUnmount(() => {
if (connected.value) kill();
});
</script>
```
- [ ] **Step 2: Verify TypeScript compiles**
Run: `npx vue-tsc --noEmit`
Expected: no errors
- [ ] **Step 3: Commit**
```bash
git add src/components/ai/CopilotPanel.vue
git commit -m "feat: CopilotPanel — local PTY terminal in AI sidebar"
```
---
### Task 7: Update MainLayout and delete GeminiPanel
**Files:**
- Modify: `src/layouts/MainLayout.vue`
- Delete: `src/components/ai/GeminiPanel.vue`
- [ ] **Step 1: Update MainLayout imports and template**
In `MainLayout.vue`:
Replace the import (line 205):
```typescript
import GeminiPanel from "@/components/ai/GeminiPanel.vue";
```
with:
```typescript
import CopilotPanel from "@/components/ai/CopilotPanel.vue";
```
Replace the template usage (line 168):
```html
<GeminiPanel v-if="geminiVisible" />
```
with:
```html
<CopilotPanel v-if="copilotVisible" />
```
Rename the ref and all references (line 219, 71, 73, 293):
- `geminiVisible``copilotVisible`
- Update the toolbar button title: `"Gemini XO (Ctrl+Shift+G)"``"AI Copilot (Ctrl+Shift+G)"`
- [ ] **Step 2: Delete GeminiPanel.vue**
```bash
rm src/components/ai/GeminiPanel.vue
```
- [ ] **Step 3: Verify frontend compiles**
Run: `npx vue-tsc --noEmit`
Expected: no errors
- [ ] **Step 4: Commit**
```bash
git add -A
git commit -m "feat: swap GeminiPanel for CopilotPanel in MainLayout"
```
---
### Task 8: Add PTY tests
**Files:**
- Modify: `src-tauri/src/pty/mod.rs`
- [ ] **Step 1: Add test module to pty/mod.rs**
Append to the bottom of `src-tauri/src/pty/mod.rs`:
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn list_shells_returns_at_least_one() {
let svc = PtyService::new();
let shells = svc.list_shells();
assert!(!shells.is_empty(), "should find at least one shell");
for shell in &shells {
assert!(!shell.name.is_empty());
assert!(!shell.path.is_empty());
}
}
#[test]
fn list_shells_no_duplicates() {
let svc = PtyService::new();
let shells = svc.list_shells();
let paths: Vec<&str> = shells.iter().map(|s| s.path.as_str()).collect();
let mut unique = paths.clone();
unique.sort();
unique.dedup();
assert_eq!(paths.len(), unique.len(), "shell list should not contain duplicates");
}
#[test]
fn disconnect_nonexistent_session_errors() {
let svc = PtyService::new();
assert!(svc.disconnect("nonexistent").is_err());
}
#[test]
fn write_nonexistent_session_errors() {
let svc = PtyService::new();
assert!(svc.write("nonexistent", b"hello").is_err());
}
#[test]
fn resize_nonexistent_session_errors() {
let svc = PtyService::new();
assert!(svc.resize("nonexistent", 80, 24).is_err());
}
}
```
- [ ] **Step 2: Run tests**
Run: `cd src-tauri && cargo test`
Expected: 87+ tests pass (82 existing + 5 new), zero warnings
- [ ] **Step 3: Commit**
```bash
git add src-tauri/src/pty/mod.rs
git commit -m "test: PtyService unit tests — shell detection, error paths"
```
---
### Task 9: Final build, verify, tag
**Files:** None (verification only)
- [ ] **Step 1: Full Rust build with zero warnings**
Run: `cd src-tauri && cargo build`
Expected: zero warnings, zero errors
- [ ] **Step 2: Full test suite**
Run: `cd src-tauri && cargo test`
Expected: 87+ tests, all passing
- [ ] **Step 3: Frontend type check**
Run: `npx vue-tsc --noEmit`
Expected: no errors
- [ ] **Step 4: Push and tag**
```bash
git push
git tag v1.2.5
git push origin v1.2.5
```
- [ ] **Step 5: Update CLAUDE.md test count**
Update the test count in CLAUDE.md to reflect the new total. Commit and push (do NOT re-tag).

View File

@ -1,614 +0,0 @@
# 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

@ -1,519 +0,0 @@
# 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

@ -1,982 +0,0 @@
# 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 |

View File

@ -0,0 +1,186 @@
# Local PTY Copilot Panel — Design Spec
**Date:** 2026-03-24
**Status:** Approved
**Author:** Claude Opus 4.6 (XO)
## Problem
The AI panel is a Gemini API stub (~130 lines backend, ~124 lines frontend) with no OAuth, no conversation history, no tool use. The Commander pays $200/mo Claude Max, $20/mo Gemini, $20/mo ChatGPT — all of which include CLI tool access (Claude Code, Gemini CLI, Codex CLI). These CLIs are designed for terminals. Wraith has a terminal. Ship a local PTY in the sidebar and let the user run whichever CLI they want.
## Solution
Replace the Gemini stub with a local PTY terminal in the sidebar panel. Reuse the existing xterm.js infrastructure. User picks a shell (bash, sh, zsh, PowerShell, Git Bash), the panel spawns it locally, and they run `claude`, `gemini`, `codex`, or anything else.
## Architecture
### Backend — `src-tauri/src/pty/mod.rs`
New module following the same patterns as `SshService`:
```
PtyService
sessions: DashMap<String, Arc<PtySession>>
PtySession
id: String
writer: Mutex<Box<dyn Write + Send>> // from master.take_writer()
master: Mutex<Box<dyn MasterPty + Send>> // kept for resize()
child: Mutex<Box<dyn Child + Send + Sync>> // for kill/wait
shell_path: String
PtyService methods:
spawn(shell_path, cols, rows, app_handle) -> Result<String, String>
write(session_id, data) -> Result<(), String>
resize(session_id, cols, rows) -> Result<(), String>
disconnect(session_id) -> Result<(), String>
list_shells() -> Vec<ShellInfo>
```
Note: `writer` and `master` require `Mutex` wrappers because `portable-pty` trait objects are `Send` but not `Sync`, and the `DashMap` requires `Sync` on stored values.
**PTY crate:** `portable-pty` — cross-platform (Unix PTY, Windows ConPTY). MIT licensed. Part of the wezterm project.
**Shell detection** (`list_shells`):
- Unix: check existence of `/bin/bash`, `/bin/sh`, `/bin/zsh`, `$SHELL`
- Windows: `powershell.exe`, `cmd.exe`, plus scan for Git Bash at common paths (`C:\Program Files\Git\bin\bash.exe`, `C:\Program Files (x86)\Git\bin\bash.exe`)
- Return `Vec<ShellInfo>` with `{ name, path }` pairs
**Output loop** — spawned per session via `spawn_blocking` (not async — `portable-pty` reader is synchronous `std::io::Read`):
```rust
tokio::task::spawn_blocking(move || {
let mut reader = BufReader::new(pty_reader);
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf) {
Ok(0) => { app.emit("pty:close:{id}", ()); break; }
Ok(n) => { app.emit("pty:data:{id}", base64(&buf[..n])); }
Err(_) => break;
}
}
});
```
`AppHandle::emit()` is synchronous in Tauri v2, so it works from a blocking thread context without issues.
**Environment:** `CommandBuilder` inherits the parent process environment by default. This is required so that `PATH` includes the user's CLI tools (`claude`, `gemini`, `codex`). No env filtering should be applied.
### Backend — Tauri Commands (`src-tauri/src/commands/pty_commands.rs`)
```rust
spawn_local_shell(shell_path: String, cols: u32, rows: u32) -> Result<String, String>
pty_write(session_id: String, data: String) -> Result<(), String>
pty_resize(session_id: String, cols: u32, rows: u32) -> Result<(), String>
disconnect_pty(session_id: String) -> Result<(), String>
list_available_shells() -> Result<Vec<ShellInfo>, String>
```
All registered in `lib.rs` invoke handler. All added to `capabilities/default.json`.
### Backend — AppState Changes
```rust
pub struct AppState {
// ... existing fields ...
pub pty: PtyService, // ADD
// pub gemini: Mutex<...>, // DELETE
}
```
### Frontend — `src/components/ai/CopilotPanel.vue`
Replaces `GeminiPanel.vue`. Structure:
1. **Header bar:** "AI Copilot" title + shell selector dropdown + spawn/kill buttons
2. **Terminal area:** xterm.js instance via `useTerminal` composable (adapted for PTY events)
3. **State:** shell list (from `list_available_shells`), active session ID, connected flag
4. **Close handling:** Listen for `pty:close:{session_id}` events to update `connected` state and show "Session ended — Relaunch?" UI. This differs from the SSH path where tab closure handles cleanup.
Shell selector is a `<select>` dropdown populated on mount. "Launch" button calls `spawn_local_shell`. Terminal mounts when session starts.
**Initial terminal size:** On spawn, measure terminal dimensions via `fitAddon.fit()` before invoking `spawn_local_shell`. Pass the measured cols/rows. If the terminal is not yet mounted, use defaults (80x24) and immediately resize after mount.
### Frontend — `useTerminal` Adaptation
Current `useTerminal.ts` hardcodes `ssh_write`, `ssh_resize`, and `ssh:data:` events.
**Chosen approach:** Parameterize the composable to accept a "backend type":
```typescript
export function useTerminal(sessionId: string, backend: 'ssh' | 'pty' = 'ssh')
```
- `backend === 'ssh'``invoke("ssh_write")`, `listen("ssh:data:{id}")`, `convertEol: true`
- `backend === 'pty'``invoke("pty_write")`, `listen("pty:data:{id}")`, `convertEol: false`
Same xterm.js instance, same resize observer, same clipboard, same base64 decode. Only the invoke target, event prefix, and EOL conversion change.
**Important:** The local PTY driver already translates LF to CRLF. The SSH path needs `convertEol: true` because raw SSH streams may send bare LF. Setting `convertEol: true` on the PTY path would produce double newlines.
### Cleanup — Delete Gemini Stub
Remove entirely:
- `src-tauri/src/ai/mod.rs`
- `src-tauri/src/commands/ai_commands.rs`
- `src/components/ai/GeminiPanel.vue`
- `AppState.gemini` field and `Mutex<Option<ai::GeminiClient>>` in `lib.rs`
- AI command registrations from invoke handler
- `pub mod ai;` from `lib.rs`
Keep `reqwest` in `Cargo.toml` — the RDP stack (`ironrdp-tokio`, `sspi`) depends on it transitively and may require the `json` feature flag our direct dependency enables.
### Data Flow
```
User types in copilot panel
→ useTerminal.onData(data)
→ invoke("pty_write", { sessionId, data })
→ PtyService.write() → writer.write_all(data)
→ PTY stdin → shell process
Shell output → PTY stdout
→ output reader loop (spawn_blocking)
→ app.emit("pty:data:{id}", base64(bytes))
→ useTerminal listener → base64 decode → xterm.js.write()
Shell exits (user types "exit" or CLI tool quits)
→ reader returns Ok(0)
→ app.emit("pty:close:{id}", ())
→ CopilotPanel listens → updates connected state → shows relaunch UI
```
### Tauri ACL
No changes needed to `capabilities/default.json``core:default` covers command invocation, `core:event:default` covers event listening. The PTY commands are registered via `generate_handler!`.
### Testing
**Rust tests:**
- `list_shells()` returns at least one shell on any platform
- `spawn()` + `write("echo hello\n")` + read output contains "hello"
- `resize()` doesn't error on active session
- `disconnect()` removes session from registry
- `disconnect()` on nonexistent session returns error
**Frontend:** `useTerminal` composable already tested via SSH path. The `backend` parameter is a simple branch — no separate test needed.
### Dependencies
**Add:**
- `portable-pty` — cross-platform PTY (MIT license, part of wezterm project)
**No removals** — `reqwest`, `md5`, `pem`, and other existing deps serve SSH and RDP functionality.
### Migration
No data migration needed. The Gemini stub stores nothing persistent — no DB tables, no settings, no vault entries. Clean delete.
## Success Criteria
1. Commander opens Wraith, presses Ctrl+Shift+G
2. Shell dropdown shows detected shells (bash on macOS, PowerShell + Git Bash on Windows)
3. Selects shell, clicks Launch
4. Full interactive terminal appears in sidebar
5. Types `claude` (or `gemini` or `codex`) — CLI launches, works normally
6. Resize sidebar → terminal reflows
7. Close panel or kill session → PTY process terminates cleanly
8. Shell exits → panel shows "Session ended — Relaunch?" prompt

View File

@ -1,108 +0,0 @@
# 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)

View File

@ -1,12 +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>
</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 +0,0 @@
{
"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"
}
}

View File

@ -1,30 +0,0 @@
<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

@ -1,29 +0,0 @@
@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

@ -1,97 +0,0 @@
<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

@ -1,277 +0,0 @@
<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

@ -1,140 +0,0 @@
<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

@ -1,117 +0,0 @@
<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

@ -1,249 +0,0 @@
<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

@ -1,170 +0,0 @@
<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

@ -1,130 +0,0 @@
<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,382 +0,0 @@
<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
ref="canvasRef"
class="rdp-canvas"
tabindex="0"
@mousedown="handleMouseDown"
@mouseup="handleMouseUp"
@mousemove="handleMouseMove"
@wheel.prevent="handleWheel"
@contextmenu.prevent
@keydown.prevent="handleKeyDown"
@keyup.prevent="handleKeyUp"
/>
</div>
<!-- Connection status overlay -->
<div v-if="!connected" class="rdp-overlay">
<div class="rdp-overlay-content">
<div class="rdp-spinner" />
<p>Connecting to RDP session...</p>
<p class="rdp-overlay-sub">Session: {{ sessionId }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from "vue";
import { useRdp, MouseFlag } from "@/composables/useRdp";
const props = defineProps<{
sessionId: string;
isActive: boolean;
width?: number;
height?: number;
}>();
const containerRef = ref<HTMLElement | null>(null);
const canvasWrapper = ref<HTMLElement | null>(null);
const canvasRef = ref<HTMLCanvasElement | null>(null);
const rdpWidth = props.width ?? 1920;
const rdpHeight = props.height ?? 1080;
const {
connected,
keyboardGrabbed,
clipboardSync,
sendMouse,
sendKey,
startFrameLoop,
stopFrameLoop,
toggleKeyboardGrab,
toggleClipboardSync,
} = useRdp();
/**
* Convert canvas-relative mouse coordinates to RDP coordinates,
* accounting for CSS scaling of the canvas.
*/
function toRdpCoords(
e: MouseEvent,
): { x: number; y: number } | null {
const canvas = canvasRef.value;
if (!canvas) return null;
const rect = canvas.getBoundingClientRect();
const scaleX = rdpWidth / rect.width;
const scaleY = rdpHeight / rect.height;
return {
x: Math.floor((e.clientX - rect.left) * scaleX),
y: Math.floor((e.clientY - rect.top) * scaleY),
};
}
function handleMouseDown(e: MouseEvent): void {
const coords = toRdpCoords(e);
if (!coords) return;
let buttonFlag = 0;
switch (e.button) {
case 0:
buttonFlag = MouseFlag.Button1;
break;
case 1:
buttonFlag = MouseFlag.Button3;
break; // middle
case 2:
buttonFlag = MouseFlag.Button2;
break;
}
sendMouse(
props.sessionId,
coords.x,
coords.y,
buttonFlag | MouseFlag.Down,
);
}
function handleMouseUp(e: MouseEvent): void {
const coords = toRdpCoords(e);
if (!coords) return;
let buttonFlag = 0;
switch (e.button) {
case 0:
buttonFlag = MouseFlag.Button1;
break;
case 1:
buttonFlag = MouseFlag.Button3;
break;
case 2:
buttonFlag = MouseFlag.Button2;
break;
}
sendMouse(props.sessionId, coords.x, coords.y, buttonFlag);
}
function handleMouseMove(e: MouseEvent): void {
const coords = toRdpCoords(e);
if (!coords) return;
sendMouse(props.sessionId, coords.x, coords.y, MouseFlag.Move);
}
function handleWheel(e: WheelEvent): void {
const coords = toRdpCoords(e);
if (!coords) return;
let flags = MouseFlag.Wheel;
if (e.deltaY > 0) {
flags |= MouseFlag.WheelNeg;
}
sendMouse(props.sessionId, coords.x, coords.y, flags);
}
function handleKeyDown(e: KeyboardEvent): void {
if (!keyboardGrabbed.value) return;
sendKey(props.sessionId, e.code, true);
}
function handleKeyUp(e: KeyboardEvent): void {
if (!keyboardGrabbed.value) return;
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,
);
}
});
onBeforeUnmount(() => {
stopFrameLoop();
});
// Focus canvas when this tab becomes active
watch(
() => props.isActive,
(active) => {
if (active && keyboardGrabbed.value && canvasRef.value) {
setTimeout(() => {
canvasRef.value?.focus();
}, 0);
}
},
);
</script>
<style scoped>
.rdp-container {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background: var(--wraith-bg-primary, #0d1117);
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;
align-items: center;
justify-content: center;
overflow: hidden;
min-height: 0;
}
.rdp-canvas {
max-width: 100%;
max-height: 100%;
object-fit: contain;
cursor: default;
outline: none;
image-rendering: auto;
}
.rdp-canvas:focus {
outline: 2px solid var(--wraith-accent, #58a6ff);
outline-offset: -2px;
}
.rdp-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(13, 17, 23, 0.85);
z-index: 10;
}
.rdp-overlay-content {
text-align: center;
color: var(--wraith-text-secondary, #8b949e);
}
.rdp-overlay-content p {
margin: 8px 0;
font-size: 14px;
}
.rdp-overlay-sub {
font-size: 12px !important;
color: var(--wraith-text-muted, #484f58);
}
.rdp-spinner {
width: 32px;
height: 32px;
margin: 0 auto 16px;
border: 3px solid var(--wraith-border, #30363d);
border-top-color: var(--wraith-accent, #58a6ff);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@ -1,61 +0,0 @@
<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

@ -1,126 +0,0 @@
<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

@ -1,89 +0,0 @@
<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

@ -1,150 +0,0 @@
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,103 +0,0 @@
import { ref, onBeforeUnmount, type Ref } from "vue";
import { Call, Events } from "@wailsio/runtime";
const SFTP = "github.com/vstockwell/wraith/internal/sftp.SFTPService";
export interface FileEntry {
name: string;
path: string;
size: number;
isDir: boolean;
permissions: string;
modTime: string;
}
export interface UseSftpReturn {
currentPath: Ref<string>;
entries: Ref<FileEntry[]>;
isLoading: Ref<boolean>;
followTerminal: Ref<boolean>;
navigateTo: (path: string) => Promise<void>;
goUp: () => Promise<void>;
refresh: () => Promise<void>;
}
/**
* Composable that manages SFTP file browsing state.
* Calls the real Go SFTPService via Wails bindings.
*/
export function useSftp(sessionId: string): UseSftpReturn {
const currentPath = ref("/");
const entries = ref<FileEntry[]>([]);
const isLoading = ref(false);
const followTerminal = ref(true);
async function listDirectory(path: string): Promise<FileEntry[]> {
try {
const result = await Call.ByName(`${SFTP}.List`, sessionId, path) as FileEntry[];
return result ?? [];
} catch (err) {
console.error("SFTP list error:", err);
return [];
}
}
async function navigateTo(path: string): Promise<void> {
isLoading.value = true;
try {
currentPath.value = path;
entries.value = await listDirectory(path);
} finally {
isLoading.value = false;
}
}
async function goUp(): Promise<void> {
const parts = currentPath.value.split("/").filter(Boolean);
if (parts.length <= 1) {
await navigateTo("/");
return;
}
parts.pop();
await navigateTo("/" + parts.join("/"));
}
async function refresh(): Promise<void> {
await navigateTo(currentPath.value);
}
// Listen for CWD changes from the Go backend (OSC 7 tracking)
const cleanupCwd = Events.On(`ssh:cwd:${sessionId}`, (event: any) => {
if (!followTerminal.value) return;
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);
}
});
onBeforeUnmount(() => {
if (cleanupCwd) cleanupCwd();
});
// Load home directory on init
navigateTo("/home");
return {
currentPath,
entries,
isLoading,
followTerminal,
navigateTo,
goUp,
refresh,
};
}

View File

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

View File

@ -1,499 +0,0 @@
<template>
<div class="h-screen w-screen flex flex-col overflow-hidden">
<!-- 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"
style="--wails-draggable: drag"
>
<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>
<!-- File menu -->
<div class="relative">
<button
class="text-xs text-[var(--wraith-text-secondary)] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer px-2 py-1 rounded hover:bg-[var(--wraith-bg-tertiary)]"
@click="showFileMenu = !showFileMenu"
@blur="closeFileMenuDeferred"
>
File
</button>
<div
v-if="showFileMenu"
class="absolute top-full left-0 mt-0.5 w-56 bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl overflow-hidden z-50 py-1"
>
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleFileMenuAction('new-connection')"
>
<svg class="w-3.5 h-3.5 shrink-0" viewBox="0 0 16 16" fill="currentColor"><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>
<span class="flex-1">New Connection</span>
<kbd class="text-[10px] text-[var(--wraith-text-muted)]">Ctrl+N</kbd>
</button>
<div class="border-t border-[#30363d] my-1" />
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleFileMenuAction('import')"
>
<svg class="w-3.5 h-3.5 shrink-0" 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>
<span class="flex-1">Import from MobaXTerm</span>
</button>
<div class="border-t border-[#30363d] my-1" />
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleFileMenuAction('settings')"
>
<svg class="w-3.5 h-3.5 shrink-0" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0a8.2 8.2 0 0 1 .701.031C8.955.017 9.209 0 9.466 0a1.934 1.934 0 0 1 1.466.665c.33.367.51.831.54 1.316a7.96 7.96 0 0 1 .82.4c.463-.207.97-.29 1.476-.19.504.1.963.37 1.3.77.339.404.516.91.5 1.423a1.94 1.94 0 0 1-.405 1.168 8.02 8.02 0 0 1 .356.9 1.939 1.939 0 0 1 1.48.803 1.941 1.941 0 0 1 0 2.29 1.939 1.939 0 0 1-1.48.803c-.095.316-.215.622-.357.9a1.94 1.94 0 0 1-.094 2.59 1.94 1.94 0 0 1-2.776.22 7.96 7.96 0 0 1-.82.4 1.94 1.94 0 0 1-2.006 1.98A8.2 8.2 0 0 1 8 16a8.2 8.2 0 0 1-.701-.031 1.938 1.938 0 0 1-2.005-1.98 7.96 7.96 0 0 1-.82-.4 1.94 1.94 0 0 1-2.776-.22 1.94 1.94 0 0 1-.094-2.59 8.02 8.02 0 0 1-.357-.9A1.939 1.939 0 0 1 0 8.945a1.941 1.941 0 0 1 0-2.29 1.939 1.939 0 0 1 1.247-.803c.095-.316.215-.622.357-.9a1.94 1.94 0 0 1 .094-2.59 1.94 1.94 0 0 1 2.776-.22c.258-.157.532-.293.82-.4A1.934 1.934 0 0 1 6.834.665 1.934 1.934 0 0 1 8.3.03 8.2 8.2 0 0 1 8 0ZM8 5a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z"/></svg>
<span class="flex-1">Settings</span>
</button>
<div class="border-t border-[#30363d] my-1" />
<button
class="w-full flex items-center gap-3 px-4 py-2 text-xs text-left text-[var(--wraith-text-secondary)] hover:bg-[#30363d] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@mousedown.prevent="handleFileMenuAction('exit')"
>
<svg class="w-3.5 h-3.5 shrink-0" viewBox="0 0 16 16" fill="currentColor"><path d="M2 2.75C2 1.784 2.784 1 3.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5a.75.75 0 0 1-1.5 0v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25v10.5c0 .138.112.25.25.25h3.5a.75.75 0 0 1 0 1.5h-3.5A1.75 1.75 0 0 1 2 13.25Zm10.44 4.5-1.97-1.97a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l3.25 3.25a.75.75 0 0 1 0 1.06l-3.25 3.25a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l1.97-1.97H6.75a.75.75 0 0 1 0-1.5Z"/></svg>
<span class="flex-1">Exit</span>
<kbd class="text-[10px] text-[var(--wraith-text-muted)]">Alt+F4</kbd>
</button>
</div>
</div>
</div>
<!-- Quick Connect -->
<div class="flex-1 max-w-xs mx-4" style="--wails-draggable: no-drag">
<input
v-model="quickConnectInput"
type="text"
placeholder="Quick connect: user@host:port"
class="w-full px-2.5 py-1 text-xs 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"
@keydown.enter="handleQuickConnect"
/>
</div>
<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"
title="Command palette (Ctrl+K)"
@click="commandPalette?.toggle()"
>
<svg class="w-4 h-4" viewBox="0 0 16 16" fill="currentColor">
<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"
title="Lock vault"
@click="appStore.lock()"
>
&#x1f512;
</button>
<button
class="hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
title="Settings"
@click="settingsModal?.open()"
>
&#x2699;
</button>
</div>
</div>
<!-- Main content area -->
<div class="flex flex-1 min-h-0">
<!-- Sidebar -->
<div
v-if="sidebarVisible"
class="flex flex-col bg-[var(--wraith-bg-secondary)] border-r border-[var(--wraith-border)] shrink-0"
:style="{ width: sidebarWidth + 'px' }"
>
<SidebarToggle v-model="sidebarTab" />
<!-- Search (connections mode only) -->
<div v-if="sidebarTab === 'connections'" class="px-3 py-2">
<input
v-model="connectionStore.searchQuery"
type="text"
placeholder="Search connections..."
class="w-full px-2.5 py-1.5 text-xs 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"
/>
</div>
<!-- Sidebar content -->
<div class="flex-1 overflow-y-auto">
<!-- Connection tree -->
<ConnectionTree v-if="sidebarTab === 'connections'" />
<!-- SFTP file tree -->
<template v-else-if="sidebarTab === 'sftp'">
<FileTree
v-if="sessionStore.activeSession && sessionStore.activeSession.protocol === 'ssh'"
:session-id="sessionStore.activeSession.id"
@open-file="handleOpenFile"
/>
<div v-else class="flex items-center justify-center py-8 px-3">
<p class="text-[var(--wraith-text-muted)] text-xs text-center">
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 -->
<div class="flex-1 flex flex-col min-w-0">
<!-- Tab bar -->
<TabBar />
<!-- Editor panel (if a file is open) -->
<EditorWindow
v-if="editorFile"
:content="editorFile.content"
:file-path="editorFile.path"
:session-id="editorFile.sessionId"
@close="editorFile = null"
/>
<!-- Session area -->
<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) -->
<CommandPalette
ref="commandPalette"
@open-import="importDialog?.open()"
@open-settings="settingsModal?.open()"
@open-new-connection="connectionEditDialog?.openNew()"
/>
<!-- Theme Picker -->
<ThemePicker ref="themePicker" @select="handleThemeSelect" />
<!-- Import Dialog -->
<ImportDialog ref="importDialog" />
<!-- Settings Modal -->
<SettingsModal ref="settingsModal" />
<!-- Connection Edit Dialog (for File menu / Command Palette new connection) -->
<ConnectionEditDialog ref="connectionEditDialog" />
<!-- First-run: MobaXTerm import prompt -->
<Teleport to="body">
<div
v-if="showMobaPrompt"
class="fixed inset-0 z-50 flex items-center justify-center"
>
<div class="absolute inset-0 bg-black/50" @click="showMobaPrompt = false" />
<div class="relative w-full max-w-sm bg-[#161b22] border border-[#30363d] rounded-lg shadow-2xl p-6 space-y-4">
<h3 class="text-sm font-semibold text-[var(--wraith-text-primary)]">No connections found</h3>
<p class="text-xs text-[var(--wraith-text-secondary)]">
It looks like this is your first time running Wraith. Would you like to import connections from MobaXTerm?
</p>
<div class="flex gap-2 justify-end">
<button
class="px-3 py-1.5 text-xs rounded bg-[var(--wraith-bg-tertiary)] text-[var(--wraith-text-secondary)] hover:text-[var(--wraith-text-primary)] transition-colors cursor-pointer"
@click="showMobaPrompt = false"
>
Skip
</button>
<button
class="px-3 py-1.5 text-xs rounded bg-[#1f6feb] text-white hover:bg-[#388bfd] transition-colors cursor-pointer"
@click="() => { showMobaPrompt = false; importDialog?.open(); }"
>
Import from MobaXTerm
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
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";
// 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();
// copilotStore removed
const sidebarWidth = ref(240);
const sidebarVisible = ref(true);
const sidebarTab = ref<SidebarTab>("connections");
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);
/** File menu dropdown state. */
const showFileMenu = ref(false);
/** Close the file menu after a short delay (allows click events to fire first). */
function closeFileMenuDeferred(): void {
setTimeout(() => { showFileMenu.value = false; }, 150);
}
/** Handle file menu item clicks. */
function handleFileMenuAction(action: string): void {
showFileMenu.value = false;
switch (action) {
case "new-connection":
connectionEditDialog.value?.openNew();
break;
case "import":
importDialog.value?.open();
break;
case "settings":
settingsModal.value?.open();
break;
case "exit":
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);
// Propagate theme to all active terminal instances via the session store
sessionStore.setTheme(theme);
}
/**
* Quick Connect: parse user@host:port and open a session.
* Default protocol: SSH, default port: 22.
* If port is 3389, use RDP.
*/
async function handleQuickConnect(): Promise<void> {
const raw = quickConnectInput.value.trim();
if (!raw) return;
let username = "";
let hostname = "";
let port = 22;
let protocol: "ssh" | "rdp" = "ssh";
let hostPart = raw;
// Extract username if present (user@...)
const atIdx = raw.indexOf("@");
if (atIdx > 0) {
username = raw.substring(0, atIdx);
hostPart = raw.substring(atIdx + 1);
}
// Extract port if present (...:port)
const colonIdx = hostPart.lastIndexOf(":");
if (colonIdx > 0) {
const portStr = hostPart.substring(colonIdx + 1);
const parsedPort = parseInt(portStr, 10);
if (!isNaN(parsedPort) && parsedPort > 0 && parsedPort <= 65535) {
port = parsedPort;
hostPart = hostPart.substring(0, colonIdx);
}
}
hostname = hostPart;
if (!hostname) return;
// Auto-detect RDP by port
if (port === 3389) {
protocol = "rdp";
}
const name = username ? `${username}@${hostname}` : hostname;
try {
// Create a persistent connection record then connect to it
const conn = await Call.ByName(`${CONN}.CreateConnection`, {
name,
hostname,
port,
protocol,
groupId: null,
credentialId: null,
color: "",
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({
id: conn.id,
name,
hostname,
port,
protocol,
groupId: null,
tags: username ? [username] : [],
});
await sessionStore.connect(conn.id);
quickConnectInput.value = "";
} catch (err) {
console.error("Quick connect failed:", err);
}
}
/** Global keyboard shortcut handler. */
function handleKeydown(event: KeyboardEvent): void {
// Skip shortcuts when the user is typing in an input, textarea, or select
const target = event.target as HTMLElement;
const isInputFocused = target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT";
const ctrl = event.ctrlKey || event.metaKey;
// Ctrl+K open command palette (fires even in inputs to match VS Code behavior)
if (ctrl && event.key === "k") {
event.preventDefault();
commandPalette.value?.toggle();
return;
}
// All remaining shortcuts skip when typing in input fields
if (isInputFocused) return;
// Ctrl+W close active tab
if (ctrl && event.key === "w") {
event.preventDefault();
const active = sessionStore.activeSession;
if (active) {
sessionStore.closeSession(active.id);
}
return;
}
// Ctrl+Tab next tab
if (ctrl && event.key === "Tab" && !event.shiftKey) {
event.preventDefault();
const sessions = sessionStore.sessions;
if (sessions.length < 2) return;
const idx = sessions.findIndex((s) => s.id === sessionStore.activeSessionId);
const next = sessions[(idx + 1) % sessions.length];
sessionStore.activateSession(next.id);
return;
}
// Ctrl+Shift+Tab previous tab
if (ctrl && event.key === "Tab" && event.shiftKey) {
event.preventDefault();
const sessions = sessionStore.sessions;
if (sessions.length < 2) return;
const idx = sessions.findIndex((s) => s.id === sessionStore.activeSessionId);
const prev = sessions[(idx - 1 + sessions.length) % sessions.length];
sessionStore.activateSession(prev.id);
return;
}
// Ctrl+1 through Ctrl+9 switch to tab by index
if (ctrl && event.key >= "1" && event.key <= "9") {
const tabIndex = parseInt(event.key, 10) - 1;
const sessions = sessionStore.sessions;
if (tabIndex < sessions.length) {
event.preventDefault();
sessionStore.activateSession(sessions[tabIndex].id);
}
return;
}
// Ctrl+B toggle sidebar
if (ctrl && event.key === "b") {
event.preventDefault();
sidebarVisible.value = !sidebarVisible.value;
return;
}
}
onMounted(async () => {
document.addEventListener("keydown", handleKeydown);
// 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
if (connectionStore.connections.length === 0) {
showMobaPrompt.value = true;
}
});
onUnmounted(() => {
document.removeEventListener("keydown", handleKeydown);
});
</script>
<style scoped>
</style>

View File

@ -1,129 +0,0 @@
<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,75 +0,0 @@
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,163 +0,0 @@
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,209 +0,0 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
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;
name: string;
protocol: "ssh" | "rdp";
active: boolean;
username?: string;
}
export interface TerminalDimensions {
cols: number;
rows: number;
}
export const useSessionStore = defineStore("session", () => {
const sessions = ref<Session[]>([]);
const activeSessionId = ref<string | null>(null);
const connecting = ref(false);
const lastError = ref<string | null>(null);
/** Active terminal theme — applied to all terminal instances. */
const activeTheme = ref<ThemeDefinition | null>(null);
/** Per-session terminal dimensions (cols x rows). */
const terminalDimensions = ref<Record<string, TerminalDimensions>>({});
const activeSession = computed(() =>
sessions.value.find((s) => s.id === activeSessionId.value) ?? null,
);
const sessionCount = computed(() => sessions.value.length);
function activateSession(id: string): void {
activeSessionId.value = id;
}
async function closeSession(id: string): Promise<void> {
const idx = sessions.value.findIndex((s) => s.id === id);
if (idx === -1) return;
const session = sessions.value[idx];
// Disconnect the backend session
try {
await Call.ByName(`${APP}.DisconnectSession`, session.id);
} catch (err) {
console.error("Failed to disconnect session:", err);
}
sessions.value.splice(idx, 1);
if (activeSessionId.value === id) {
if (sessions.value.length === 0) {
activeSessionId.value = null;
} else {
const nextIdx = Math.min(idx, sessions.value.length - 1);
activeSessionId.value = sessions.value[nextIdx].id;
}
}
}
/** Count how many sessions already exist for this connection (for tab name disambiguation). */
function sessionCountForConnection(connId: number): number {
return sessions.value.filter((s) => s.connectionId === connId).length;
}
/** Generate a disambiguated tab name like "Asgard", "Asgard (2)", "Asgard (3)". */
function disambiguatedName(baseName: string, connId: number): string {
const count = sessionCountForConnection(connId);
return count === 0 ? baseName : `${baseName} (${count + 1})`;
}
/**
* 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)".
*/
async function connect(connectionId: number): Promise<void> {
const connectionStore = useConnectionStore();
const conn = connectionStore.connections.find((c) => c.id === connectionId);
if (!conn) return;
connecting.value = true;
try {
if (conn.protocol === "ssh") {
let sessionId: string;
let resolvedUsername: string | undefined;
try {
// 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, 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 Call.ByName(
`${APP}.ConnectSSHWithPassword`,
connectionId,
username,
password,
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,
name: disambiguatedName(conn.name, connectionId),
protocol: "ssh",
active: true,
username: resolvedUsername,
});
activeSessionId.value = sessionId;
} else if (conn.protocol === "rdp") {
// 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,
connectionId,
name: disambiguatedName(conn.name, connectionId),
protocol: "rdp",
active: true,
});
activeSessionId.value = sessionId;
}
} 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
alert(`Connection failed: ${msg}`);
} finally {
connecting.value = false;
}
}
/** Apply a theme to all active terminal instances. */
function setTheme(theme: ThemeDefinition): void {
activeTheme.value = theme;
}
/** Update the recorded dimensions for a terminal session. */
function setTerminalDimensions(sessionId: string, cols: number, rows: number): void {
terminalDimensions.value[sessionId] = { cols, rows };
}
/** Get the dimensions for the active session, or null if not tracked yet. */
const activeDimensions = computed<TerminalDimensions | null>(() => {
if (!activeSessionId.value) return null;
return terminalDimensions.value[activeSessionId.value] ?? null;
});
return {
sessions,
activeSessionId,
activeSession,
sessionCount,
connecting,
lastError,
activeTheme,
terminalDimensions,
activeDimensions,
activateSession,
closeSession,
connect,
setTheme,
setTerminalDimensions,
};
});

View File

@ -1,19 +0,0 @@
{
"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"]
}

View File

@ -1,13 +0,0 @@
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
View File

@ -1,58 +0,0 @@
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
View File

@ -1,197 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

25
index.html Normal file
View File

@ -0,0 +1,25 @@
<!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>

View File

@ -1,256 +0,0 @@
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 "", ""
}

View File

@ -1,123 +0,0 @@
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")
}
}

View File

@ -1,169 +0,0 @@
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

@ -1,220 +0,0 @@
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)
}
}

View File

@ -1,473 +0,0 @@
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
}

View File

@ -1,91 +0,0 @@
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")
}
}

View File

@ -1,564 +0,0 @@
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

@ -1,85 +0,0 @@
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")
}
}

View File

@ -1,79 +0,0 @@
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

@ -1,118 +0,0 @@
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)
}
}
}

View File

@ -1,338 +0,0 @@
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

@ -1,101 +0,0 @@
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

@ -1,175 +0,0 @@
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))
}
}

View File

@ -1,207 +0,0 @@
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"]
}`),
},
}

View File

@ -1,75 +0,0 @@
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"`
}

View File

@ -1,722 +0,0 @@
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()
}

View File

@ -1,66 +0,0 @@
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

@ -1,74 +0,0 @@
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

@ -1,40 +0,0 @@
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

@ -1,53 +0,0 @@
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

@ -1,380 +0,0 @@
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

@ -1,234 +0,0 @@
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

@ -1,441 +0,0 @@
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

@ -1,176 +0,0 @@
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)
}
}

View File

@ -1,34 +0,0 @@
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

@ -1,101 +0,0 @@
-- 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

@ -1,10 +0,0 @@
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
);

View File

@ -1,8 +0,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
);

View File

@ -1,39 +0,0 @@
package db
import (
"database/sql"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
func Open(dbPath string) (*sql.DB, error) {
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, fmt.Errorf("create db directory: %w", err)
}
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
db.Close()
return nil, fmt.Errorf("set WAL mode: %w", err)
}
if _, err := db.Exec("PRAGMA busy_timeout=5000"); err != nil {
db.Close()
return nil, fmt.Errorf("set busy_timeout: %w", err)
}
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
db.Close()
return nil, fmt.Errorf("enable foreign keys: %w", err)
}
return db, nil
}

View File

@ -1,85 +0,0 @@
package db
import (
"os"
"path/filepath"
"testing"
)
func TestOpenCreatesDatabase(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
db, err := Open(dbPath)
if err != nil {
t.Fatalf("Open() error: %v", err)
}
defer db.Close()
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
t.Fatal("database file was not created")
}
}
func TestOpenSetsWALMode(t *testing.T) {
dir := t.TempDir()
db, err := Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("Open() error: %v", err)
}
defer db.Close()
var mode string
err = db.QueryRow("PRAGMA journal_mode").Scan(&mode)
if err != nil {
t.Fatalf("PRAGMA query error: %v", err)
}
if mode != "wal" {
t.Errorf("journal_mode = %q, want %q", mode, "wal")
}
}
func TestOpenSetsBusyTimeout(t *testing.T) {
dir := t.TempDir()
db, err := Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("Open() error: %v", err)
}
defer db.Close()
var timeout int
err = db.QueryRow("PRAGMA busy_timeout").Scan(&timeout)
if err != nil {
t.Fatalf("PRAGMA query error: %v", err)
}
if timeout != 5000 {
t.Errorf("busy_timeout = %d, want %d", timeout, 5000)
}
}
func TestMigrateCreatesAllTables(t *testing.T) {
dir := t.TempDir()
db, err := Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("Open() error: %v", err)
}
defer db.Close()
if err := Migrate(db); err != nil {
t.Fatalf("Migrate() error: %v", err)
}
expectedTables := []string{
"groups", "connections", "credentials", "ssh_keys",
"themes", "connection_history", "host_keys", "settings",
}
for _, table := range expectedTables {
var name string
err := db.QueryRow(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?", table,
).Scan(&name)
if err != nil {
t.Errorf("table %q not found: %v", table, err)
}
}
}

View File

@ -1,322 +0,0 @@
package importer
import (
"fmt"
"strconv"
"strings"
"github.com/vstockwell/wraith/internal/plugin"
)
// MobaConfImporter parses MobaXTerm .mobaconf configuration files and extracts
// connections, groups, host keys, and terminal theme settings.
type MobaConfImporter struct{}
func (m *MobaConfImporter) Name() string { return "MobaXTerm" }
func (m *MobaConfImporter) FileExtensions() []string { return []string{".mobaconf"} }
// Parse reads a .mobaconf file and returns the extracted import result.
func (m *MobaConfImporter) Parse(data []byte) (*plugin.ImportResult, error) {
result := &plugin.ImportResult{
Groups: []plugin.ImportGroup{},
Connections: []plugin.ImportConnection{},
HostKeys: []plugin.ImportHostKey{},
}
sections := parseSections(string(data))
// Parse [Bookmarks_N] sections
for name, lines := range sections {
if !strings.HasPrefix(name, "Bookmarks") {
continue
}
groupName := ""
for _, line := range lines {
key, value := splitKV(line)
if key == "SubRep" && value != "" {
groupName = value
result.Groups = append(result.Groups, plugin.ImportGroup{
Name: groupName,
})
}
}
// Parse session lines within the bookmarks section
for _, line := range lines {
key, value := splitKV(line)
if key == "SubRep" || key == "ImgNum" || key == "" {
continue
}
// Session lines start with a name (possibly prefixed with *)
// and contain = followed by the session definition
if value == "" {
continue
}
sessionName := key
// Strip leading * from session names (marks favorites)
sessionName = strings.TrimPrefix(sessionName, "*")
conn := parseSessionLine(sessionName, value)
if conn != nil {
conn.GroupName = groupName
result.Connections = append(result.Connections, *conn)
}
}
}
// Parse [SSH_Hostkeys] section
if hostKeyLines, ok := sections["SSH_Hostkeys"]; ok {
for _, line := range hostKeyLines {
key, value := splitKV(line)
if key == "" || value == "" {
continue
}
hk := parseHostKeyLine(key, value)
if hk != nil {
result.HostKeys = append(result.HostKeys, *hk)
}
}
}
// Parse [Colors] section
if colorLines, ok := sections["Colors"]; ok {
theme := parseColorSection(colorLines)
if theme != nil {
result.Theme = theme
}
}
return result, nil
}
// parseSections splits .mobaconf INI content into named sections.
// Returns a map of section name -> lines within that section.
func parseSections(content string) map[string][]string {
sections := make(map[string][]string)
currentSection := ""
for _, line := range strings.Split(content, "\n") {
line = strings.TrimRight(line, "\r")
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
currentSection = line[1 : len(line)-1]
if _, exists := sections[currentSection]; !exists {
sections[currentSection] = []string{}
}
continue
}
if currentSection != "" && line != "" {
sections[currentSection] = append(sections[currentSection], line)
}
}
return sections
}
// splitKV splits a line on the first = sign into key and value.
func splitKV(line string) (string, string) {
idx := strings.Index(line, "=")
if idx < 0 {
return line, ""
}
return strings.TrimSpace(line[:idx]), strings.TrimSpace(line[idx+1:])
}
// parseSessionLine parses a MobaXTerm session definition string.
// Format: #type#flags%field1%field2%...
// SSH (#109#): fields[0]=host, fields[1]=port, fields[2]=username
// RDP (#91#): fields[0]=host, fields[1]=port, fields[2]=username
func parseSessionLine(name, value string) *plugin.ImportConnection {
// Find the protocol type marker
hashIdx := strings.Index(value, "#")
if hashIdx < 0 {
return nil
}
// Extract the type number between # markers
rest := value[hashIdx+1:]
secondHash := strings.Index(rest, "#")
if secondHash < 0 {
return nil
}
typeStr := rest[:secondHash]
afterType := rest[secondHash+1:]
// The remainder after the type and flags section: flags%field1%field2%...
// Split on the first % to separate flags from fields, then split the rest
parts := strings.Split(afterType, "%")
if len(parts) < 2 {
return nil
}
// parts[0] = flags (numeric), parts[1:] = fields
fields := parts[1:]
conn := &plugin.ImportConnection{
Name: name,
}
switch typeStr {
case "109": // SSH
conn.Protocol = "ssh"
if len(fields) >= 1 {
conn.Hostname = fields[0]
}
if len(fields) >= 2 {
port, err := strconv.Atoi(fields[1])
if err == nil && port > 0 {
conn.Port = port
} else {
conn.Port = 22
}
} else {
conn.Port = 22
}
if len(fields) >= 3 {
conn.Username = fields[2]
}
case "91": // RDP
conn.Protocol = "rdp"
if len(fields) >= 1 {
conn.Hostname = fields[0]
}
if len(fields) >= 2 {
port, err := strconv.Atoi(fields[1])
if err == nil && port > 0 {
conn.Port = port
} else {
conn.Port = 3389
}
} else {
conn.Port = 3389
}
if len(fields) >= 3 {
conn.Username = fields[2]
}
default:
// Unknown protocol type — skip
return nil
}
if conn.Hostname == "" {
return nil
}
return conn
}
// parseHostKeyLine parses a line from the [SSH_Hostkeys] section.
// Key format: keytype@port:hostname
// Value: the fingerprint data
func parseHostKeyLine(key, value string) *plugin.ImportHostKey {
// Parse key format: keytype@port:hostname
atIdx := strings.Index(key, "@")
if atIdx < 0 {
return nil
}
keyType := key[:atIdx]
rest := key[atIdx+1:]
colonIdx := strings.Index(rest, ":")
if colonIdx < 0 {
return nil
}
portStr := rest[:colonIdx]
hostname := rest[colonIdx+1:]
port, err := strconv.Atoi(portStr)
if err != nil {
return nil
}
return &plugin.ImportHostKey{
Hostname: hostname,
Port: port,
KeyType: keyType,
Fingerprint: value,
}
}
// parseColorSection extracts a terminal theme from the [Colors] section.
func parseColorSection(lines []string) *plugin.ImportTheme {
colorMap := make(map[string]string)
for _, line := range lines {
key, value := splitKV(line)
if key != "" && value != "" {
colorMap[key] = value
}
}
// Need at least foreground and background to form a theme
fg, hasFg := colorMap["ForegroundColour"]
bg, hasBg := colorMap["BackgroundColour"]
if !hasFg || !hasBg {
return nil
}
theme := &plugin.ImportTheme{
Name: "MobaXTerm Import",
Foreground: rgbToHex(fg),
Background: rgbToHex(bg),
}
if cursor, ok := colorMap["CursorColour"]; ok {
theme.Cursor = rgbToHex(cursor)
}
// Map MobaXTerm color names to the 16-color array positions
// Standard ANSI order: black, red, green, yellow, blue, magenta, cyan, white,
// then bright variants in the same order
colorNames := [16][2]string{
{"Black", ""},
{"Red", ""},
{"Green", ""},
{"Yellow", ""},
{"Blue", ""},
{"Magenta", ""},
{"Cyan", ""},
{"White", ""},
{"BoldBlack", ""},
{"BoldRed", ""},
{"BoldGreen", ""},
{"BoldYellow", ""},
{"BoldBlue", ""},
{"BoldMagenta", ""},
{"BoldCyan", ""},
{"BoldWhite", ""},
}
for i, cn := range colorNames {
name := cn[0]
if val, ok := colorMap[name]; ok {
theme.Colors[i] = rgbToHex(val)
}
}
return theme
}
// rgbToHex converts a "R,G,B" string to "#RRGGBB" hex format.
func rgbToHex(rgb string) string {
parts := strings.Split(rgb, ",")
if len(parts) != 3 {
return rgb // Return as-is if not in expected format
}
r, err1 := strconv.Atoi(strings.TrimSpace(parts[0]))
g, err2 := strconv.Atoi(strings.TrimSpace(parts[1]))
b, err3 := strconv.Atoi(strings.TrimSpace(parts[2]))
if err1 != nil || err2 != nil || err3 != nil {
return rgb
}
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
}

View File

@ -1,236 +0,0 @@
package importer
import (
"os"
"testing"
)
func TestParseMobaConf(t *testing.T) {
data, err := os.ReadFile("../../docs/config-export.mobaconf")
if err != nil {
t.Skip("config file not found")
}
imp := &MobaConfImporter{}
result, err := imp.Parse(data)
if err != nil {
t.Fatal(err)
}
if len(result.Groups) == 0 {
t.Error("should parse groups")
}
if len(result.Connections) == 0 {
t.Error("should parse connections")
}
// Check that we found the expected group
foundGroup := false
for _, g := range result.Groups {
if g.Name == "AAA Vantz's Stuff" {
foundGroup = true
break
}
}
if !foundGroup {
t.Error("should find 'AAA Vantz's Stuff' group")
}
// Check for known SSH connections
foundAsgard := false
foundDocker := false
for _, c := range result.Connections {
if c.Name == "Asgard" {
foundAsgard = true
if c.Hostname != "192.168.1.4" {
t.Errorf("Asgard hostname = %q, want 192.168.1.4", c.Hostname)
}
if c.Port != 22 {
t.Errorf("Asgard port = %d, want 22", c.Port)
}
if c.Protocol != "ssh" {
t.Errorf("Asgard protocol = %q, want ssh", c.Protocol)
}
if c.Username != "vstockwell" {
t.Errorf("Asgard username = %q, want vstockwell", c.Username)
}
}
if c.Name == "Docker" {
foundDocker = true
if c.Hostname != "155.254.29.221" {
t.Errorf("Docker hostname = %q, want 155.254.29.221", c.Hostname)
}
}
}
if !foundAsgard {
t.Error("should find Asgard connection")
}
if !foundDocker {
t.Error("should find Docker connection")
}
// Check for RDP connections
foundRDP := false
for _, c := range result.Connections {
if c.Name == "CLT-VMHOST01" {
foundRDP = true
if c.Protocol != "rdp" {
t.Errorf("CLT-VMHOST01 protocol = %q, want rdp", c.Protocol)
}
if c.Hostname != "100.64.1.204" {
t.Errorf("CLT-VMHOST01 hostname = %q, want 100.64.1.204", c.Hostname)
}
if c.Port != 3389 {
t.Errorf("CLT-VMHOST01 port = %d, want 3389", c.Port)
}
}
}
if !foundRDP {
t.Error("should find CLT-VMHOST01 RDP connection")
}
// Check host keys were parsed
if len(result.HostKeys) == 0 {
t.Error("should parse host keys")
}
// Check theme was parsed
if result.Theme == nil {
t.Error("should parse theme from Colors section")
} else {
if result.Theme.Foreground != "#ececec" {
t.Errorf("theme foreground = %q, want #ececec", result.Theme.Foreground)
}
if result.Theme.Background != "#242424" {
t.Errorf("theme background = %q, want #242424", result.Theme.Background)
}
}
}
func TestParseSSHSession(t *testing.T) {
line := `#109#0%192.168.1.4%22%vstockwell%%-1%-1%%%22%%0%0%0%V:\ssh-key%%-1%0%0%0%%1080%%0%0%1%%0%%%%0%-1%-1%0`
conn := parseSessionLine("Asgard", line)
if conn == nil {
t.Fatal("should parse SSH session")
}
if conn.Hostname != "192.168.1.4" {
t.Errorf("hostname = %q, want 192.168.1.4", conn.Hostname)
}
if conn.Port != 22 {
t.Errorf("port = %d, want 22", conn.Port)
}
if conn.Protocol != "ssh" {
t.Errorf("protocol = %q, want ssh", conn.Protocol)
}
if conn.Username != "vstockwell" {
t.Errorf("username = %q, want vstockwell", conn.Username)
}
}
func TestParseRDPSession(t *testing.T) {
line := `#91#4%100.64.1.204%3389%%-1%0%0%0%-1%0%0%-1`
conn := parseSessionLine("CLT-VMHOST01", line)
if conn == nil {
t.Fatal("should parse RDP session")
}
if conn.Hostname != "100.64.1.204" {
t.Errorf("hostname = %q, want 100.64.1.204", conn.Hostname)
}
if conn.Port != 3389 {
t.Errorf("port = %d, want 3389", conn.Port)
}
if conn.Protocol != "rdp" {
t.Errorf("protocol = %q, want rdp", conn.Protocol)
}
}
func TestParseSSHSessionWithUsername(t *testing.T) {
line := `#109#0%192.168.1.105%22%root%%-1%-1%%%%%0%0%0%%%-1%0%0%0%%1080%%0%0%1`
conn := parseSessionLine("Node 1(top)", line)
if conn == nil {
t.Fatal("should parse SSH session")
}
if conn.Username != "root" {
t.Errorf("username = %q, want root", conn.Username)
}
}
func TestParseRDPSessionWithUsername(t *testing.T) {
line := `#91#4%192.154.253.107%3389%administrator%0%0%0%0%-1%0%0%-1`
conn := parseSessionLine("Win Game Host", line)
if conn == nil {
t.Fatal("should parse RDP session")
}
if conn.Username != "administrator" {
t.Errorf("username = %q, want administrator", conn.Username)
}
if conn.Hostname != "192.154.253.107" {
t.Errorf("hostname = %q, want 192.154.253.107", conn.Hostname)
}
}
func TestRgbToHex(t *testing.T) {
tests := []struct {
input string
want string
}{
{"236,236,236", "#ececec"},
{"0,0,0", "#000000"},
{"255,255,255", "#ffffff"},
{"36,36,36", "#242424"},
{"128,128,128", "#808080"},
}
for _, tt := range tests {
got := rgbToHex(tt.input)
if got != tt.want {
t.Errorf("rgbToHex(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestParseHostKeyLine(t *testing.T) {
hk := parseHostKeyLine(
"ssh-ed25519@22:192.168.1.4",
"0x29ac3a21e1d5166c45aed41398d71cc889b683d01e1a019bf23cb2e1ce1c8276,0x2a8e2417caf686ac4b219cc3b94cd726fb49d2559bd8725ac2281b842845582b",
)
if hk == nil {
t.Fatal("should parse host key")
}
if hk.Hostname != "192.168.1.4" {
t.Errorf("hostname = %q, want 192.168.1.4", hk.Hostname)
}
if hk.Port != 22 {
t.Errorf("port = %d, want 22", hk.Port)
}
if hk.KeyType != "ssh-ed25519" {
t.Errorf("keyType = %q, want ssh-ed25519", hk.KeyType)
}
}
func TestImporterInterface(t *testing.T) {
imp := &MobaConfImporter{}
if imp.Name() != "MobaXTerm" {
t.Errorf("Name() = %q, want MobaXTerm", imp.Name())
}
exts := imp.FileExtensions()
if len(exts) != 1 || exts[0] != ".mobaconf" {
t.Errorf("FileExtensions() = %v, want [.mobaconf]", exts)
}
}
func TestParseUnknownProtocol(t *testing.T) {
line := `#999#0%hostname%22%user`
conn := parseSessionLine("Unknown", line)
if conn != nil {
t.Error("should return nil for unknown protocol type")
}
}
func TestParseEmptySessionLine(t *testing.T) {
conn := parseSessionLine("Empty", "")
if conn != nil {
t.Error("should return nil for empty session line")
}
}

View File

@ -1,57 +0,0 @@
package plugin
type ProtocolHandler interface {
Name() string
Connect(config map[string]interface{}) (Session, error)
Disconnect(sessionID string) error
}
type Session interface {
ID() string
Protocol() string
Write(data []byte) error
Close() error
}
type Importer interface {
Name() string
FileExtensions() []string
Parse(data []byte) (*ImportResult, error)
}
type ImportResult struct {
Groups []ImportGroup `json:"groups"`
Connections []ImportConnection `json:"connections"`
HostKeys []ImportHostKey `json:"hostKeys"`
Theme *ImportTheme `json:"theme,omitempty"`
}
type ImportGroup struct {
Name string `json:"name"`
ParentName string `json:"parentName,omitempty"`
}
type ImportConnection struct {
Name string `json:"name"`
Hostname string `json:"hostname"`
Port int `json:"port"`
Protocol string `json:"protocol"`
Username string `json:"username"`
GroupName string `json:"groupName"`
Notes string `json:"notes"`
}
type ImportHostKey struct {
Hostname string `json:"hostname"`
Port int `json:"port"`
KeyType string `json:"keyType"`
Fingerprint string `json:"fingerprint"`
}
type ImportTheme struct {
Name string `json:"name"`
Foreground string `json:"foreground"`
Background string `json:"background"`
Cursor string `json:"cursor"`
Colors [16]string `json:"colors"`
}

View File

@ -1,47 +0,0 @@
package plugin
import "fmt"
type Registry struct {
protocols map[string]ProtocolHandler
importers map[string]Importer
}
func NewRegistry() *Registry {
return &Registry{
protocols: make(map[string]ProtocolHandler),
importers: make(map[string]Importer),
}
}
func (r *Registry) RegisterProtocol(handler ProtocolHandler) {
r.protocols[handler.Name()] = handler
}
func (r *Registry) RegisterImporter(imp Importer) {
r.importers[imp.Name()] = imp
}
func (r *Registry) GetProtocol(name string) (ProtocolHandler, error) {
h, ok := r.protocols[name]
if !ok {
return nil, fmt.Errorf("protocol handler %q not registered", name)
}
return h, nil
}
func (r *Registry) GetImporter(name string) (Importer, error) {
imp, ok := r.importers[name]
if !ok {
return nil, fmt.Errorf("importer %q not registered", name)
}
return imp, nil
}
func (r *Registry) ListProtocols() []string {
names := make([]string, 0, len(r.protocols))
for name := range r.protocols {
names = append(names, name)
}
return names
}

View File

@ -1,14 +0,0 @@
package rdp
import "runtime"
// NewProductionBackend returns the appropriate RDP backend for the current
// platform. On Windows it returns a FreeRDPBackend that loads freerdp3.dll
// at runtime via syscall. On other platforms it falls back to MockBackend
// so the application can still be developed and tested without FreeRDP.
func NewProductionBackend() RDPBackend {
if runtime.GOOS == "windows" {
return NewFreeRDPBackend()
}
return NewMockBackend()
}

View File

@ -1,45 +0,0 @@
//go:build !windows
package rdp
import "fmt"
// FreeRDPBackend is a stub on non-Windows platforms. The real implementation
// lives in freerdp_windows.go and loads FreeRDP3 DLLs via syscall at runtime.
type FreeRDPBackend struct{}
// NewFreeRDPBackend creates a stub backend that returns errors on all operations.
func NewFreeRDPBackend() *FreeRDPBackend {
return &FreeRDPBackend{}
}
func (f *FreeRDPBackend) Connect(config RDPConfig) error {
return fmt.Errorf("FreeRDP backend is only available on Windows — use MockBackend for development")
}
func (f *FreeRDPBackend) Disconnect() error {
return nil
}
func (f *FreeRDPBackend) SendMouseEvent(x, y int, flags uint32) error {
return fmt.Errorf("FreeRDP backend is not available on this platform")
}
func (f *FreeRDPBackend) SendKeyEvent(scancode uint32, pressed bool) error {
return fmt.Errorf("FreeRDP backend is not available on this platform")
}
func (f *FreeRDPBackend) SendClipboard(data string) error {
return fmt.Errorf("FreeRDP backend is not available on this platform")
}
func (f *FreeRDPBackend) GetFrame() ([]byte, error) {
return nil, fmt.Errorf("FreeRDP backend is not available on this platform")
}
func (f *FreeRDPBackend) IsConnected() bool {
return false
}
// Ensure FreeRDPBackend satisfies the RDPBackend interface at compile time.
var _ RDPBackend = (*FreeRDPBackend)(nil)

View File

@ -1,715 +0,0 @@
//go:build windows
package rdp
import (
"fmt"
"sync"
"syscall"
"time"
"unsafe"
)
var (
libfreerdp = syscall.NewLazyDLL("libfreerdp3.dll")
libfreerdpClient = syscall.NewLazyDLL("libfreerdp-client3.dll")
// Instance lifecycle
procFreerdpNew = libfreerdp.NewProc("freerdp_new")
procFreerdpFree = libfreerdp.NewProc("freerdp_free")
procFreerdpConnect = libfreerdp.NewProc("freerdp_connect")
procFreerdpDisconnect = libfreerdp.NewProc("freerdp_disconnect")
// Context
procContextNew = libfreerdp.NewProc("freerdp_context_new")
// Settings
procSettingsSetString = libfreerdp.NewProc("freerdp_settings_set_string")
procSettingsSetUint32 = libfreerdp.NewProc("freerdp_settings_set_uint32")
procSettingsSetBool = libfreerdp.NewProc("freerdp_settings_set_bool")
// Input
procInputSendMouse = libfreerdp.NewProc("freerdp_input_send_mouse_event")
procInputSendKeyboard = libfreerdp.NewProc("freerdp_input_send_keyboard_event")
// Event loop — NOTE: takes rdpContext*, not freerdp*
procCheckEventHandles = libfreerdp.NewProc("freerdp_check_event_handles")
// GDI subsystem (libfreerdp3.dll exports these)
procGdiInit = libfreerdp.NewProc("gdi_init")
procGdiFree = libfreerdp.NewProc("gdi_free")
// Client helpers
procClientNew = libfreerdpClient.NewProc("freerdp_client_context_new")
procClientFree = libfreerdpClient.NewProc("freerdp_client_context_free")
)
// ============================================================================
// FreeRDP3 settings IDs
//
// Source: FreeRDP 3.10.3 — include/freerdp/settings_types_private.h
// Each ID is the ALIGN64 slot index from the rdp_settings struct.
// ============================================================================
const (
FreeRDP_ServerHostname = 20
FreeRDP_ServerPort = 21
FreeRDP_Username = 22
FreeRDP_Password = 23
FreeRDP_Domain = 24
FreeRDP_DesktopWidth = 1025
FreeRDP_DesktopHeight = 1026
FreeRDP_ColorDepth = 1027
FreeRDP_FullscreenMode = 1028
FreeRDP_SoftwareGdi = 1601 // BOOL — enable software GDI rendering
FreeRDP_IgnoreCertificate = 4556
FreeRDP_AuthenticationOnly = 4554
FreeRDP_NlaSecurity = 4560
FreeRDP_TlsSecurity = 4561
FreeRDP_RdpSecurity = 4562
)
// Keyboard event flags for FreeRDP input calls.
const (
KBD_FLAGS_EXTENDED = 0x0100
KBD_FLAGS_DOWN = 0x4000
KBD_FLAGS_RELEASE = 0x8000
)
// ============================================================================
// FreeRDP3 struct offsets — x86_64 / Windows amd64
//
// IMPORTANT: These offsets are specific to FreeRDP 3.10.3 compiled with
// WITH_FREERDP_DEPRECATED=OFF (the default, and what our CI uses).
//
// Every ALIGN64 slot is 8 bytes. The slot number from the FreeRDP headers
// gives the byte offset as: slot * 8.
//
// Source: FreeRDP 3.10.3 — include/freerdp/freerdp.h, include/freerdp/update.h
// ============================================================================
const (
// rdp_freerdp struct (ALIGN64 slots, 8 bytes each)
// slot 0: context (rdpContext*)
// slot 48: PreConnect callback (pConnectCallback)
// slot 49: PostConnect callback (pConnectCallback)
// slot 55: PostDisconnect callback (pPostDisconnect)
freerdpOffsetContext = 0 * 8 // rdpContext* — slot 0
freerdpOffsetPreConnect = 48 * 8 // pConnectCallback — slot 48
freerdpOffsetPostConnect = 49 * 8 // pConnectCallback — slot 49
freerdpOffsetPostDisconnect = 55 * 8 // pPostDisconnect — slot 55
// rdp_context struct (ALIGN64 slots, 8 bytes each)
// slot 33: gdi (rdpGdi*)
// slot 38: input (rdpInput*)
// slot 39: update (rdpUpdate*)
// slot 40: settings (rdpSettings*)
contextOffsetGdi = 33 * 8 // rdpGdi*
contextOffsetInput = 38 * 8 // rdpInput*
contextOffsetUpdate = 39 * 8 // rdpUpdate*
contextOffsetSettings = 40 * 8 // rdpSettings*
// rdp_update struct (ALIGN64 slots, 8 bytes each)
// slot 21: BitmapUpdate callback (pBitmapUpdate)
updateOffsetBitmapUpdate = 21 * 8
// rdpGdi struct (NOT ALIGN64 — regular C struct with natural alignment)
// On x86_64:
// offset 0: rdpContext* context (8 bytes)
// offset 8: INT32 width (4 bytes)
// offset 12: INT32 height (4 bytes)
// offset 16: UINT32 stride (4 bytes)
// offset 20: UINT32 dstFormat (4 bytes)
// offset 24: UINT32 cursor_x (4 bytes)
// offset 28: UINT32 cursor_y (4 bytes)
// offset 32: HGDI_DC hdc (8 bytes, pointer)
// offset 40: gdiBitmap* primary (8 bytes)
// offset 48: gdiBitmap* drawing (8 bytes)
// offset 56: UINT32 bitmap_size (4 bytes)
// offset 60: UINT32 bitmap_stride (4 bytes)
// offset 64: BYTE* primary_buffer (8 bytes)
gdiOffsetWidth = 8
gdiOffsetHeight = 12
gdiOffsetStride = 16
gdiOffsetPrimaryBuffer = 64
)
// Pixel format constants from FreeRDP3 codec/color.h.
// Formula: (bpp << 24) | (type << 16) | (a << 12) | (r << 8) | (g << 4) | b
// BGRA type = 4, RGBA type = 3
const (
PIXEL_FORMAT_BGRA32 = 0x20040888
)
// ============================================================================
// Instance-to-backend registry
//
// syscall.NewCallback produces a bare C function pointer — it cannot capture
// Go closures. We use a global map keyed by the freerdp instance pointer to
// recover the FreeRDPBackend from inside callbacks.
// ============================================================================
var (
instanceRegistry = make(map[uintptr]*FreeRDPBackend)
instanceRegistryMu sync.RWMutex
)
func registerInstance(instance uintptr, backend *FreeRDPBackend) {
instanceRegistryMu.Lock()
instanceRegistry[instance] = backend
instanceRegistryMu.Unlock()
}
func unregisterInstance(instance uintptr) {
instanceRegistryMu.Lock()
delete(instanceRegistry, instance)
instanceRegistryMu.Unlock()
}
func lookupInstance(instance uintptr) *FreeRDPBackend {
instanceRegistryMu.RLock()
b := instanceRegistry[instance]
instanceRegistryMu.RUnlock()
return b
}
// ============================================================================
// Unsafe pointer helpers
// ============================================================================
// readPtr reads a pointer-sized value at the given byte offset from base.
func readPtr(base uintptr, offsetBytes uintptr) uintptr {
return *(*uintptr)(unsafe.Pointer(base + offsetBytes))
}
// writePtr writes a pointer-sized value at the given byte offset from base.
func writePtr(base uintptr, offsetBytes uintptr, val uintptr) {
*(*uintptr)(unsafe.Pointer(base + offsetBytes)) = val
}
// readU32 reads a uint32 at the given byte offset from base.
func readU32(base uintptr, offsetBytes uintptr) uint32 {
return *(*uint32)(unsafe.Pointer(base + offsetBytes))
}
// ============================================================================
// Callbacks — registered via syscall.NewCallback (stdcall on Windows)
//
// FreeRDP callback signatures (from freerdp.h):
// typedef BOOL (*pConnectCallback)(freerdp* instance);
// typedef BOOL (*pBitmapUpdate)(rdpContext* context, const BITMAP_UPDATE* bitmap);
// ============================================================================
// postConnectCallback is the global PostConnect handler. FreeRDP calls this
// after the RDP connection is fully established. We use it to:
// 1. Initialize the GDI subsystem (software rendering into a memory buffer)
// 2. Extract the GDI primary_buffer pointer for frame capture
// 3. Register the BitmapUpdate callback for partial screen updates
var postConnectCallbackPtr = syscall.NewCallback(postConnectCallback)
func postConnectCallback(instance uintptr) uintptr {
backend := lookupInstance(instance)
if backend == nil {
return 0 // FALSE — unknown instance
}
// Read context from instance->context (slot 0).
context := readPtr(instance, freerdpOffsetContext)
if context == 0 {
return 0
}
// Initialize the GDI subsystem with BGRA32 pixel format.
// gdi_init(freerdp* instance, UINT32 format) -> BOOL
// This allocates the primary surface and registers internal paint callbacks.
ret, _, _ := procGdiInit.Call(instance, uintptr(PIXEL_FORMAT_BGRA32))
if ret == 0 {
return 0 // gdi_init failed
}
// Read the rdpGdi pointer from context->gdi (slot 33).
gdi := readPtr(context, contextOffsetGdi)
if gdi == 0 {
return 0
}
// Extract frame dimensions and primary buffer pointer from rdpGdi.
gdiWidth := readU32(gdi, gdiOffsetWidth)
gdiHeight := readU32(gdi, gdiOffsetHeight)
gdiStride := readU32(gdi, gdiOffsetStride)
primaryBuf := readPtr(gdi, gdiOffsetPrimaryBuffer)
if primaryBuf == 0 {
return 0
}
// Store the GDI surface info on the backend for frame reads.
backend.gdiPrimaryBuf = primaryBuf
backend.gdiWidth = int(gdiWidth)
backend.gdiHeight = int(gdiHeight)
backend.gdiStride = int(gdiStride)
// Re-create the pixel buffer if GDI negotiated a different resolution
// (the server may reject our requested size).
if int(gdiWidth) != backend.buffer.Width || int(gdiHeight) != backend.buffer.Height {
backend.buffer = NewPixelBuffer(int(gdiWidth), int(gdiHeight))
}
// Register the BitmapUpdate callback on update->BitmapUpdate (slot 21).
// With GDI mode enabled, FreeRDP's internal GDI layer handles most
// rendering via BeginPaint/EndPaint. The BitmapUpdate callback fires for
// uncompressed bitmap transfers that bypass GDI. We register it as a
// safety net to capture any frames that arrive through this path.
update := readPtr(context, contextOffsetUpdate)
if update != 0 {
writePtr(update, updateOffsetBitmapUpdate, bitmapUpdateCallbackPtr)
}
backend.gdiReady = true
return 1 // TRUE — success
}
// preConnectCallback is called before the connection is fully established.
// We use it to enable SoftwareGdi mode so FreeRDP handles bitmap decoding.
var preConnectCallbackPtr = syscall.NewCallback(preConnectCallback)
func preConnectCallback(instance uintptr) uintptr {
backend := lookupInstance(instance)
if backend == nil {
return 0
}
// Enable software GDI — FreeRDP will decode bitmaps into a memory surface.
backend.setBool(FreeRDP_SoftwareGdi, true)
return 1 // TRUE
}
// bitmapUpdateCallback handles BITMAP_UPDATE messages. With GDI mode active,
// most rendering goes through the GDI pipeline and lands in primary_buffer
// automatically. This callback catches any bitmap updates that come through
// the legacy path and blits them into our pixel buffer.
//
// BITMAP_UPDATE layout (FreeRDP 3.10.3):
// UINT32 number — offset 0, number of rectangles
// BITMAP_DATA* rectangles — offset 8 (pointer, 8-byte aligned)
//
// BITMAP_DATA layout (packed, not ALIGN64):
// UINT32 destLeft — offset 0
// UINT32 destTop — offset 4
// UINT32 destRight — offset 8
// UINT32 destBottom — offset 12
// UINT32 width — offset 16
// UINT32 height — offset 20
// UINT32 bitsPerPixel — offset 24
// UINT32 flags — offset 28
// UINT32 bitmapLength — offset 32
// UINT32 cbCompFirstRowSize — offset 36
// UINT32 cbCompMainBodySize — offset 40
// UINT32 cbScanWidth — offset 44
// UINT32 cbUncompressedSize — offset 48
// BYTE* bitmapDataStream — offset 56 (pointer, 8-byte aligned after padding)
// BOOL compressed — offset 64
var bitmapUpdateCallbackPtr = syscall.NewCallback(bitmapUpdateCallback)
func bitmapUpdateCallback(context uintptr, bitmapUpdate uintptr) uintptr {
if context == 0 || bitmapUpdate == 0 {
return 1 // TRUE — don't break the pipeline
}
// Recover the instance from context->instance (slot 0 of rdpContext).
instance := readPtr(context, 0)
backend := lookupInstance(instance)
if backend == nil || backend.buffer == nil {
return 1
}
// With GDI mode enabled, the primary_buffer is already updated by FreeRDP's
// internal GDI renderer before this callback fires. We just need to mark
// the pixel buffer as dirty so the next GetFrame picks up changes.
//
// For the bitmap update path specifically, we parse the rectangles and
// copy each one from the GDI primary buffer into our PixelBuffer.
if backend.gdiReady && backend.gdiPrimaryBuf != 0 {
numRects := readU32(bitmapUpdate, 0)
if numRects > 0 {
backend.copyGdiToBuffer()
}
}
return 1 // TRUE
}
// ============================================================================
// FreeRDPBackend
// ============================================================================
// FreeRDPBackend implements RDPBackend using the FreeRDP3 library loaded at
// runtime via syscall.NewLazyDLL. This avoids any CGO dependency and allows
// cross-compilation from Linux while the DLLs are resolved at runtime on
// Windows.
type FreeRDPBackend struct {
instance uintptr // freerdp*
context uintptr // rdpContext* (extracted from instance->context)
settings uintptr // rdpSettings* (extracted from context->settings)
input uintptr // rdpInput* (extracted from context->input)
buffer *PixelBuffer
connected bool
config RDPConfig
mu sync.Mutex
stopCh chan struct{}
// GDI surface state — populated by PostConnect callback.
gdiPrimaryBuf uintptr // BYTE* — FreeRDP's GDI framebuffer
gdiWidth int
gdiHeight int
gdiStride int // bytes per row (may include padding)
gdiReady bool
}
// NewFreeRDPBackend creates a new FreeRDP-backed RDP backend. The underlying
// DLLs are not loaded until Connect is called.
func NewFreeRDPBackend() *FreeRDPBackend {
return &FreeRDPBackend{
stopCh: make(chan struct{}),
}
}
// Connect establishes an RDP session using FreeRDP3. It creates a new FreeRDP
// instance, configures connection settings, and starts the event loop.
//
// Initialization order (required by FreeRDP3):
// 1. freerdp_new() — allocate instance
// 2. Register callbacks — PreConnect, PostConnect on the instance struct
// 3. freerdp_context_new() — allocate context (also allocates settings, input, update)
// 4. Configure settings — via freerdp_settings_set_* on context->settings
// 5. freerdp_connect() — triggers PreConnect -> TCP -> PostConnect
// 6. Event loop — freerdp_check_event_handles in a goroutine
func (f *FreeRDPBackend) Connect(config RDPConfig) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.connected {
return fmt.Errorf("already connected")
}
f.config = config
// ── Step 1: Create a bare FreeRDP instance ──
ret, _, err := procFreerdpNew.Call()
if ret == 0 {
return fmt.Errorf("freerdp_new failed: %v", err)
}
f.instance = ret
// Register this instance in the global map so callbacks can find us.
registerInstance(f.instance, f)
// ── Step 2: Register callbacks on the instance struct ──
// PreConnect (slot 48): called before RDP negotiation, used to enable GDI.
writePtr(f.instance, freerdpOffsetPreConnect, preConnectCallbackPtr)
// PostConnect (slot 49): called after connection, sets up GDI rendering.
writePtr(f.instance, freerdpOffsetPostConnect, postConnectCallbackPtr)
// ── Step 3: Create the context ──
// freerdp_context_new(freerdp* instance) -> BOOL
// This allocates rdpContext and populates instance->context, including
// the settings, input, and update sub-objects.
ret, _, err = procContextNew.Call(f.instance)
if ret == 0 {
unregisterInstance(f.instance)
procFreerdpFree.Call(f.instance)
f.instance = 0
return fmt.Errorf("freerdp_context_new failed: %v", err)
}
// ── Extract context, settings, and input pointers ──
f.context = readPtr(f.instance, freerdpOffsetContext)
if f.context == 0 {
unregisterInstance(f.instance)
procFreerdpFree.Call(f.instance)
f.instance = 0
return fmt.Errorf("freerdp context is null after context_new")
}
f.settings = readPtr(f.context, contextOffsetSettings)
if f.settings == 0 {
unregisterInstance(f.instance)
procFreerdpFree.Call(f.instance)
f.instance = 0
return fmt.Errorf("freerdp settings is null after context_new")
}
f.input = readPtr(f.context, contextOffsetInput)
if f.input == 0 {
unregisterInstance(f.instance)
procFreerdpFree.Call(f.instance)
f.instance = 0
return fmt.Errorf("freerdp input is null after context_new")
}
// ── Step 4: Configure connection settings ──
f.setString(FreeRDP_ServerHostname, config.Hostname)
f.setUint32(FreeRDP_ServerPort, uint32(config.Port))
f.setString(FreeRDP_Username, config.Username)
f.setString(FreeRDP_Password, config.Password)
if config.Domain != "" {
f.setString(FreeRDP_Domain, config.Domain)
}
// Display settings with sensible defaults.
width := config.Width
if width == 0 {
width = 1920
}
height := config.Height
if height == 0 {
height = 1080
}
f.setUint32(FreeRDP_DesktopWidth, uint32(width))
f.setUint32(FreeRDP_DesktopHeight, uint32(height))
colorDepth := config.ColorDepth
if colorDepth == 0 {
colorDepth = 32
}
f.setUint32(FreeRDP_ColorDepth, uint32(colorDepth))
// Security mode selection.
switch config.Security {
case "nla":
f.setBool(FreeRDP_NlaSecurity, true)
case "tls":
f.setBool(FreeRDP_TlsSecurity, true)
case "rdp":
f.setBool(FreeRDP_RdpSecurity, true)
default:
f.setBool(FreeRDP_NlaSecurity, true)
}
// Accept all server certificates. Per-host pinning is a future enhancement.
f.setBool(FreeRDP_IgnoreCertificate, true)
// Enable software GDI rendering. This tells FreeRDP to decode all bitmap
// data into a memory framebuffer that we can read directly. The PreConnect
// callback also sets this, but we set it here as well for clarity —
// FreeRDP reads it during connection negotiation.
f.setBool(FreeRDP_SoftwareGdi, true)
// Allocate the pixel buffer for frame capture.
f.buffer = NewPixelBuffer(width, height)
// ── Step 5: Initiate the RDP connection ──
// freerdp_connect calls PreConnect -> TCP/TLS/NLA -> PostConnect internally.
ret, _, err = procFreerdpConnect.Call(f.instance)
if ret == 0 {
unregisterInstance(f.instance)
procFreerdpFree.Call(f.instance)
f.instance = 0
return fmt.Errorf("freerdp_connect failed: %v", err)
}
f.connected = true
// ── Step 6: Start the event processing loop ──
go f.eventLoop()
return nil
}
// eventLoop polls FreeRDP for incoming events at roughly 60 fps. It runs
// until the stop channel is closed or the connection is marked as inactive.
func (f *FreeRDPBackend) eventLoop() {
for {
select {
case <-f.stopCh:
return
default:
f.mu.Lock()
if !f.connected {
f.mu.Unlock()
return
}
// freerdp_check_event_handles takes rdpContext*, NOT freerdp*.
procCheckEventHandles.Call(f.context)
// After processing events, copy the GDI framebuffer into our
// PixelBuffer so GetFrame returns current data.
if f.gdiReady {
f.copyGdiToBuffer()
}
f.mu.Unlock()
time.Sleep(16 * time.Millisecond) // ~60 fps
}
}
}
// copyGdiToBuffer copies the FreeRDP GDI primary surface into the PixelBuffer.
// The GDI buffer is BGRA32; our PixelBuffer expects RGBA. We do the channel
// swap during the copy.
//
// Must be called with f.mu held.
func (f *FreeRDPBackend) copyGdiToBuffer() {
if f.gdiPrimaryBuf == 0 || f.buffer == nil {
return
}
w := f.gdiWidth
h := f.gdiHeight
stride := f.gdiStride
if stride == 0 {
stride = w * 4 // fallback: assume tightly packed BGRA32
}
// Total bytes in the GDI surface.
totalBytes := stride * h
if totalBytes <= 0 {
return
}
// Read the raw GDI framebuffer. This is a direct memory read from the
// FreeRDP-managed buffer. The GDI layer updates this buffer during
// BeginPaint/EndPaint cycles triggered by freerdp_check_event_handles.
src := unsafe.Slice((*byte)(unsafe.Pointer(f.gdiPrimaryBuf)), totalBytes)
// Build the RGBA frame, swapping B and R channels (BGRA -> RGBA).
buf := f.buffer
buf.mu.Lock()
defer buf.mu.Unlock()
dstLen := w * h * 4
if len(buf.Data) != dstLen {
buf.Data = make([]byte, dstLen)
buf.Width = w
buf.Height = h
}
for y := 0; y < h; y++ {
srcRow := y * stride
dstRow := y * w * 4
for x := 0; x < w; x++ {
si := srcRow + x*4
di := dstRow + x*4
if si+3 >= totalBytes || di+3 >= dstLen {
break
}
// BGRA -> RGBA
buf.Data[di+0] = src[si+2] // R <- B
buf.Data[di+1] = src[si+1] // G <- G
buf.Data[di+2] = src[si+0] // B <- R
buf.Data[di+3] = src[si+3] // A <- A
}
}
buf.dirty = true
}
// Disconnect tears down the RDP session and frees the FreeRDP instance.
func (f *FreeRDPBackend) Disconnect() error {
f.mu.Lock()
defer f.mu.Unlock()
if !f.connected {
return nil
}
close(f.stopCh)
f.connected = false
f.gdiReady = false
// Free GDI resources before disconnecting.
if f.gdiPrimaryBuf != 0 {
procGdiFree.Call(f.instance)
f.gdiPrimaryBuf = 0
}
procFreerdpDisconnect.Call(f.instance)
unregisterInstance(f.instance)
procFreerdpFree.Call(f.instance)
f.instance = 0
f.context = 0
f.settings = 0
f.input = 0
return nil
}
// SendMouseEvent forwards a mouse event to the remote RDP session.
func (f *FreeRDPBackend) SendMouseEvent(x, y int, flags uint32) error {
f.mu.Lock()
defer f.mu.Unlock()
if !f.connected || f.input == 0 {
return fmt.Errorf("not connected")
}
procInputSendMouse.Call(f.input, uintptr(flags), uintptr(x), uintptr(y))
return nil
}
// SendKeyEvent forwards a keyboard event to the remote RDP session. The
// scancode uses the same format as the ScancodeMap in input.go — extended
// keys have the 0xE0 prefix in the high byte.
func (f *FreeRDPBackend) SendKeyEvent(scancode uint32, pressed bool) error {
f.mu.Lock()
defer f.mu.Unlock()
if !f.connected || f.input == 0 {
return fmt.Errorf("not connected")
}
var flags uint32
if pressed {
flags = KBD_FLAGS_DOWN
} else {
flags = KBD_FLAGS_RELEASE
}
if scancode > 0xFF {
flags |= KBD_FLAGS_EXTENDED
}
procInputSendKeyboard.Call(f.input, uintptr(flags), uintptr(scancode&0xFF))
return nil
}
// SendClipboard sends clipboard text to the remote session.
// TODO: Implement via the FreeRDP cliprdr channel.
func (f *FreeRDPBackend) SendClipboard(data string) error {
return nil
}
// GetFrame returns the current full-frame RGBA pixel buffer. The frame is
// populated from the FreeRDP GDI primary surface during the event loop and
// via the PostConnect/BitmapUpdate callbacks.
func (f *FreeRDPBackend) GetFrame() ([]byte, error) {
if f.buffer == nil {
return nil, fmt.Errorf("no frame buffer")
}
return f.buffer.GetFrame(), nil
}
// IsConnected reports whether the backend has an active RDP connection.
func (f *FreeRDPBackend) IsConnected() bool {
f.mu.Lock()
defer f.mu.Unlock()
return f.connected
}
// setString sets a string setting on the FreeRDP settings object.
func (f *FreeRDPBackend) setString(id int, value string) {
b, _ := syscall.BytePtrFromString(value)
procSettingsSetString.Call(f.settings, uintptr(id), uintptr(unsafe.Pointer(b)))
}
// setUint32 sets a uint32 setting on the FreeRDP settings object.
func (f *FreeRDPBackend) setUint32(id int, value uint32) {
procSettingsSetUint32.Call(f.settings, uintptr(id), uintptr(value))
}
// setBool sets a boolean setting on the FreeRDP settings object.
func (f *FreeRDPBackend) setBool(id int, value bool) {
v := uintptr(0)
if value {
v = 1
}
procSettingsSetBool.Call(f.settings, uintptr(id), v)
}
// Ensure FreeRDPBackend satisfies the RDPBackend interface at compile time.
var _ RDPBackend = (*FreeRDPBackend)(nil)

View File

@ -1,189 +0,0 @@
package rdp
// RDP mouse event flags — these match the MS-RDPBCGR specification.
const (
MouseFlagMove uint32 = 0x0800 // Mouse moved (no button change)
MouseFlagButton1 uint32 = 0x1000 // Left button
MouseFlagButton2 uint32 = 0x2000 // Right button
MouseFlagButton3 uint32 = 0x4000 // Middle button
MouseFlagDown uint32 = 0x8000 // Button pressed (absence = released)
// Extended mouse flags for wheel events
MouseFlagWheel uint32 = 0x0200 // Vertical wheel rotation
MouseFlagWheelNeg uint32 = 0x0100 // Negative wheel direction (scroll down)
MouseFlagHWheel uint32 = 0x0400 // Horizontal wheel rotation
)
// ScancodeMap maps JavaScript KeyboardEvent.code strings to RDP hardware
// scancodes (Set 1 / XT scan codes). This covers the standard US 104-key
// layout. Extended keys (those with a 0xE0 prefix on the wire) have the
// high byte set to 0xE0.
//
// Reference: USB HID to PS/2 scancode mapping + MS-RDPBCGR 2.2.8.1.1.3.1.1.1
var ScancodeMap = map[string]uint32{
// ── Row 0: Escape + Function keys ──────────────────────────────
"Escape": 0x0001,
"F1": 0x003B,
"F2": 0x003C,
"F3": 0x003D,
"F4": 0x003E,
"F5": 0x003F,
"F6": 0x0040,
"F7": 0x0041,
"F8": 0x0042,
"F9": 0x0043,
"F10": 0x0044,
"F11": 0x0057,
"F12": 0x0058,
// ── Row 1: Number row ──────────────────────────────────────────
"Backquote": 0x0029, // ` ~
"Digit1": 0x0002,
"Digit2": 0x0003,
"Digit3": 0x0004,
"Digit4": 0x0005,
"Digit5": 0x0006,
"Digit6": 0x0007,
"Digit7": 0x0008,
"Digit8": 0x0009,
"Digit9": 0x000A,
"Digit0": 0x000B,
"Minus": 0x000C, // - _
"Equal": 0x000D, // = +
"Backspace": 0x000E,
// ── Row 2: QWERTY row ─────────────────────────────────────────
"Tab": 0x000F,
"KeyQ": 0x0010,
"KeyW": 0x0011,
"KeyE": 0x0012,
"KeyR": 0x0013,
"KeyT": 0x0014,
"KeyY": 0x0015,
"KeyU": 0x0016,
"KeyI": 0x0017,
"KeyO": 0x0018,
"KeyP": 0x0019,
"BracketLeft": 0x001A, // [ {
"BracketRight":0x001B, // ] }
"Backslash": 0x002B, // \ |
// ── Row 3: Home row ───────────────────────────────────────────
"CapsLock": 0x003A,
"KeyA": 0x001E,
"KeyS": 0x001F,
"KeyD": 0x0020,
"KeyF": 0x0021,
"KeyG": 0x0022,
"KeyH": 0x0023,
"KeyJ": 0x0024,
"KeyK": 0x0025,
"KeyL": 0x0026,
"Semicolon": 0x0027, // ; :
"Quote": 0x0028, // ' "
"Enter": 0x001C,
// ── Row 4: Bottom row ─────────────────────────────────────────
"ShiftLeft": 0x002A,
"KeyZ": 0x002C,
"KeyX": 0x002D,
"KeyC": 0x002E,
"KeyV": 0x002F,
"KeyB": 0x0030,
"KeyN": 0x0031,
"KeyM": 0x0032,
"Comma": 0x0033, // , <
"Period": 0x0034, // . >
"Slash": 0x0035, // / ?
"ShiftRight": 0x0036,
// ── Row 5: Bottom modifiers + space ───────────────────────────
"ControlLeft": 0x001D,
"MetaLeft": 0xE05B, // Left Windows / Super
"AltLeft": 0x0038,
"Space": 0x0039,
"AltRight": 0xE038, // Right Alt (extended)
"MetaRight": 0xE05C, // Right Windows / Super
"ContextMenu": 0xE05D, // Application / Menu key
"ControlRight": 0xE01D, // Right Ctrl (extended)
// ── Navigation cluster ────────────────────────────────────────
"PrintScreen": 0xE037,
"ScrollLock": 0x0046,
"Pause": 0x0045, // Note: Pause has special handling on wire
"Insert": 0xE052,
"Home": 0xE047,
"PageUp": 0xE049,
"Delete": 0xE053,
"End": 0xE04F,
"PageDown": 0xE051,
// ── Arrow keys ────────────────────────────────────────────────
"ArrowUp": 0xE048,
"ArrowLeft": 0xE04B,
"ArrowDown": 0xE050,
"ArrowRight": 0xE04D,
// ── Numpad ────────────────────────────────────────────────────
"NumLock": 0x0045,
"NumpadDivide": 0xE035,
"NumpadMultiply":0x0037,
"NumpadSubtract":0x004A,
"Numpad7": 0x0047,
"Numpad8": 0x0048,
"Numpad9": 0x0049,
"NumpadAdd": 0x004E,
"Numpad4": 0x004B,
"Numpad5": 0x004C,
"Numpad6": 0x004D,
"Numpad1": 0x004F,
"Numpad2": 0x0050,
"Numpad3": 0x0051,
"NumpadEnter": 0xE01C,
"Numpad0": 0x0052,
"NumpadDecimal": 0x0053,
// ── Multimedia / browser keys (common on 104+ key layouts) ───
"BrowserBack": 0xE06A,
"BrowserForward": 0xE069,
"BrowserRefresh": 0xE067,
"BrowserStop": 0xE068,
"BrowserSearch": 0xE065,
"BrowserFavorites":0xE066,
"BrowserHome": 0xE032,
"VolumeMute": 0xE020,
"VolumeDown": 0xE02E,
"VolumeUp": 0xE030,
"MediaTrackNext": 0xE019,
"MediaTrackPrevious":0xE010,
"MediaStop": 0xE024,
"MediaPlayPause": 0xE022,
"LaunchMail": 0xE06C,
"LaunchApp1": 0xE06B,
"LaunchApp2": 0xE021,
// ── International keys ────────────────────────────────────────
"IntlBackslash": 0x0056, // key between left Shift and Z on ISO keyboards
"IntlYen": 0x007D, // Yen key on Japanese keyboards
"IntlRo": 0x0073, // Ro key on Japanese keyboards
}
// JSKeyToScancode translates a JavaScript KeyboardEvent.code string to an
// RDP hardware scancode. Returns the scancode and true if a mapping exists,
// or 0 and false for unmapped keys.
func JSKeyToScancode(jsCode string) (uint32, bool) {
sc, ok := ScancodeMap[jsCode]
return sc, ok
}
// IsExtendedKey returns true if the scancode has the 0xE0 extended prefix.
// Extended keys require a two-byte sequence on the RDP wire.
func IsExtendedKey(scancode uint32) bool {
return (scancode & 0xFF00) == 0xE000
}
// ScancodeValue returns the low byte of the scancode (the actual scan code
// value without the extended prefix).
func ScancodeValue(scancode uint32) uint8 {
return uint8(scancode & 0xFF)
}

View File

@ -1,227 +0,0 @@
package rdp
import "testing"
func TestScancodeMapping(t *testing.T) {
tests := []struct {
jsCode string
expected uint32
}{
{"Escape", 0x0001},
{"Digit1", 0x0002},
{"Digit0", 0x000B},
{"KeyA", 0x001E},
{"KeyZ", 0x002C},
{"Enter", 0x001C},
{"Space", 0x0039},
{"Tab", 0x000F},
{"Backspace", 0x000E},
{"ShiftLeft", 0x002A},
{"ShiftRight", 0x0036},
{"ControlLeft", 0x001D},
{"ControlRight", 0xE01D},
{"AltLeft", 0x0038},
{"AltRight", 0xE038},
{"CapsLock", 0x003A},
{"F1", 0x003B},
{"F12", 0x0058},
{"ArrowUp", 0xE048},
{"ArrowDown", 0xE050},
{"ArrowLeft", 0xE04B},
{"ArrowRight", 0xE04D},
{"Insert", 0xE052},
{"Delete", 0xE053},
{"Home", 0xE047},
{"End", 0xE04F},
{"PageUp", 0xE049},
{"PageDown", 0xE051},
{"NumLock", 0x0045},
{"Numpad0", 0x0052},
{"Numpad9", 0x0049},
{"NumpadEnter", 0xE01C},
{"NumpadAdd", 0x004E},
{"NumpadSubtract", 0x004A},
{"NumpadMultiply", 0x0037},
{"NumpadDivide", 0xE035},
{"NumpadDecimal", 0x0053},
{"MetaLeft", 0xE05B},
{"MetaRight", 0xE05C},
{"ContextMenu", 0xE05D},
{"PrintScreen", 0xE037},
{"ScrollLock", 0x0046},
{"Backquote", 0x0029},
{"Minus", 0x000C},
{"Equal", 0x000D},
{"BracketLeft", 0x001A},
{"BracketRight", 0x001B},
{"Backslash", 0x002B},
{"Semicolon", 0x0027},
{"Quote", 0x0028},
{"Comma", 0x0033},
{"Period", 0x0034},
{"Slash", 0x0035},
}
for _, tt := range tests {
t.Run(tt.jsCode, func(t *testing.T) {
sc, ok := JSKeyToScancode(tt.jsCode)
if !ok {
t.Fatalf("JSKeyToScancode(%q) returned false", tt.jsCode)
}
if sc != tt.expected {
t.Errorf("JSKeyToScancode(%q) = 0x%04X, want 0x%04X", tt.jsCode, sc, tt.expected)
}
})
}
}
func TestAllLetterKeys(t *testing.T) {
// Verify all 26 letter keys are mapped
letters := "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
for _, ch := range letters {
code := "Key" + string(ch)
_, ok := JSKeyToScancode(code)
if !ok {
t.Errorf("missing mapping for %s", code)
}
}
}
func TestAllDigitKeys(t *testing.T) {
for i := 0; i <= 9; i++ {
code := "Digit" + string(rune('0'+i))
_, ok := JSKeyToScancode(code)
if !ok {
t.Errorf("missing mapping for %s", code)
}
}
}
func TestAllFunctionKeys(t *testing.T) {
fKeys := []string{"F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12"}
for _, key := range fKeys {
_, ok := JSKeyToScancode(key)
if !ok {
t.Errorf("missing mapping for %s", key)
}
}
}
func TestAllNumpadKeys(t *testing.T) {
numpadKeys := []string{
"Numpad0", "Numpad1", "Numpad2", "Numpad3", "Numpad4",
"Numpad5", "Numpad6", "Numpad7", "Numpad8", "Numpad9",
"NumpadAdd", "NumpadSubtract", "NumpadMultiply", "NumpadDivide",
"NumpadDecimal", "NumpadEnter", "NumLock",
}
for _, key := range numpadKeys {
_, ok := JSKeyToScancode(key)
if !ok {
t.Errorf("missing mapping for %s", key)
}
}
}
func TestUnknownKey(t *testing.T) {
sc, ok := JSKeyToScancode("FakeKey123")
if ok {
t.Errorf("unknown key returned ok=true with scancode 0x%04X", sc)
}
if sc != 0 {
t.Errorf("unknown key scancode = 0x%04X, want 0", sc)
}
}
func TestIsExtendedKey(t *testing.T) {
tests := []struct {
name string
scancode uint32
extended bool
}{
{"Escape (not extended)", 0x0001, false},
{"Enter (not extended)", 0x001C, false},
{"Right Ctrl (extended)", 0xE01D, true},
{"Right Alt (extended)", 0xE038, true},
{"ArrowUp (extended)", 0xE048, true},
{"Insert (extended)", 0xE052, true},
{"NumpadEnter (extended)", 0xE01C, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IsExtendedKey(tt.scancode)
if got != tt.extended {
t.Errorf("IsExtendedKey(0x%04X) = %v, want %v", tt.scancode, got, tt.extended)
}
})
}
}
func TestScancodeValue(t *testing.T) {
tests := []struct {
name string
scancode uint32
value uint8
}{
{"Escape", 0x0001, 0x01},
{"Right Ctrl", 0xE01D, 0x1D},
{"ArrowUp", 0xE048, 0x48},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ScancodeValue(tt.scancode)
if got != tt.value {
t.Errorf("ScancodeValue(0x%04X) = 0x%02X, want 0x%02X", tt.scancode, got, tt.value)
}
})
}
}
func TestMouseFlags(t *testing.T) {
// Verify the flag constants have the right values
if MouseFlagMove != 0x0800 {
t.Errorf("MouseFlagMove = 0x%04X, want 0x0800", MouseFlagMove)
}
if MouseFlagButton1 != 0x1000 {
t.Errorf("MouseFlagButton1 = 0x%04X, want 0x1000", MouseFlagButton1)
}
if MouseFlagButton2 != 0x2000 {
t.Errorf("MouseFlagButton2 = 0x%04X, want 0x2000", MouseFlagButton2)
}
if MouseFlagButton3 != 0x4000 {
t.Errorf("MouseFlagButton3 = 0x%04X, want 0x4000", MouseFlagButton3)
}
if MouseFlagDown != 0x8000 {
t.Errorf("MouseFlagDown = 0x%04X, want 0x8000", MouseFlagDown)
}
// Test combining flags — left click down
flags := MouseFlagButton1 | MouseFlagDown
if flags != 0x9000 {
t.Errorf("left click down = 0x%04X, want 0x9000", flags)
}
// Test combining flags — right click up (no Down flag)
flags = MouseFlagButton2
if flags != 0x2000 {
t.Errorf("right click up = 0x%04X, want 0x2000", flags)
}
}
func TestScancodeMapCompleteness(t *testing.T) {
// Minimum expected mappings for a standard 104-key US keyboard:
// 26 letters + 10 digits + 12 F-keys + escape + tab + caps + 2 shifts +
// 2 ctrls + 2 alts + 2 metas + space + enter + backspace +
// numrow punctuation (backquote, minus, equal) +
// bracket pair + backslash + semicolon + quote + comma + period + slash +
// 4 arrows + insert + delete + home + end + pageup + pagedown +
// printscreen + scrolllock + pause +
// numlock + numpad 0-9 + numpad operators (5) + numpad enter + numpad decimal +
// context menu
// = 26+10+12+1+1+1+2+2+2+2+1+1+1+3+2+1+1+1+1+1+1+4+6+3+1+10+5+1+1+1 = ~104
minExpected := 90 // conservative lower bound for core keys
if len(ScancodeMap) < minExpected {
t.Errorf("ScancodeMap has %d entries, expected at least %d", len(ScancodeMap), minExpected)
}
}

View File

@ -1,313 +0,0 @@
package rdp
import (
"fmt"
"math"
"sync"
"time"
)
// MockBackend implements the RDPBackend interface for development and testing.
// Instead of connecting to a real RDP server, it generates animated test frames
// with a gradient pattern, moving shapes, and a timestamp overlay.
type MockBackend struct {
connected bool
config RDPConfig
buffer *PixelBuffer
mu sync.Mutex
startTime time.Time
clipboard string
}
// NewMockBackend creates a new MockBackend instance.
func NewMockBackend() *MockBackend {
return &MockBackend{}
}
// Connect initializes the mock backend with the given configuration and starts
// generating test frames.
func (m *MockBackend) Connect(config RDPConfig) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.connected {
return fmt.Errorf("already connected")
}
width := config.Width
height := config.Height
if width <= 0 {
width = 1920
}
if height <= 0 {
height = 1080
}
m.config = config
m.config.Width = width
m.config.Height = height
m.buffer = NewPixelBuffer(width, height)
m.connected = true
m.startTime = time.Now()
// Generate the initial test frame
m.generateFrame()
return nil
}
// Disconnect shuts down the mock backend.
func (m *MockBackend) Disconnect() error {
m.mu.Lock()
defer m.mu.Unlock()
if !m.connected {
return fmt.Errorf("not connected")
}
m.connected = false
m.buffer = nil
return nil
}
// SendMouseEvent records a mouse event (no-op for mock).
func (m *MockBackend) SendMouseEvent(x, y int, flags uint32) error {
m.mu.Lock()
defer m.mu.Unlock()
if !m.connected {
return fmt.Errorf("not connected")
}
// In the mock, we draw a cursor indicator at the mouse position
if m.buffer != nil && flags&MouseFlagMove != 0 {
m.drawCursor(x, y)
}
return nil
}
// SendKeyEvent records a key event (no-op for mock).
func (m *MockBackend) SendKeyEvent(scancode uint32, pressed bool) error {
m.mu.Lock()
defer m.mu.Unlock()
if !m.connected {
return fmt.Errorf("not connected")
}
return nil
}
// SendClipboard stores clipboard text (mock implementation).
func (m *MockBackend) SendClipboard(data string) error {
m.mu.Lock()
defer m.mu.Unlock()
if !m.connected {
return fmt.Errorf("not connected")
}
m.clipboard = data
return nil
}
// GetFrame returns the current test frame. Each call regenerates the frame
// with an updated animation state so the renderer can verify dynamic updates.
func (m *MockBackend) GetFrame() ([]byte, error) {
m.mu.Lock()
defer m.mu.Unlock()
if !m.connected {
return nil, fmt.Errorf("not connected")
}
m.generateFrame()
return m.buffer.GetFrame(), nil
}
// IsConnected reports whether the mock backend is connected.
func (m *MockBackend) IsConnected() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.connected
}
// generateFrame creates a visually interesting test frame with:
// - A blue-to-purple diagonal gradient background
// - An animated bouncing rectangle
// - A grid overlay for alignment verification
// - Session info text area
func (m *MockBackend) generateFrame() {
w := m.config.Width
h := m.config.Height
elapsed := time.Since(m.startTime).Seconds()
data := make([]byte, w*h*4)
// ── Background: diagonal gradient from dark blue to dark purple ──
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
offset := (y*w + x) * 4
// Normalized coordinates
nx := float64(x) / float64(w)
ny := float64(y) / float64(h)
diag := (nx + ny) / 2.0
r := uint8(20 + diag*40) // 2060
g := uint8(25 + (1.0-diag)*30) // 2555
b := uint8(80 + diag*100) // 80180
data[offset+0] = r
data[offset+1] = g
data[offset+2] = b
data[offset+3] = 255
}
}
// ── Grid overlay: subtle lines every 100 pixels ──
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
if x%100 == 0 || y%100 == 0 {
offset := (y*w + x) * 4
data[offset+0] = min8(data[offset+0]+20, 255)
data[offset+1] = min8(data[offset+1]+20, 255)
data[offset+2] = min8(data[offset+2]+20, 255)
}
}
}
// ── Animated bouncing rectangle ──
rectW, rectH := 200, 120
// Bounce horizontally and vertically using sine/cosine
cx := int(float64(w-rectW) * (0.5 + 0.4*math.Sin(elapsed*0.7)))
cy := int(float64(h-rectH) * (0.5 + 0.4*math.Cos(elapsed*0.5)))
// Color cycles through hues
hue := math.Mod(elapsed*30, 360)
rr, gg, bb := hueToRGB(hue)
for ry := 0; ry < rectH; ry++ {
for rx := 0; rx < rectW; rx++ {
px := cx + rx
py := cy + ry
if px >= 0 && px < w && py >= 0 && py < h {
offset := (py*w + px) * 4
// Border: 3px white outline
if rx < 3 || rx >= rectW-3 || ry < 3 || ry >= rectH-3 {
data[offset+0] = 255
data[offset+1] = 255
data[offset+2] = 255
data[offset+3] = 255
} else {
data[offset+0] = rr
data[offset+1] = gg
data[offset+2] = bb
data[offset+3] = 220
}
}
}
}
// ── Info panel in top-left corner ──
panelW, panelH := 320, 80
for py := 10; py < 10+panelH && py < h; py++ {
for px := 10; px < 10+panelW && px < w; px++ {
offset := (py*w + px) * 4
data[offset+0] = 0
data[offset+1] = 0
data[offset+2] = 0
data[offset+3] = 180
}
}
// ── Secondary animated element: pulsing circle ──
circleX := w / 2
circleY := h / 2
radius := 40.0 + 20.0*math.Sin(elapsed*2.0)
for dy := -70; dy <= 70; dy++ {
for dx := -70; dx <= 70; dx++ {
dist := math.Sqrt(float64(dx*dx + dy*dy))
if dist <= radius && dist >= radius-4 {
px := circleX + dx
py := circleY + dy
if px >= 0 && px < w && py >= 0 && py < h {
offset := (py*w + px) * 4
data[offset+0] = 88
data[offset+1] = 166
data[offset+2] = 255
data[offset+3] = 255
}
}
}
}
// Apply as a full-frame update
m.buffer.Update(0, 0, w, h, data)
}
// drawCursor draws a small crosshair at the given position.
func (m *MockBackend) drawCursor(cx, cy int) {
size := 10
w := m.config.Width
h := m.config.Height
cursorData := make([]byte, (size*2+1)*(size*2+1)*4)
idx := 0
for dy := -size; dy <= size; dy++ {
for dx := -size; dx <= size; dx++ {
if dx == 0 || dy == 0 {
cursorData[idx+0] = 255
cursorData[idx+1] = 255
cursorData[idx+2] = 0
cursorData[idx+3] = 200
}
idx += 4
}
}
startX := cx - size
startY := cy - size
if startX < 0 {
startX = 0
}
if startY < 0 {
startY = 0
}
_ = w
_ = h
m.buffer.Update(startX, startY, size*2+1, size*2+1, cursorData)
}
// hueToRGB converts a hue angle (0-360) to RGB values.
func hueToRGB(hue float64) (uint8, uint8, uint8) {
h := math.Mod(hue, 360) / 60
c := 200.0 // chroma
x := c * (1 - math.Abs(math.Mod(h, 2)-1))
var r, g, b float64
switch {
case h < 1:
r, g, b = c, x, 0
case h < 2:
r, g, b = x, c, 0
case h < 3:
r, g, b = 0, c, x
case h < 4:
r, g, b = 0, x, c
case h < 5:
r, g, b = x, 0, c
default:
r, g, b = c, 0, x
}
return uint8(r + 55), uint8(g + 55), uint8(b + 55)
}
// min8 returns the smaller of two uint8 values.
func min8(a, b uint8) uint8 {
if a < b {
return a
}
return b
}

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