waveterm/pkg/waveattach/auth.go
dfbb e64d1feb0f feat(wsh): add attach command for read-only terminal observation
Adds `wsh attach` — a command that streams the live output of any Wave
Terminal block to a local terminal window without affecting the remote
session. Useful for monitoring long-running processes, CI jobs, or AI
coding agents from a separate window or SSH session.

Key capabilities:
- Interactive block selector (workspace → tab → block)
- Live PTY streaming via snapshot + WPS event subscription
- Viewport model: server PTY size is fixed; local terminal is a
  moveable window into the remote screen (Ctrl+Arrow to pan)
- Diff-based renderer that emits only changed cells per frame,
  with full SGR, wide-character, alt-screen, and cursor-style sync
- Debounced render loop (16 ms) coalesces rapid PTY bursts so that
  full-screen TUI repaints are always consumed before rendering
- Resync command (Ctrl-A s) rebuilds xterm-go state from a fresh
  snapshot when local state drifts from the remote

Bug fix included: EventRecv messages are now dispatched synchronously
in the WshRpc message loop (same pattern as StreamData/StreamDataAck)
so that back-to-back PTY events are always processed in arrival order.
Without this fix, concurrent goroutines race to write PTY chunks into
the terminal emulator, producing mixed-frame garbling.
2026-05-01 23:08:15 +08:00

143 lines
4.2 KiB
Go

// Copyright 2026, Command Line Inc.
// SPDX-License-Identifier: Apache-2.0
package waveattach
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "modernc.org/sqlite"
"github.com/wavetermdev/waveterm/pkg/wavejwt"
"github.com/wavetermdev/waveterm/pkg/wshrpc"
"github.com/wavetermdev/waveterm/pkg/wshrpc/wshclient"
"github.com/wavetermdev/waveterm/pkg/wshutil"
)
const (
dbSubdir = "db"
dbFileName = "waveterm.db"
socketFileName = "wave.sock"
)
func ResolveDataDir() (string, error) {
if v := os.Getenv("WAVETERM_DATA_HOME"); v != "" {
return v, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("cannot resolve home dir: %w", err)
}
var candidates []string
switch runtime.GOOS {
case "darwin":
candidates = []string{
filepath.Join(home, "Library", "Application Support", "waveterm"),
filepath.Join(home, "Library", "Application Support", "waveterm-dev"),
filepath.Join(home, ".waveterm"),
filepath.Join(home, ".waveterm-dev"),
}
case "linux":
xdgData := os.Getenv("XDG_DATA_HOME")
if xdgData == "" {
xdgData = filepath.Join(home, ".local", "share")
}
candidates = []string{
filepath.Join(xdgData, "waveterm"),
filepath.Join(xdgData, "waveterm-dev"),
filepath.Join(home, ".waveterm"),
filepath.Join(home, ".waveterm-dev"),
}
default:
candidates = []string{
filepath.Join(home, ".waveterm"),
filepath.Join(home, ".waveterm-dev"),
}
}
for _, candidate := range candidates {
if st, err := os.Stat(candidate); err == nil && st.IsDir() {
return candidate, nil
}
}
return "", fmt.Errorf("Wave data directory not found. Is Wave running? (set $WAVETERM_DATA_HOME to override)")
}
func loadJwtPrivateKey(dataDir string) (ed25519.PrivateKey, error) {
dbPath := filepath.Join(dataDir, dbSubdir, dbFileName)
if _, err := os.Stat(dbPath); err != nil {
return nil, fmt.Errorf("Wave database not found at %s: %w", dbPath, err)
}
dsn := fmt.Sprintf("file:%s?mode=ro&_journal_mode=WAL&_busy_timeout=5000", dbPath)
db, err := sqlx.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("opening wave db: %w", err)
}
defer db.Close()
var rawJSON string
if err := db.Get(&rawJSON, "SELECT data FROM db_mainserver LIMIT 1"); err != nil {
return nil, fmt.Errorf("querying db_mainserver (Wave schema may have changed): %w", err)
}
var ms struct {
JwtPrivateKey string `json:"jwtprivatekey"`
}
if err := json.Unmarshal([]byte(rawJSON), &ms); err != nil {
return nil, fmt.Errorf("parsing mainserver JSON: %w", err)
}
if ms.JwtPrivateKey == "" {
return nil, fmt.Errorf("jwtprivatekey is empty in db_mainserver")
}
keyBytes, err := base64.StdEncoding.DecodeString(ms.JwtPrivateKey)
if err != nil {
return nil, fmt.Errorf("base64 decoding jwt private key: %w", err)
}
if len(keyBytes) != ed25519.PrivateKeySize {
return nil, fmt.Errorf("jwt private key has wrong length: got %d, want %d", len(keyBytes), ed25519.PrivateKeySize)
}
return ed25519.PrivateKey(keyBytes), nil
}
func Connect() (*wshutil.WshRpc, string, error) {
dataDir, err := ResolveDataDir()
if err != nil {
return nil, "", err
}
sockPath := filepath.Join(dataDir, socketFileName)
if _, err := os.Stat(sockPath); err != nil {
return nil, "", fmt.Errorf("Wave socket not found at %s: %w", sockPath, err)
}
privKey, err := loadJwtPrivateKey(dataDir)
if err != nil {
return nil, "", err
}
if err := wavejwt.SetPrivateKey([]byte(privKey)); err != nil {
return nil, "", fmt.Errorf("setting jwt private key: %w", err)
}
routeId := "waveattach-" + uuid.NewString()
rpcCtx := wshrpc.RpcContext{
SockName: sockPath,
RouteId: routeId,
}
jwtToken, err := wshutil.MakeClientJWTToken(rpcCtx)
if err != nil {
return nil, "", fmt.Errorf("creating jwt: %w", err)
}
rpcClient, err := wshutil.SetupDomainSocketRpcClient(sockPath, nil, "waveattach")
if err != nil {
return nil, "", fmt.Errorf("connecting to %s: %w", sockPath, err)
}
authRtn, err := wshclient.AuthenticateCommand(rpcClient, jwtToken, &wshrpc.RpcOpts{Route: wshutil.ControlRoute})
if err != nil {
return nil, "", fmt.Errorf("authenticating: %w", err)
}
return rpcClient, authRtn.RouteId, nil
}