2214 lines
64 KiB
Markdown
2214 lines
64 KiB
Markdown
# Phase 2: muxterm Go Binary — Implementation Plan
|
|
|
|
> **Execution:** Use the subagent-driven-development workflow to implement this plan.
|
|
|
|
**Goal:** Replace the ttyd process pool + Python WebSocket proxy with a single Go binary (`muxterm`) that owns the entire terminal data path — one process, one WebSocket hop, direct PTY ownership, server-side session switching.
|
|
|
|
**Architecture:** muxterm is a Go binary that manages a map of tmux sessions → PTYs. The browser connects directly to muxterm's WebSocket after getting a short-lived HMAC token from Python. Binary frames carry raw terminal I/O; text frames carry JSON control messages (attach, resize, detach). Python's only roles are issuing auth tokens and supervising the muxterm process.
|
|
|
|
**Tech Stack:** Go (creack/pty, gorilla/websocket), Python FastAPI, vanilla JS frontend.
|
|
|
|
**Assumes:** Phase 1 (ghostty-web swap) is already done. The frontend uses ghostty-web, not xterm.js.
|
|
|
|
---
|
|
|
|
## Group A — Go Binary Core (`muxterm/`)
|
|
|
|
### Task 1: Scaffold Go Module
|
|
|
|
**Files:**
|
|
- Create: `muxterm/go.mod`
|
|
- Create: `muxterm/main.go`
|
|
|
|
**Step 1: Create the Go module**
|
|
|
|
Create `muxterm/go.mod`:
|
|
|
|
```go
|
|
module github.com/muxplex/muxterm
|
|
|
|
go 1.22
|
|
|
|
require (
|
|
github.com/creack/pty v1.1.24
|
|
github.com/gorilla/websocket v1.5.3
|
|
)
|
|
```
|
|
|
|
**Step 2: Create the main.go entry point**
|
|
|
|
Create `muxterm/main.go`:
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
)
|
|
|
|
var (
|
|
listenAddr = flag.String("addr", "127.0.0.1:7682", "listen address")
|
|
secret = flag.String("secret", "", "HMAC shared secret for token validation")
|
|
)
|
|
|
|
func main() {
|
|
flag.Parse()
|
|
|
|
if *secret == "" {
|
|
// Also check env var
|
|
if s := os.Getenv("MUXTERM_SECRET"); s != "" {
|
|
*secret = s
|
|
} else {
|
|
fmt.Fprintln(os.Stderr, "muxterm: --secret or MUXTERM_SECRET required")
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
pool := NewPool()
|
|
|
|
// Graceful shutdown on SIGTERM/SIGINT
|
|
sigCh := make(chan os.Signal, 1)
|
|
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
|
|
go func() {
|
|
<-sigCh
|
|
log.Println("muxterm: shutting down")
|
|
pool.CloseAll()
|
|
os.Exit(0)
|
|
}()
|
|
|
|
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
|
|
HandleWebSocket(pool, *secret, w, r)
|
|
})
|
|
|
|
// Health check endpoint
|
|
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(200)
|
|
w.Write([]byte(`{"status":"ok"}`))
|
|
})
|
|
|
|
log.Printf("muxterm: listening on %s", *listenAddr)
|
|
if err := http.ListenAndServe(*listenAddr, nil); err != nil {
|
|
log.Fatalf("muxterm: %v", err)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Download dependencies**
|
|
|
|
Run:
|
|
```bash
|
|
cd muxterm && go mod tidy
|
|
```
|
|
Expected: `go.sum` created, dependencies resolved.
|
|
|
|
**Step 4: Verify it compiles (will fail with missing symbols — that's expected)**
|
|
|
|
Run:
|
|
```bash
|
|
cd muxterm && go build ./... 2>&1 || echo "Expected: fails on missing Pool/HandleWebSocket"
|
|
```
|
|
Expected: Compilation errors for undefined `NewPool` and `HandleWebSocket` — confirming the scaffold is structurally valid.
|
|
|
|
**Step 5: Commit**
|
|
```bash
|
|
git add muxterm/ && git commit -m "feat(muxterm): scaffold Go module with main.go entry point"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: PTY Pool — Attach, Detach, Cleanup
|
|
|
|
**Files:**
|
|
- Create: `muxterm/pool.go`
|
|
|
|
**Step 1: Write pool.go**
|
|
|
|
Create `muxterm/pool.go`:
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"log"
|
|
"os"
|
|
"os/exec"
|
|
"sync"
|
|
"syscall"
|
|
|
|
"github.com/creack/pty"
|
|
)
|
|
|
|
// Session holds a PTY file descriptor and the child process for one tmux attach.
|
|
type Session struct {
|
|
PTY *os.File
|
|
Cmd *exec.Cmd
|
|
Cols uint16
|
|
Rows uint16
|
|
}
|
|
|
|
// Pool manages a map of session names to live PTY sessions.
|
|
type Pool struct {
|
|
mu sync.Mutex
|
|
sessions map[string]*Session
|
|
}
|
|
|
|
// NewPool creates an empty session pool.
|
|
func NewPool() *Pool {
|
|
return &Pool{sessions: make(map[string]*Session)}
|
|
}
|
|
|
|
// Attach returns the session for name, spawning `tmux attach -t name` if needed.
|
|
// cols and rows set the initial PTY size.
|
|
func (p *Pool) Attach(name string, cols, rows uint16) (*Session, error) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
// Reuse existing session if process is still alive
|
|
if s, ok := p.sessions[name]; ok {
|
|
if s.Cmd.ProcessState == nil {
|
|
// Process hasn't exited — check if it's still running
|
|
if err := s.Cmd.Process.Signal(syscall.Signal(0)); err == nil {
|
|
return s, nil
|
|
}
|
|
}
|
|
// Dead session — clean up
|
|
s.PTY.Close()
|
|
delete(p.sessions, name)
|
|
}
|
|
|
|
cmd := exec.Command("tmux", "attach", "-t", name)
|
|
winSize := &pty.Winsize{Cols: cols, Rows: rows}
|
|
ptmx, err := pty.StartWithSize(cmd, winSize)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s := &Session{
|
|
PTY: ptmx,
|
|
Cmd: cmd,
|
|
Cols: cols,
|
|
Rows: rows,
|
|
}
|
|
p.sessions[name] = s
|
|
|
|
// Background goroutine: wait for process exit and clean up
|
|
go func() {
|
|
_ = cmd.Wait()
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
// Only delete if this is still the same session (not replaced)
|
|
if current, ok := p.sessions[name]; ok && current == s {
|
|
log.Printf("muxterm: session %q exited", name)
|
|
ptmx.Close()
|
|
delete(p.sessions, name)
|
|
}
|
|
}()
|
|
|
|
return s, nil
|
|
}
|
|
|
|
// Resize changes the PTY window size for the named session.
|
|
func (p *Pool) Resize(name string, cols, rows uint16) error {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
s, ok := p.sessions[name]
|
|
if !ok {
|
|
return nil // no-op if session doesn't exist
|
|
}
|
|
|
|
s.Cols = cols
|
|
s.Rows = rows
|
|
return pty.Setsize(s.PTY, &pty.Winsize{Cols: cols, Rows: rows})
|
|
}
|
|
|
|
// Get returns the session for name, or nil if not attached.
|
|
func (p *Pool) Get(name string) *Session {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
return p.sessions[name]
|
|
}
|
|
|
|
// IsAlive checks if a session's process is still running.
|
|
func (p *Pool) IsAlive(name string) bool {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
s, ok := p.sessions[name]
|
|
if !ok {
|
|
return false
|
|
}
|
|
if s.Cmd.ProcessState != nil {
|
|
return false
|
|
}
|
|
return s.Cmd.Process.Signal(syscall.Signal(0)) == nil
|
|
}
|
|
|
|
// CloseAll terminates all sessions. Called on graceful shutdown.
|
|
func (p *Pool) CloseAll() {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
for name, s := range p.sessions {
|
|
log.Printf("muxterm: closing session %q", name)
|
|
_ = s.Cmd.Process.Signal(syscall.SIGTERM)
|
|
s.PTY.Close()
|
|
}
|
|
p.sessions = make(map[string]*Session)
|
|
}
|
|
```
|
|
|
|
**Step 2: Verify it compiles**
|
|
|
|
Run:
|
|
```bash
|
|
cd muxterm && go build ./... 2>&1 || echo "Expected: fails on missing HandleWebSocket only"
|
|
```
|
|
Expected: Only `HandleWebSocket` undefined — pool compiles.
|
|
|
|
**Step 3: Commit**
|
|
```bash
|
|
git add muxterm/pool.go && git commit -m "feat(muxterm): PTY pool with attach, resize, cleanup"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: Wire Protocol — Binary I/O + JSON Control
|
|
|
|
**Files:**
|
|
- Create: `muxterm/protocol.go`
|
|
|
|
**Step 1: Write protocol.go**
|
|
|
|
Create `muxterm/protocol.go`:
|
|
|
|
```go
|
|
package main
|
|
|
|
import "encoding/json"
|
|
|
|
// Control message types — client to server
|
|
|
|
// AttachMsg requests switching to a session.
|
|
type AttachMsg struct {
|
|
Attach string `json:"attach"`
|
|
}
|
|
|
|
// ResizeMsg requests resizing the active PTY.
|
|
type ResizeMsg struct {
|
|
Resize struct {
|
|
Cols int `json:"cols"`
|
|
Rows int `json:"rows"`
|
|
} `json:"resize"`
|
|
}
|
|
|
|
// DetachMsg requests detaching (keep PTY alive).
|
|
type DetachMsg struct {
|
|
Detach bool `json:"detach"`
|
|
}
|
|
|
|
// Control message types — server to client
|
|
|
|
// AttachedMsg confirms a session switch.
|
|
type AttachedMsg struct {
|
|
Attached string `json:"attached"`
|
|
}
|
|
|
|
// ErrorMsg signals a failure.
|
|
type ErrorMsg struct {
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
// ExitedMsg signals a session death.
|
|
type ExitedMsg struct {
|
|
Exited string `json:"exited"`
|
|
}
|
|
|
|
// ParseControlMessage parses a text frame JSON into one of the control types.
|
|
// Returns the parsed type and value. Unknown messages return ("unknown", nil).
|
|
func ParseControlMessage(data []byte) (string, interface{}) {
|
|
var raw map[string]json.RawMessage
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
return "invalid", nil
|
|
}
|
|
|
|
if _, ok := raw["attach"]; ok {
|
|
var msg AttachMsg
|
|
if err := json.Unmarshal(data, &msg); err == nil {
|
|
return "attach", msg
|
|
}
|
|
}
|
|
|
|
if _, ok := raw["resize"]; ok {
|
|
var msg ResizeMsg
|
|
if err := json.Unmarshal(data, &msg); err == nil {
|
|
return "resize", msg
|
|
}
|
|
}
|
|
|
|
if _, ok := raw["detach"]; ok {
|
|
var msg DetachMsg
|
|
if err := json.Unmarshal(data, &msg); err == nil {
|
|
return "detach", msg
|
|
}
|
|
}
|
|
|
|
return "unknown", nil
|
|
}
|
|
|
|
// MarshalAttached creates a JSON text frame for attach confirmation.
|
|
func MarshalAttached(session string) []byte {
|
|
b, _ := json.Marshal(AttachedMsg{Attached: session})
|
|
return b
|
|
}
|
|
|
|
// MarshalError creates a JSON text frame for an error.
|
|
func MarshalError(msg string) []byte {
|
|
b, _ := json.Marshal(ErrorMsg{Error: msg})
|
|
return b
|
|
}
|
|
|
|
// MarshalExited creates a JSON text frame for session exit.
|
|
func MarshalExited(session string) []byte {
|
|
b, _ := json.Marshal(ExitedMsg{Exited: session})
|
|
return b
|
|
}
|
|
```
|
|
|
|
**Step 2: Verify it compiles**
|
|
|
|
Run:
|
|
```bash
|
|
cd muxterm && go build ./... 2>&1 || echo "Expected: fails on missing HandleWebSocket only"
|
|
```
|
|
Expected: Only `HandleWebSocket` undefined.
|
|
|
|
**Step 3: Commit**
|
|
```bash
|
|
git add muxterm/protocol.go && git commit -m "feat(muxterm): wire protocol types and parser"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: WebSocket Server — Token Auth + Relay Goroutines
|
|
|
|
**Files:**
|
|
- Create: `muxterm/ws.go`
|
|
|
|
**Step 1: Write ws.go**
|
|
|
|
Create `muxterm/ws.go`:
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
var upgrader = websocket.Upgrader{
|
|
CheckOrigin: func(r *http.Request) bool { return true }, // localhost only
|
|
}
|
|
|
|
// ValidateToken checks an HMAC-SHA256 token: hex(hmac(secret, timestamp)) + "." + timestamp.
|
|
// Returns true if the signature is valid and the token is within ttl seconds of now.
|
|
func ValidateToken(token, secret string, ttl int64) bool {
|
|
parts := strings.SplitN(token, ".", 2)
|
|
if len(parts) != 2 {
|
|
return false
|
|
}
|
|
sig, tsStr := parts[0], parts[1]
|
|
|
|
ts, err := strconv.ParseInt(tsStr, 10, 64)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
// Check TTL
|
|
now := time.Now().Unix()
|
|
if now-ts > ttl || ts-now > 5 { // 5s future tolerance for clock skew
|
|
return false
|
|
}
|
|
|
|
// Verify HMAC
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
mac.Write([]byte(tsStr))
|
|
expected := hex.EncodeToString(mac.Sum(nil))
|
|
|
|
return hmac.Equal([]byte(sig), []byte(expected))
|
|
}
|
|
|
|
// HandleWebSocket upgrades the HTTP connection and manages the terminal relay.
|
|
func HandleWebSocket(pool *Pool, secret string, w http.ResponseWriter, r *http.Request) {
|
|
// Validate auth token from query parameter
|
|
token := r.URL.Query().Get("token")
|
|
if token == "" || !ValidateToken(token, secret, 30) {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
conn, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
log.Printf("muxterm: upgrade failed: %v", err)
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
|
|
log.Printf("muxterm: client connected from %s", r.RemoteAddr)
|
|
|
|
var (
|
|
activeSession string
|
|
activePTY *Session
|
|
ptyCancel chan struct{} // signals PTY→WS goroutine to stop
|
|
mu sync.Mutex // protects activeSession/activePTY/ptyCancel
|
|
)
|
|
|
|
// stopRelay cancels the current PTY→WS relay goroutine
|
|
stopRelay := func() {
|
|
if ptyCancel != nil {
|
|
close(ptyCancel)
|
|
ptyCancel = nil
|
|
}
|
|
}
|
|
|
|
// startRelay spawns a goroutine that reads from PTY and writes binary frames to WS
|
|
startRelay := func(s *Session, cancel chan struct{}) {
|
|
go func() {
|
|
buf := make([]byte, 32*1024)
|
|
for {
|
|
select {
|
|
case <-cancel:
|
|
return
|
|
default:
|
|
}
|
|
n, err := s.PTY.Read(buf)
|
|
if err != nil {
|
|
if err != io.EOF {
|
|
select {
|
|
case <-cancel:
|
|
return // expected — relay was stopped
|
|
default:
|
|
}
|
|
}
|
|
// PTY closed — session exited
|
|
mu.Lock()
|
|
sessionName := activeSession
|
|
mu.Unlock()
|
|
if sessionName != "" {
|
|
conn.WriteMessage(websocket.TextMessage, MarshalExited(sessionName))
|
|
}
|
|
return
|
|
}
|
|
if err := conn.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil {
|
|
return // WS write failed — client disconnected
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
defer func() {
|
|
mu.Lock()
|
|
stopRelay()
|
|
mu.Unlock()
|
|
}()
|
|
|
|
// Main read loop — handles text (control) and binary (input) frames
|
|
for {
|
|
msgType, data, err := conn.ReadMessage()
|
|
if err != nil {
|
|
break // client disconnected
|
|
}
|
|
|
|
switch msgType {
|
|
case websocket.TextMessage:
|
|
kind, val := ParseControlMessage(data)
|
|
switch kind {
|
|
case "attach":
|
|
msg := val.(AttachMsg)
|
|
mu.Lock()
|
|
// Stop existing relay
|
|
stopRelay()
|
|
|
|
s, err := pool.Attach(msg.Attach, 80, 24) // default size, will resize
|
|
if err != nil {
|
|
mu.Unlock()
|
|
log.Printf("muxterm: attach %q failed: %v", msg.Attach, err)
|
|
conn.WriteMessage(websocket.TextMessage,
|
|
MarshalError(fmt.Sprintf("session not found: %s", msg.Attach)))
|
|
continue
|
|
}
|
|
|
|
activeSession = msg.Attach
|
|
activePTY = s
|
|
ptyCancel = make(chan struct{})
|
|
|
|
// SIGWINCH to trigger tmux repaint
|
|
sendSIGWINCH(s)
|
|
|
|
startRelay(s, ptyCancel)
|
|
mu.Unlock()
|
|
|
|
conn.WriteMessage(websocket.TextMessage, MarshalAttached(msg.Attach))
|
|
log.Printf("muxterm: attached to %q", msg.Attach)
|
|
|
|
case "resize":
|
|
msg := val.(ResizeMsg)
|
|
mu.Lock()
|
|
if activeSession != "" {
|
|
pool.Resize(activeSession, uint16(msg.Resize.Cols), uint16(msg.Resize.Rows))
|
|
}
|
|
mu.Unlock()
|
|
|
|
case "detach":
|
|
mu.Lock()
|
|
stopRelay()
|
|
activeSession = ""
|
|
activePTY = nil
|
|
mu.Unlock()
|
|
|
|
default:
|
|
log.Printf("muxterm: unknown control message: %s", string(data))
|
|
}
|
|
|
|
case websocket.BinaryMessage:
|
|
// Raw terminal input — write to active PTY
|
|
mu.Lock()
|
|
if activePTY != nil {
|
|
activePTY.PTY.Write(data)
|
|
}
|
|
mu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
|
|
// sendSIGWINCH sends SIGWINCH to the tmux attach process to trigger a repaint.
|
|
func sendSIGWINCH(s *Session) {
|
|
if s.Cmd.Process != nil {
|
|
_ = s.Cmd.Process.Signal(syscall.SIGWINCH)
|
|
}
|
|
}
|
|
```
|
|
|
|
Note: Add the missing `syscall` import to the import block:
|
|
|
|
```go
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
```
|
|
|
|
**Step 2: Build the full binary**
|
|
|
|
Run:
|
|
```bash
|
|
cd muxterm && go build -o muxterm .
|
|
```
|
|
Expected: Binary compiles successfully. `muxterm/muxterm` binary created.
|
|
|
|
**Step 3: Quick smoke test**
|
|
|
|
Run:
|
|
```bash
|
|
cd muxterm && MUXTERM_SECRET=test123 ./muxterm &
|
|
sleep 1
|
|
curl -s http://127.0.0.1:7682/health
|
|
kill %1
|
|
```
|
|
Expected: `{"status":"ok"}`
|
|
|
|
**Step 4: Add muxterm binary to .gitignore**
|
|
|
|
Add `muxterm/muxterm` to the project `.gitignore` (if one exists) or create `muxterm/.gitignore`:
|
|
```
|
|
muxterm
|
|
```
|
|
|
|
**Step 5: Commit**
|
|
```bash
|
|
git add muxterm/ws.go muxterm/.gitignore && git commit -m "feat(muxterm): WebSocket server with HMAC token auth and PTY relay"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 5: Go Tests — Pool, Protocol, Token Validation
|
|
|
|
**Files:**
|
|
- Create: `muxterm/pool_test.go`
|
|
- Create: `muxterm/protocol_test.go`
|
|
- Create: `muxterm/ws_test.go`
|
|
|
|
**Step 1: Write protocol tests**
|
|
|
|
Create `muxterm/protocol_test.go`:
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
)
|
|
|
|
func TestParseAttachMessage(t *testing.T) {
|
|
data := []byte(`{"attach": "dev-server"}`)
|
|
kind, val := ParseControlMessage(data)
|
|
if kind != "attach" {
|
|
t.Fatalf("expected attach, got %s", kind)
|
|
}
|
|
msg := val.(AttachMsg)
|
|
if msg.Attach != "dev-server" {
|
|
t.Fatalf("expected dev-server, got %s", msg.Attach)
|
|
}
|
|
}
|
|
|
|
func TestParseResizeMessage(t *testing.T) {
|
|
data := []byte(`{"resize": {"cols": 120, "rows": 40}}`)
|
|
kind, val := ParseControlMessage(data)
|
|
if kind != "resize" {
|
|
t.Fatalf("expected resize, got %s", kind)
|
|
}
|
|
msg := val.(ResizeMsg)
|
|
if msg.Resize.Cols != 120 || msg.Resize.Rows != 40 {
|
|
t.Fatalf("unexpected size: %+v", msg.Resize)
|
|
}
|
|
}
|
|
|
|
func TestParseDetachMessage(t *testing.T) {
|
|
data := []byte(`{"detach": true}`)
|
|
kind, val := ParseControlMessage(data)
|
|
if kind != "detach" {
|
|
t.Fatalf("expected detach, got %s", kind)
|
|
}
|
|
msg := val.(DetachMsg)
|
|
if !msg.Detach {
|
|
t.Fatal("expected detach=true")
|
|
}
|
|
}
|
|
|
|
func TestParseInvalidJSON(t *testing.T) {
|
|
data := []byte(`not json`)
|
|
kind, _ := ParseControlMessage(data)
|
|
if kind != "invalid" {
|
|
t.Fatalf("expected invalid, got %s", kind)
|
|
}
|
|
}
|
|
|
|
func TestParseUnknownMessage(t *testing.T) {
|
|
data := []byte(`{"foo": "bar"}`)
|
|
kind, _ := ParseControlMessage(data)
|
|
if kind != "unknown" {
|
|
t.Fatalf("expected unknown, got %s", kind)
|
|
}
|
|
}
|
|
|
|
func TestMarshalAttached(t *testing.T) {
|
|
b := MarshalAttached("dev-server")
|
|
var msg AttachedMsg
|
|
json.Unmarshal(b, &msg)
|
|
if msg.Attached != "dev-server" {
|
|
t.Fatalf("expected dev-server, got %s", msg.Attached)
|
|
}
|
|
}
|
|
|
|
func TestMarshalError(t *testing.T) {
|
|
b := MarshalError("session not found")
|
|
var msg ErrorMsg
|
|
json.Unmarshal(b, &msg)
|
|
if msg.Error != "session not found" {
|
|
t.Fatalf("expected 'session not found', got %s", msg.Error)
|
|
}
|
|
}
|
|
|
|
func TestMarshalExited(t *testing.T) {
|
|
b := MarshalExited("dev-server")
|
|
var msg ExitedMsg
|
|
json.Unmarshal(b, &msg)
|
|
if msg.Exited != "dev-server" {
|
|
t.Fatalf("expected dev-server, got %s", msg.Exited)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Write token validation tests**
|
|
|
|
Create `muxterm/ws_test.go`:
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func makeToken(secret string, ts int64) string {
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
tsStr := fmt.Sprintf("%d", ts)
|
|
mac.Write([]byte(tsStr))
|
|
sig := hex.EncodeToString(mac.Sum(nil))
|
|
return sig + "." + tsStr
|
|
}
|
|
|
|
func TestValidateToken_Valid(t *testing.T) {
|
|
secret := "test-secret-key"
|
|
token := makeToken(secret, time.Now().Unix())
|
|
if !ValidateToken(token, secret, 30) {
|
|
t.Fatal("expected valid token")
|
|
}
|
|
}
|
|
|
|
func TestValidateToken_Expired(t *testing.T) {
|
|
secret := "test-secret-key"
|
|
token := makeToken(secret, time.Now().Unix()-60) // 60s ago
|
|
if ValidateToken(token, secret, 30) {
|
|
t.Fatal("expected expired token to be rejected")
|
|
}
|
|
}
|
|
|
|
func TestValidateToken_WrongSecret(t *testing.T) {
|
|
token := makeToken("correct-secret", time.Now().Unix())
|
|
if ValidateToken(token, "wrong-secret", 30) {
|
|
t.Fatal("expected wrong secret to be rejected")
|
|
}
|
|
}
|
|
|
|
func TestValidateToken_MalformedNoTimestamp(t *testing.T) {
|
|
if ValidateToken("justasignature", "secret", 30) {
|
|
t.Fatal("expected malformed token to be rejected")
|
|
}
|
|
}
|
|
|
|
func TestValidateToken_EmptyToken(t *testing.T) {
|
|
if ValidateToken("", "secret", 30) {
|
|
t.Fatal("expected empty token to be rejected")
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Write pool tests**
|
|
|
|
Create `muxterm/pool_test.go`:
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"testing"
|
|
)
|
|
|
|
func TestNewPool(t *testing.T) {
|
|
p := NewPool()
|
|
if p == nil {
|
|
t.Fatal("expected non-nil pool")
|
|
}
|
|
}
|
|
|
|
func TestPool_Get_NoSession(t *testing.T) {
|
|
p := NewPool()
|
|
s := p.Get("nonexistent")
|
|
if s != nil {
|
|
t.Fatal("expected nil for nonexistent session")
|
|
}
|
|
}
|
|
|
|
func TestPool_IsAlive_NoSession(t *testing.T) {
|
|
p := NewPool()
|
|
if p.IsAlive("nonexistent") {
|
|
t.Fatal("expected false for nonexistent session")
|
|
}
|
|
}
|
|
|
|
func TestPool_CloseAll_Empty(t *testing.T) {
|
|
p := NewPool()
|
|
p.CloseAll() // should not panic
|
|
}
|
|
|
|
// Integration test: requires tmux to be running with a session.
|
|
// Skipped in CI — run manually with: go test -run TestPool_Attach_Real -count=1
|
|
func TestPool_Attach_Real(t *testing.T) {
|
|
// Skip if no tmux sessions exist
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test in short mode")
|
|
}
|
|
|
|
p := NewPool()
|
|
defer p.CloseAll()
|
|
|
|
// This test requires a tmux session named "test-muxterm" to exist.
|
|
// Create it with: tmux new-session -d -s test-muxterm
|
|
s, err := p.Attach("test-muxterm", 80, 24)
|
|
if err != nil {
|
|
t.Skipf("tmux session 'test-muxterm' not found (create with: tmux new-session -d -s test-muxterm): %v", err)
|
|
}
|
|
|
|
if s.PTY == nil {
|
|
t.Fatal("expected non-nil PTY")
|
|
}
|
|
|
|
if !p.IsAlive("test-muxterm") {
|
|
t.Fatal("expected session to be alive")
|
|
}
|
|
|
|
// Test resize
|
|
err = p.Resize("test-muxterm", 120, 40)
|
|
if err != nil {
|
|
t.Fatalf("resize failed: %v", err)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 4: Run the tests**
|
|
|
|
Run:
|
|
```bash
|
|
cd muxterm && go test -short -v ./...
|
|
```
|
|
Expected: All protocol and token tests pass. Pool integration test skipped in short mode.
|
|
|
|
**Step 5: Commit**
|
|
```bash
|
|
git add muxterm/*_test.go && git commit -m "test(muxterm): unit tests for protocol, token validation, and pool"
|
|
```
|
|
|
|
---
|
|
|
|
## Group B — Python Integration
|
|
|
|
### Task 6: Token Endpoint — `GET /api/terminal-token`
|
|
|
|
**Files:**
|
|
- Modify: `muxplex/main.py`
|
|
|
|
**Step 1: Write the failing test**
|
|
|
|
Add to `muxplex/tests/test_api.py` (at the end of the file, before any final newline):
|
|
|
|
```python
|
|
# ---------------------------------------------------------------------------
|
|
# GET /api/terminal-token — muxterm auth token endpoint
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_terminal_token_returns_200(client):
|
|
"""GET /api/terminal-token returns 200 with a token string."""
|
|
response = client.get("/api/terminal-token")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "token" in data
|
|
assert isinstance(data["token"], str)
|
|
assert "." in data["token"] # format: hex_signature.timestamp
|
|
|
|
|
|
def test_terminal_token_contains_port(client):
|
|
"""GET /api/terminal-token returns the muxterm port."""
|
|
response = client.get("/api/terminal-token")
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
assert "port" in data
|
|
assert isinstance(data["port"], int)
|
|
|
|
|
|
def test_terminal_token_is_unique_per_call(client):
|
|
"""Each call to GET /api/terminal-token returns a different token (time-based)."""
|
|
r1 = client.get("/api/terminal-token")
|
|
# Tokens include a timestamp, so even rapid calls may produce the same ts.
|
|
# At minimum, the response must be valid JSON with a token field.
|
|
assert r1.status_code == 200
|
|
assert "token" in r1.json()
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run:
|
|
```bash
|
|
.venv/bin/python -m pytest muxplex/tests/test_api.py::test_terminal_token_returns_200 -x -q --timeout=30
|
|
```
|
|
Expected: FAIL — 404 (endpoint doesn't exist yet).
|
|
|
|
**Step 3: Implement the endpoint**
|
|
|
|
In `muxplex/main.py`, add the following. First, add a module-level constant for the muxterm shared secret and port near the other configuration constants (around line 96, after `SERVER_PORT`):
|
|
|
|
```python
|
|
MUXTERM_PORT: int = int(os.environ.get("MUXTERM_PORT", "7682"))
|
|
_muxterm_secret: str = os.environ.get("MUXTERM_SECRET", "")
|
|
```
|
|
|
|
Then add a helper function and the endpoint. Place the endpoint near the other `/api/` endpoints (after `connect_session` around line 747):
|
|
|
|
```python
|
|
def _generate_muxterm_token() -> str:
|
|
"""Generate a short-lived HMAC token for muxterm WebSocket auth.
|
|
|
|
Format: hex(hmac-sha256(secret, timestamp)) + "." + timestamp
|
|
Token is valid for 30 seconds.
|
|
"""
|
|
import hashlib
|
|
ts = str(int(time.time()))
|
|
sig = hmac.new(
|
|
_muxterm_secret.encode(),
|
|
ts.encode(),
|
|
hashlib.sha256,
|
|
).hexdigest()
|
|
return f"{sig}.{ts}"
|
|
|
|
|
|
@app.get("/api/terminal-token")
|
|
async def get_terminal_token() -> dict:
|
|
"""Return a short-lived HMAC token for direct WebSocket auth with muxterm.
|
|
|
|
The browser uses this token to connect directly to muxterm's WebSocket.
|
|
Token is valid for 30 seconds. Auth is handled by the middleware —
|
|
if this endpoint is reached, the caller is already authenticated.
|
|
|
|
Returns {token: str, port: int}.
|
|
"""
|
|
if not _muxterm_secret:
|
|
raise HTTPException(status_code=503, detail="muxterm not configured")
|
|
return {"token": _generate_muxterm_token(), "port": MUXTERM_PORT}
|
|
```
|
|
|
|
**Important:** The `_generate_muxterm_token` function uses `hmac.new` — but Python's module is `hmac` (already imported at line 13 of main.py). The function name is `hmac.new()`. Double-check: it's actually `hmac.new()` (lowercase) in Python's stdlib.
|
|
|
|
**Step 4: Run test to verify it passes**
|
|
|
|
Run:
|
|
```bash
|
|
MUXTERM_SECRET=test-secret-for-tests .venv/bin/python -m pytest muxplex/tests/test_api.py::test_terminal_token_returns_200 muxplex/tests/test_api.py::test_terminal_token_contains_port muxplex/tests/test_api.py::test_terminal_token_is_unique_per_call -x -q --timeout=30
|
|
```
|
|
Expected: All 3 pass.
|
|
|
|
**Note:** You may need to set `MUXTERM_SECRET` in the test fixture or monkeypatch `_muxterm_secret` on `muxplex.main`. Check how the existing `patch_startup_and_state` autouse fixture works in `test_api.py` and add a `monkeypatch.setattr("muxplex.main._muxterm_secret", "test-secret")` there, or define a dedicated fixture for these tests.
|
|
|
|
**Step 5: Commit**
|
|
```bash
|
|
git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: GET /api/terminal-token endpoint for muxterm HMAC auth"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: muxterm Process Supervision
|
|
|
|
**Files:**
|
|
- Create: `muxplex/muxterm.py`
|
|
- Modify: `muxplex/main.py` (lifespan function, lines 371-422)
|
|
|
|
**Step 1: Write the failing test**
|
|
|
|
Create `muxplex/tests/test_muxterm.py`:
|
|
|
|
```python
|
|
"""Tests for muxplex/muxterm.py — muxterm process supervision."""
|
|
|
|
import asyncio
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from muxplex.muxterm import start_muxterm, stop_muxterm, _muxterm_process
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_muxterm_state():
|
|
"""Reset module state between tests."""
|
|
import muxplex.muxterm as mod
|
|
mod._muxterm_process = None
|
|
yield
|
|
mod._muxterm_process = None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_muxterm_spawns_process(monkeypatch):
|
|
"""start_muxterm calls create_subprocess_exec with correct args."""
|
|
mock_proc = MagicMock()
|
|
mock_proc.pid = 99999
|
|
mock_proc.returncode = None
|
|
|
|
mock_create = AsyncMock(return_value=mock_proc)
|
|
monkeypatch.setattr("asyncio.create_subprocess_exec", mock_create)
|
|
|
|
await start_muxterm(secret="test-secret", port=7682, binary_path="/usr/local/bin/muxterm")
|
|
|
|
mock_create.assert_called_once()
|
|
args = mock_create.call_args
|
|
# First positional arg should be the binary path
|
|
assert args[0][0] == "/usr/local/bin/muxterm"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_muxterm_terminates_process(monkeypatch):
|
|
"""stop_muxterm sends SIGTERM and waits."""
|
|
import muxplex.muxterm as mod
|
|
|
|
mock_proc = MagicMock()
|
|
mock_proc.returncode = None
|
|
mock_proc.terminate = MagicMock()
|
|
mock_proc.wait = AsyncMock(return_value=0)
|
|
mod._muxterm_process = mock_proc
|
|
|
|
await stop_muxterm()
|
|
|
|
mock_proc.terminate.assert_called_once()
|
|
assert mod._muxterm_process is None
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run:
|
|
```bash
|
|
.venv/bin/python -m pytest muxplex/tests/test_muxterm.py -x -q --timeout=30
|
|
```
|
|
Expected: FAIL — `ModuleNotFoundError: No module named 'muxplex.muxterm'`
|
|
|
|
**Step 3: Implement muxterm.py**
|
|
|
|
Create `muxplex/muxterm.py`:
|
|
|
|
```python
|
|
"""muxterm process supervision — start, monitor, restart, stop.
|
|
|
|
Python starts the muxterm Go binary as a managed subprocess.
|
|
If muxterm crashes, Python restarts it automatically.
|
|
On shutdown, Python sends SIGTERM for graceful cleanup.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import shutil
|
|
|
|
_log = logging.getLogger(__name__)
|
|
|
|
_muxterm_process: asyncio.subprocess.Process | None = None
|
|
_restart_task: asyncio.Task | None = None
|
|
|
|
|
|
def _find_muxterm_binary(binary_path: str | None = None) -> str:
|
|
"""Locate the muxterm binary.
|
|
|
|
Priority:
|
|
1. Explicit binary_path argument
|
|
2. ``muxterm`` on PATH (shutil.which)
|
|
|
|
Raises FileNotFoundError if not found.
|
|
"""
|
|
if binary_path:
|
|
return binary_path
|
|
found = shutil.which("muxterm")
|
|
if found:
|
|
return found
|
|
raise FileNotFoundError(
|
|
"muxterm binary not found. Build it with: cd muxterm && go build -o muxterm ."
|
|
)
|
|
|
|
|
|
async def start_muxterm(
|
|
secret: str,
|
|
port: int = 7682,
|
|
binary_path: str | None = None,
|
|
auto_restart: bool = True,
|
|
) -> None:
|
|
"""Start the muxterm process.
|
|
|
|
Args:
|
|
secret: HMAC shared secret for token validation.
|
|
port: TCP port for muxterm to listen on.
|
|
binary_path: Explicit path to the muxterm binary. If None, searches PATH.
|
|
auto_restart: If True, restart muxterm automatically on crash.
|
|
"""
|
|
global _muxterm_process, _restart_task
|
|
|
|
binary = _find_muxterm_binary(binary_path)
|
|
|
|
_muxterm_process = await asyncio.create_subprocess_exec(
|
|
binary,
|
|
"--addr", f"127.0.0.1:{port}",
|
|
"--secret", secret,
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
_log.info("muxterm started (pid=%d, port=%d)", _muxterm_process.pid, port)
|
|
|
|
if auto_restart:
|
|
_restart_task = asyncio.create_task(
|
|
_monitor_and_restart(secret, port, binary_path)
|
|
)
|
|
|
|
|
|
async def _monitor_and_restart(
|
|
secret: str,
|
|
port: int,
|
|
binary_path: str | None,
|
|
) -> None:
|
|
"""Wait for muxterm to exit and restart it if it crashed."""
|
|
global _muxterm_process
|
|
|
|
while True:
|
|
if _muxterm_process is None:
|
|
return
|
|
|
|
returncode = await _muxterm_process.wait()
|
|
|
|
if returncode == 0:
|
|
_log.info("muxterm exited cleanly")
|
|
_muxterm_process = None
|
|
return
|
|
|
|
_log.warning("muxterm crashed (rc=%d), restarting in 1s", returncode)
|
|
await asyncio.sleep(1)
|
|
|
|
try:
|
|
await start_muxterm(
|
|
secret=secret,
|
|
port=port,
|
|
binary_path=binary_path,
|
|
auto_restart=False, # this task IS the restarter
|
|
)
|
|
except Exception as exc:
|
|
_log.error("muxterm restart failed: %s", exc)
|
|
await asyncio.sleep(5) # back off on repeated failures
|
|
|
|
|
|
async def stop_muxterm() -> None:
|
|
"""Stop the muxterm process gracefully."""
|
|
global _muxterm_process, _restart_task
|
|
|
|
if _restart_task is not None:
|
|
_restart_task.cancel()
|
|
try:
|
|
await _restart_task
|
|
except (asyncio.CancelledError, Exception):
|
|
pass
|
|
_restart_task = None
|
|
|
|
if _muxterm_process is not None:
|
|
_log.info("stopping muxterm (pid=%d)", _muxterm_process.pid)
|
|
try:
|
|
_muxterm_process.terminate()
|
|
try:
|
|
await asyncio.wait_for(_muxterm_process.wait(), timeout=5.0)
|
|
except asyncio.TimeoutError:
|
|
_muxterm_process.kill()
|
|
except ProcessLookupError:
|
|
pass
|
|
_muxterm_process = None
|
|
```
|
|
|
|
**Step 4: Run tests to verify they pass**
|
|
|
|
Run:
|
|
```bash
|
|
.venv/bin/python -m pytest muxplex/tests/test_muxterm.py -x -q --timeout=30
|
|
```
|
|
Expected: All pass.
|
|
|
|
**Step 5: Commit**
|
|
```bash
|
|
git add muxplex/muxterm.py muxplex/tests/test_muxterm.py && git commit -m "feat: muxterm process supervision (start, restart on crash, stop)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Wire muxterm into FastAPI Lifespan
|
|
|
|
**Files:**
|
|
- Modify: `muxplex/main.py` (lifespan function at line 372, imports at line 81)
|
|
|
|
**Step 1: Update imports in main.py**
|
|
|
|
Replace the ttyd import block (lines 81-90) with the muxterm import:
|
|
|
|
```python
|
|
from muxplex.muxterm import start_muxterm, stop_muxterm
|
|
```
|
|
|
|
Remove these lines entirely:
|
|
```python
|
|
from muxplex.ttyd import (
|
|
TTYD_PORT,
|
|
_ttyd_is_listening,
|
|
get_or_spawn,
|
|
kill_orphan_ttyd,
|
|
kill_session,
|
|
kill_ttyd, # noqa: F401 — backward compat re-export used by tests
|
|
pool_port,
|
|
spawn_ttyd, # noqa: F401 — backward compat re-export used by tests
|
|
)
|
|
```
|
|
|
|
**Step 2: Update the lifespan function**
|
|
|
|
Replace the startup section of the `lifespan` function (line 372). Change:
|
|
```python
|
|
# Startup: kill any orphaned ttyd from a previous muxplex run, then
|
|
# start the background poll loop.
|
|
await kill_orphan_ttyd()
|
|
```
|
|
|
|
To:
|
|
```python
|
|
# Startup: start muxterm Go binary, then start the background poll loop.
|
|
if _muxterm_secret:
|
|
try:
|
|
await start_muxterm(secret=_muxterm_secret, port=MUXTERM_PORT)
|
|
except FileNotFoundError:
|
|
_log.warning("muxterm binary not found — terminal features disabled")
|
|
except Exception as exc:
|
|
_log.warning("failed to start muxterm: %s", exc)
|
|
```
|
|
|
|
Add cleanup in the shutdown section (after the poll task cancellation, before the end of the `finally` block):
|
|
```python
|
|
# Stop muxterm
|
|
await stop_muxterm()
|
|
```
|
|
|
|
**Step 3: Generate the muxterm secret on startup if not set**
|
|
|
|
Update the `_muxterm_secret` initialization (from Task 6) to auto-generate a secret if not provided via env var:
|
|
|
|
```python
|
|
import secrets as _secrets_mod
|
|
|
|
MUXTERM_PORT: int = int(os.environ.get("MUXTERM_PORT", "7682"))
|
|
_muxterm_secret: str = os.environ.get("MUXTERM_SECRET", "") or _secrets_mod.token_hex(32)
|
|
```
|
|
|
|
This ensures muxterm always has a shared secret — either from the environment or auto-generated.
|
|
|
|
**Step 4: Run the existing test suite to check for breakage**
|
|
|
|
Run:
|
|
```bash
|
|
.venv/bin/python -m pytest muxplex/tests/test_api.py -x -q --timeout=30 -k "not connect_session and not terminal_ws and not ws_proxy"
|
|
```
|
|
Expected: Tests that don't depend on ttyd imports pass. Tests that reference the deleted `connect_session` or WS proxy will fail — that's expected and handled in Task 10.
|
|
|
|
**Step 5: Commit**
|
|
```bash
|
|
git add muxplex/main.py && git commit -m "feat: wire muxterm into FastAPI lifespan (start on boot, stop on shutdown)"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Delete Old Code — ttyd.py, WS Proxy, Connect Endpoint
|
|
|
|
**Files:**
|
|
- Delete: `muxplex/ttyd.py`
|
|
- Delete: `muxplex/tests/test_ttyd.py`
|
|
- Delete: `muxplex/tests/test_ws_proxy.py`
|
|
- Modify: `muxplex/main.py` (remove `connect_session`, `terminal_ws_proxy`, `_ws_auth_check`, `delete_current_session`'s `kill_session` call, update `delete_session`'s cleanup call)
|
|
|
|
**Step 1: Delete ttyd.py and its tests**
|
|
|
|
Run:
|
|
```bash
|
|
git rm muxplex/ttyd.py muxplex/tests/test_ttyd.py muxplex/tests/test_ws_proxy.py
|
|
```
|
|
|
|
**Step 2: Remove the WS proxy from main.py**
|
|
|
|
Delete the following sections from `muxplex/main.py`:
|
|
|
|
1. The `_ws_auth_check` function (lines 1002-1025)
|
|
2. The `terminal_ws_proxy` function and its `@app.websocket("/terminal/ws")` decorator (lines 1028-1112)
|
|
3. The comment block above them: `# WebSocket proxy — bridges browser to ttyd` (line 995) and `# _ttyd_is_listening is imported from muxplex.ttyd` (line 999)
|
|
4. The `websockets` import used by the proxy — find `import websockets` and `from websockets import Subprotocol` and remove them if they're only used by the proxy
|
|
|
|
**Step 3: Remove the connect_session endpoint**
|
|
|
|
Delete the `connect_session` function (lines 723-746):
|
|
```python
|
|
@app.post("/api/sessions/{name}/connect")
|
|
async def connect_session(name: str) -> dict:
|
|
...
|
|
```
|
|
|
|
**Step 4: Update delete_current_session**
|
|
|
|
The `delete_current_session` endpoint (line 749) calls `await kill_session(active)` which comes from `ttyd.py`. Remove that call — muxterm handles PTY lifecycle. Change it to just clear the state:
|
|
|
|
```python
|
|
@app.delete("/api/sessions/current")
|
|
async def delete_current_session() -> dict:
|
|
"""Clear the active session in persistent state.
|
|
|
|
Returns {active_session: None}.
|
|
"""
|
|
async with state_lock:
|
|
state = load_state()
|
|
state["active_session"] = None
|
|
save_state(state)
|
|
|
|
return {"active_session": None}
|
|
```
|
|
|
|
**Step 5: Update delete_session**
|
|
|
|
The `delete_session` endpoint (around line 770) calls `await kill_session(name)` for pool cleanup. Remove that call — muxterm detects when tmux sessions die via process exit.
|
|
|
|
Remove the line:
|
|
```python
|
|
await kill_session(name)
|
|
```
|
|
|
|
And remove the comment above it:
|
|
```python
|
|
# Clean up the pool entry (ttyd will die when tmux session dies,
|
|
# but clean up immediately so the port is freed).
|
|
```
|
|
|
|
**Step 6: Remove tests for deleted endpoints**
|
|
|
|
In `muxplex/tests/test_api.py`, delete or comment out:
|
|
- `test_connect_session_returns_200` (line 370)
|
|
- `test_connect_session_sets_active_session` (line 386)
|
|
- `test_connect_session_calls_get_or_spawn` (line 403)
|
|
- `test_connect_nonexistent_session_returns_404` (line 420)
|
|
- `test_terminal_ws_route_exists` (line 809)
|
|
- `test_connect_session_logs_session_name` (line 2473)
|
|
|
|
**Step 7: Run the test suite**
|
|
|
|
Run:
|
|
```bash
|
|
.venv/bin/python -m pytest muxplex/tests/test_api.py -x -q --timeout=30
|
|
```
|
|
Expected: Passes (minus the deleted tests). Any remaining import errors for `ttyd` indicate missed references — fix them.
|
|
|
|
**Step 8: Commit**
|
|
```bash
|
|
git add -A && git commit -m "refactor: delete ttyd.py, WS proxy, connect endpoint — muxterm owns terminal path"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Fix Remaining ttyd References
|
|
|
|
**Files:**
|
|
- Modify: various files that may still reference ttyd imports
|
|
|
|
**Step 1: Search for remaining ttyd references**
|
|
|
|
Run:
|
|
```bash
|
|
grep -rn "ttyd\|kill_session\|get_or_spawn\|pool_port\|spawn_ttyd\|kill_ttyd\|TTYD_PORT\|_ttyd_is_listening" muxplex/ --include="*.py" | grep -v __pycache__ | grep -v test_ttyd | grep -v test_ws_proxy
|
|
```
|
|
|
|
**Step 2: Fix each remaining reference**
|
|
|
|
For each hit:
|
|
- If it's a comment referencing ttyd in documentation, update the comment to reference muxterm
|
|
- If it's an import, remove it
|
|
- If it's a function call, remove it or replace with the muxterm equivalent
|
|
- The `delete_current_session` endpoint no longer needs `kill_session` (handled in Task 9)
|
|
- The federation proxy at line ~1503 calls connect on remote instances — this still makes sense (remote instances may still use ttyd), so leave federation proxy code as-is if it's calling a remote `/connect` endpoint
|
|
|
|
**Step 3: Run the full test suite**
|
|
|
|
Run:
|
|
```bash
|
|
.venv/bin/python -m pytest muxplex/tests/ -x -q --timeout=30
|
|
```
|
|
Expected: All remaining tests pass. Fix any failures.
|
|
|
|
**Step 4: Commit**
|
|
```bash
|
|
git add -A && git commit -m "fix: remove remaining ttyd references from Python codebase"
|
|
```
|
|
|
|
---
|
|
|
|
## Group C — Frontend Rewrite
|
|
|
|
### Task 11: Rewrite terminal.js — Single WebSocket, Control Protocol
|
|
|
|
**Files:**
|
|
- Rewrite: `muxplex/frontend/terminal.js`
|
|
|
|
This is the biggest task. The entire file (~1091 lines) gets rewritten to ~300 lines. The new version:
|
|
- Maintains ONE WebSocket connection to muxterm (not one per session)
|
|
- Maintains ONE terminal instance (not cached per session)
|
|
- Uses binary frames for raw I/O (no `0x30`/`0x31` prefix encoding)
|
|
- Uses text frames for JSON control messages (attach, resize, detach)
|
|
- No terminal cache, no `_terminalCache`, no `_cacheOrder`, no `switchTerminal`
|
|
|
|
**Step 1: Write the new terminal.js**
|
|
|
|
Rewrite `muxplex/frontend/terminal.js` with:
|
|
|
|
```javascript
|
|
// terminal.js — muxterm WebSocket client
|
|
// Single WebSocket to muxterm, single terminal instance, binary/text frame protocol.
|
|
|
|
// ——— Module-level state ———————————————————————————————————————
|
|
let _term = null;
|
|
let _fitAddon = null;
|
|
let _ws = null;
|
|
let _reconnectTimer = null;
|
|
let _overlayTimer = null;
|
|
let _currentSession = null;
|
|
let _vpHandler = null;
|
|
let _reconnectAttempts = 0;
|
|
let _searchAddon = null;
|
|
let _resizeObserver = null;
|
|
let _ctrlActive = false;
|
|
let _altActive = false;
|
|
let _muxtermPort = null;
|
|
let _muxtermToken = null;
|
|
|
|
// ——— Encoding helpers ————————————————————————————————————————
|
|
const _encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
|
|
const _decoder = typeof TextDecoder !== 'undefined' ? new TextDecoder() : null;
|
|
|
|
// ——— Clipboard helpers ——————————————————————————————————————
|
|
function _copyToClipboard(text) {
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(text).catch(function() {});
|
|
} else {
|
|
var ta = document.createElement('textarea');
|
|
ta.value = text;
|
|
ta.style.position = 'fixed';
|
|
ta.style.left = '-9999px';
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
try { document.execCommand('copy'); } catch(e) {}
|
|
document.body.removeChild(ta);
|
|
}
|
|
}
|
|
|
|
// ——— WebSocket connection ————————————————————————————————————
|
|
|
|
function connectWebSocket() {
|
|
// Fetch auth token from Python, then connect directly to muxterm
|
|
fetch('/api/terminal-token')
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
_muxtermPort = data.port;
|
|
_muxtermToken = data.token;
|
|
_openMuxtermSocket();
|
|
})
|
|
.catch(function(err) {
|
|
console.warn('tmux-web: failed to get terminal token:', err);
|
|
_scheduleReconnect();
|
|
});
|
|
}
|
|
|
|
function _openMuxtermSocket() {
|
|
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
var host = location.hostname;
|
|
var url = proto + '//' + host + ':' + _muxtermPort + '/ws?token=' + encodeURIComponent(_muxtermToken);
|
|
|
|
var ws = new WebSocket(url);
|
|
_ws = ws;
|
|
ws.binaryType = 'arraybuffer';
|
|
var reconnectOverlay = document.getElementById('reconnect-overlay');
|
|
|
|
ws.addEventListener('open', function() {
|
|
if (ws !== _ws) return;
|
|
_reconnectAttempts = 0;
|
|
if (_overlayTimer) { clearTimeout(_overlayTimer); _overlayTimer = null; }
|
|
if (reconnectOverlay) reconnectOverlay.classList.add('hidden');
|
|
|
|
// If we have a session to attach to, send attach message
|
|
if (_currentSession) {
|
|
ws.send(JSON.stringify({ attach: _currentSession }));
|
|
}
|
|
|
|
if (_term) _term.focus();
|
|
});
|
|
|
|
ws.addEventListener('message', function(e) {
|
|
if (ws !== _ws) return;
|
|
if (!_term) return;
|
|
|
|
if (e.data instanceof ArrayBuffer) {
|
|
// Binary frame = raw terminal output from muxterm
|
|
var bytes = new Uint8Array(e.data);
|
|
if (bytes.length > 0) {
|
|
_term.write(_decoder ? _decoder.decode(bytes) : bytes);
|
|
}
|
|
} else if (typeof e.data === 'string') {
|
|
// Text frame = JSON control message from muxterm
|
|
try {
|
|
var msg = JSON.parse(e.data);
|
|
if (msg.attached) {
|
|
// Session switch confirmed
|
|
_currentSession = msg.attached;
|
|
} else if (msg.error) {
|
|
console.warn('muxterm error:', msg.error);
|
|
if (typeof showToast === 'function') showToast(msg.error);
|
|
} else if (msg.exited) {
|
|
console.info('muxterm: session exited:', msg.exited);
|
|
// Return to dashboard if the active session exited
|
|
if (msg.exited === _currentSession && typeof closeSession === 'function') {
|
|
closeSession();
|
|
}
|
|
}
|
|
} catch (err) {
|
|
// Not JSON — write as text (shouldn't happen)
|
|
_term.write(e.data);
|
|
}
|
|
}
|
|
});
|
|
|
|
ws.addEventListener('close', function() {
|
|
if (ws !== _ws) return;
|
|
if (!_currentSession) return; // intentional close
|
|
_scheduleReconnect();
|
|
});
|
|
|
|
ws.addEventListener('error', function() {
|
|
if (ws !== _ws) return;
|
|
console.warn('tmux-web: WebSocket error');
|
|
});
|
|
}
|
|
|
|
function _scheduleReconnect() {
|
|
var reconnectOverlay = document.getElementById('reconnect-overlay');
|
|
if (_overlayTimer) clearTimeout(_overlayTimer);
|
|
_overlayTimer = setTimeout(function() {
|
|
if (reconnectOverlay && _currentSession) reconnectOverlay.classList.remove('hidden');
|
|
_overlayTimer = null;
|
|
}, 1500);
|
|
|
|
_reconnectAttempts++;
|
|
var delay = Math.min(1000 * Math.pow(2, _reconnectAttempts - 1), 15000);
|
|
delay += Math.random() * 500;
|
|
_reconnectTimer = setTimeout(connectWebSocket, delay);
|
|
}
|
|
|
|
// ——— Visual viewport (mobile keyboard) ——————————————————————
|
|
function initVisualViewport() {
|
|
if (!window.visualViewport) return;
|
|
if (_vpHandler) window.visualViewport.removeEventListener('resize', _vpHandler);
|
|
|
|
_vpHandler = function() {
|
|
if (!_term || !_fitAddon) return;
|
|
var container = document.getElementById('terminal-container');
|
|
if (!container) return;
|
|
var headerHeight = 44;
|
|
var toolbar = document.getElementById('mobile-toolbar');
|
|
var toolbarHeight = (toolbar && !toolbar.classList.contains('hidden')) ? toolbar.offsetHeight : 0;
|
|
var vvh = window.visualViewport.height;
|
|
var termHeight = Math.max(100, vvh - headerHeight - toolbarHeight);
|
|
container.style.height = termHeight + 'px';
|
|
window.scrollTo(0, 0);
|
|
try { _fitAddon.fit(); } catch (_) {}
|
|
};
|
|
window.visualViewport.addEventListener('resize', _vpHandler);
|
|
}
|
|
|
|
// ——— Terminal creation ——————————————————————————————————————
|
|
|
|
function createTerminal(fontSize) {
|
|
if (_term) {
|
|
_term.dispose();
|
|
_term = null;
|
|
_fitAddon = null;
|
|
}
|
|
|
|
var storedFontSize = (typeof fontSize === 'number' && fontSize > 0) ? fontSize : 14;
|
|
const mobile = window.innerWidth < 600;
|
|
const effectiveFontSize = mobile ? Math.min(storedFontSize, 12) : storedFontSize;
|
|
|
|
_term = new window.Terminal({
|
|
cursorBlink: true,
|
|
fontSize: effectiveFontSize,
|
|
fontFamily: "'SF Mono', 'Fira Code', Consolas, monospace",
|
|
theme: { background: '#000000', foreground: '#c9d1d9', cursor: '#58a6ff' },
|
|
scrollback: mobile ? 500 : 5000,
|
|
allowProposedApi: true,
|
|
linkHandler: {
|
|
activate: function(event, uri) { window.open(uri, '_blank'); },
|
|
},
|
|
});
|
|
|
|
_fitAddon = new window.FitAddon.FitAddon();
|
|
_term.loadAddon(_fitAddon);
|
|
|
|
var WebLinksAddon = window.WebLinksAddon && window.WebLinksAddon.WebLinksAddon;
|
|
if (WebLinksAddon) {
|
|
_term.loadAddon(new WebLinksAddon(function(event, uri) { window.open(uri, '_blank'); }));
|
|
}
|
|
|
|
var SearchAddon = window.SearchAddon && window.SearchAddon.SearchAddon;
|
|
if (SearchAddon) {
|
|
_searchAddon = new SearchAddon();
|
|
_term.loadAddon(_searchAddon);
|
|
}
|
|
|
|
var ImageAddon = window.ImageAddon && window.ImageAddon.ImageAddon;
|
|
if (ImageAddon) {
|
|
_term.loadAddon(new ImageAddon());
|
|
}
|
|
}
|
|
|
|
// ——— Search helpers ——————————————————————————————————————————
|
|
function _openSearch() {
|
|
var bar = document.getElementById('terminal-search-bar');
|
|
var input = document.getElementById('terminal-search-input');
|
|
if (bar) { bar.classList.remove('hidden'); if (input) { input.focus(); input.select(); } }
|
|
}
|
|
function _closeSearch() {
|
|
var bar = document.getElementById('terminal-search-bar');
|
|
if (bar) bar.classList.add('hidden');
|
|
if (_searchAddon) _searchAddon.clearDecorations();
|
|
if (_term) _term.focus();
|
|
}
|
|
function _searchNext() {
|
|
var input = document.getElementById('terminal-search-input');
|
|
if (input && input.value && _searchAddon) _searchAddon.findNext(input.value);
|
|
}
|
|
function _searchPrev() {
|
|
var input = document.getElementById('terminal-search-input');
|
|
if (input && input.value && _searchAddon) _searchAddon.findPrevious(input.value);
|
|
}
|
|
|
|
// ——— Open terminal ——————————————————————————————————————————
|
|
|
|
function openTerminal(sessionName, remoteId, fontSize) {
|
|
// For remote sessions, fall back to old behavior (federation proxy).
|
|
// Phase 2 only handles local sessions via muxterm.
|
|
// TODO: remote session support via muxterm
|
|
|
|
_currentSession = null;
|
|
_reconnectAttempts = 0;
|
|
|
|
if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
|
|
|
|
// Don't close WS — reuse it. Just send attach for the new session.
|
|
_currentSession = sessionName;
|
|
|
|
const container = document.getElementById('terminal-container');
|
|
if (!container) { console.warn('[openTerminal] #terminal-container not found'); return; }
|
|
|
|
// Create terminal only if we don't have one
|
|
if (!_term) {
|
|
createTerminal(fontSize);
|
|
_term.open(container);
|
|
|
|
// Resize observer
|
|
if (_resizeObserver) { _resizeObserver.disconnect(); _resizeObserver = null; }
|
|
if (typeof ResizeObserver !== 'undefined') {
|
|
var _roTimer = null;
|
|
_resizeObserver = new ResizeObserver(function() {
|
|
clearTimeout(_roTimer);
|
|
_roTimer = setTimeout(function() {
|
|
if (_fitAddon) try { _fitAddon.fit(); } catch (_) {}
|
|
}, 50);
|
|
});
|
|
_resizeObserver.observe(container);
|
|
}
|
|
|
|
// Terminal input → binary frame to muxterm (raw bytes, no prefix)
|
|
_term.onData(function(data) {
|
|
if (_ws && _ws.readyState === WebSocket.OPEN) {
|
|
var outData = data;
|
|
if (_ctrlActive && data.length === 1) {
|
|
var code = data.toUpperCase().charCodeAt(0);
|
|
if (code >= 65 && code <= 90) outData = String.fromCharCode(code - 64);
|
|
_ctrlActive = false;
|
|
var cb = document.querySelector('[data-modifier="ctrl"]');
|
|
if (cb) cb.classList.remove('mobile-toolbar__key--active');
|
|
} else if (_altActive && data.length === 1) {
|
|
outData = '\x1b' + data;
|
|
_altActive = false;
|
|
var ab = document.querySelector('[data-modifier="alt"]');
|
|
if (ab) ab.classList.remove('mobile-toolbar__key--active');
|
|
}
|
|
// Send as raw binary — no ttyd prefix encoding
|
|
var bytes = _encoder ? _encoder.encode(outData) : new Uint8Array(Array.from(outData).map(function(c) { return c.charCodeAt(0); }));
|
|
_ws.send(bytes);
|
|
}
|
|
});
|
|
|
|
// Terminal resize → JSON control message
|
|
_term.onResize(function(size) {
|
|
if (_ws && _ws.readyState === WebSocket.OPEN) {
|
|
_ws.send(JSON.stringify({ resize: { cols: size.cols, rows: size.rows } }));
|
|
}
|
|
});
|
|
|
|
// Clipboard: Ctrl+Shift+C, Ctrl+F
|
|
_term.attachCustomKeyEventHandler(function(e) {
|
|
if (e.type !== 'keydown') return true;
|
|
if (e.ctrlKey && e.shiftKey && (e.key === 'C' || e.code === 'KeyC')) {
|
|
var sel = _term.getSelection(); if (sel) _copyToClipboard(sel); return false;
|
|
}
|
|
if (e.ctrlKey && !e.shiftKey && (e.key === 'f' || e.key === 'F' || e.code === 'KeyF')) {
|
|
_openSearch(); return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
// Auto-copy on selection
|
|
_term.onSelectionChange(function() { var sel = _term.getSelection(); if (sel) _copyToClipboard(sel); });
|
|
|
|
// OSC 52 clipboard
|
|
_term.parser.registerOscHandler(52, function(data) {
|
|
var parts = data.split(';');
|
|
if (parts.length >= 2) { try { _copyToClipboard(atob(parts[1])); } catch(e) {} }
|
|
return true;
|
|
});
|
|
|
|
// Right-click context menu suppression
|
|
container.addEventListener('contextmenu', function(e) {
|
|
if (e.shiftKey || e.ctrlKey || e.metaKey) return; e.preventDefault();
|
|
});
|
|
|
|
// Search bar wiring
|
|
_wireSearchBar();
|
|
}
|
|
|
|
// Fit after layout
|
|
if (_fitAddon) {
|
|
var fitRef = _fitAddon;
|
|
var raf = typeof requestAnimationFrame !== 'undefined' ? requestAnimationFrame : function(fn) { fn(); };
|
|
raf(function() {
|
|
try { fitRef.fit(); } catch (_) {}
|
|
setTimeout(function() { if (_fitAddon) try { _fitAddon.fit(); } catch (_) {} }, 500);
|
|
});
|
|
}
|
|
|
|
// Connect or switch session
|
|
if (!_ws || _ws.readyState !== WebSocket.OPEN) {
|
|
connectWebSocket();
|
|
} else {
|
|
// WebSocket already open — just send attach for the new session
|
|
_ws.send(JSON.stringify({ attach: sessionName }));
|
|
}
|
|
|
|
initVisualViewport();
|
|
_initAndroidIMEFix(container);
|
|
_initMobileToolbar();
|
|
}
|
|
|
|
function _wireSearchBar() {
|
|
var searchInput = document.getElementById('terminal-search-input');
|
|
var searchClose = document.getElementById('terminal-search-close');
|
|
var searchNextBtn = document.getElementById('terminal-search-next');
|
|
var searchPrevBtn = document.getElementById('terminal-search-prev');
|
|
if (searchInput) {
|
|
var newInput = searchInput.cloneNode(true);
|
|
searchInput.parentNode.replaceChild(newInput, searchInput);
|
|
searchInput = newInput;
|
|
searchInput.addEventListener('input', function() {
|
|
if (_searchAddon && searchInput.value) _searchAddon.findNext(searchInput.value);
|
|
else if (_searchAddon) _searchAddon.clearDecorations();
|
|
});
|
|
searchInput.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter') { e.preventDefault(); if (e.shiftKey) _searchPrev(); else _searchNext(); }
|
|
if (e.key === 'Escape') { e.preventDefault(); _closeSearch(); }
|
|
});
|
|
}
|
|
if (searchClose) { var nc = searchClose.cloneNode(true); searchClose.parentNode.replaceChild(nc, searchClose); nc.addEventListener('click', _closeSearch); }
|
|
if (searchNextBtn) { var nn = searchNextBtn.cloneNode(true); searchNextBtn.parentNode.replaceChild(nn, searchNextBtn); nn.addEventListener('click', _searchNext); }
|
|
if (searchPrevBtn) { var np = searchPrevBtn.cloneNode(true); searchPrevBtn.parentNode.replaceChild(np, searchPrevBtn); np.addEventListener('click', _searchPrev); }
|
|
}
|
|
|
|
// ——— Close terminal ——————————————————————————————————————————
|
|
|
|
function closeTerminal() {
|
|
if (_vpHandler) {
|
|
if (window.visualViewport) window.visualViewport.removeEventListener('resize', _vpHandler);
|
|
_vpHandler = null;
|
|
}
|
|
if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
|
|
if (_overlayTimer) { clearTimeout(_overlayTimer); _overlayTimer = null; }
|
|
|
|
// Send detach so muxterm keeps the PTY alive
|
|
if (_ws && _ws.readyState === WebSocket.OPEN) {
|
|
_ws.send(JSON.stringify({ detach: true }));
|
|
}
|
|
|
|
if (_ws) { _ws.close(); _ws = null; }
|
|
if (_resizeObserver) { _resizeObserver.disconnect(); _resizeObserver = null; }
|
|
|
|
_ctrlActive = false;
|
|
_altActive = false;
|
|
var mobileToolbar = document.getElementById('mobile-toolbar');
|
|
if (mobileToolbar) mobileToolbar.classList.add('hidden');
|
|
|
|
if (_term) { _term.dispose(); _term = null; _fitAddon = null; _searchAddon = null; }
|
|
_closeSearch();
|
|
_currentSession = null;
|
|
_reconnectAttempts = 0;
|
|
}
|
|
|
|
// ——— Font size ————————————————————————————————————————————————
|
|
function setTerminalFontSize(size) {
|
|
if (!_term) return;
|
|
_term.options.fontSize = size;
|
|
if (_fitAddon) try { _fitAddon.fit(); } catch (_) {}
|
|
}
|
|
|
|
// ——— Android IME fix —————————————————————————————————————————
|
|
function _initAndroidIMEFix(container) {
|
|
if (!/Android/i.test(navigator.userAgent)) return;
|
|
setTimeout(function() {
|
|
var ta = container.querySelector('.xterm-helper-textarea');
|
|
if (!ta) return;
|
|
ta.addEventListener('beforeinput', function(e) {
|
|
if (e.inputType === 'insertReplacementText') {
|
|
e.preventDefault(); e.stopImmediatePropagation();
|
|
var data = e.data || '';
|
|
if (data && _ws && _ws.readyState === WebSocket.OPEN) {
|
|
var bytes = _encoder ? _encoder.encode('\x08' + data) : new Uint8Array(0);
|
|
_ws.send(bytes);
|
|
}
|
|
ta.value = '';
|
|
}
|
|
}, true);
|
|
}, 100);
|
|
}
|
|
|
|
// ——— Mobile toolbar ——————————————————————————————————————————
|
|
function _initMobileToolbar() {
|
|
var isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
|
if (!isTouchDevice) return;
|
|
var toolbar = document.getElementById('mobile-toolbar');
|
|
if (!toolbar) return;
|
|
toolbar.classList.remove('hidden');
|
|
_ctrlActive = false; _altActive = false;
|
|
var ctrlBtn = toolbar.querySelector('[data-modifier="ctrl"]');
|
|
var altBtn = toolbar.querySelector('[data-modifier="alt"]');
|
|
|
|
toolbar.addEventListener('pointerdown', function(e) {
|
|
var btn = e.target.closest('.mobile-toolbar__key');
|
|
if (!btn) return;
|
|
var modifier = btn.dataset.modifier;
|
|
if (modifier === 'ctrl') {
|
|
e.preventDefault(); _ctrlActive = !_ctrlActive;
|
|
btn.classList.toggle('mobile-toolbar__key--active', _ctrlActive);
|
|
if (_altActive && altBtn) { _altActive = false; altBtn.classList.remove('mobile-toolbar__key--active'); }
|
|
if (_ctrlActive && _term) _term.focus(); return;
|
|
}
|
|
if (modifier === 'alt') {
|
|
e.preventDefault(); _altActive = !_altActive;
|
|
btn.classList.toggle('mobile-toolbar__key--active', _altActive);
|
|
if (_ctrlActive && ctrlBtn) { _ctrlActive = false; ctrlBtn.classList.remove('mobile-toolbar__key--active'); }
|
|
if (_altActive && _term) _term.focus(); return;
|
|
}
|
|
e.preventDefault();
|
|
var key = btn.dataset.key, input = btn.dataset.input, seq = '';
|
|
if (key) {
|
|
switch (key) {
|
|
case 'Escape': seq = '\x1b'; break; case 'Tab': seq = '\t'; break;
|
|
case 'ArrowUp': seq = '\x1b[A'; break; case 'ArrowDown': seq = '\x1b[B'; break;
|
|
case 'ArrowRight': seq = '\x1b[C'; break; case 'ArrowLeft': seq = '\x1b[D'; break;
|
|
}
|
|
} else if (input) { seq = input; }
|
|
if (seq && _ws && _ws.readyState === WebSocket.OPEN) {
|
|
var bytes = _encoder ? _encoder.encode(seq) : new Uint8Array(0);
|
|
_ws.send(bytes);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ——— Expose to app.js ————————————————————————————————————————
|
|
window._openTerminal = openTerminal;
|
|
window._switchTerminal = openTerminal; // same function — server-side switching
|
|
window._closeTerminal = closeTerminal;
|
|
window._closeAllTerminals = closeTerminal; // no cache — same as close
|
|
window._destroyCachedTerminal = function() {}; // no-op — no cache
|
|
window._isTerminalCached = function() { return false; }; // no cache
|
|
window._openSearch = _openSearch;
|
|
window._closeSearch = _closeSearch;
|
|
window._setTerminalFontSize = setTerminalFontSize;
|
|
```
|
|
|
|
**Step 2: Verify the file is syntactically valid**
|
|
|
|
Run:
|
|
```bash
|
|
node -c muxplex/frontend/terminal.js
|
|
```
|
|
Expected: No syntax errors.
|
|
|
|
**Step 3: Commit**
|
|
```bash
|
|
git add muxplex/frontend/terminal.js && git commit -m "feat: rewrite terminal.js for muxterm — single WS, control protocol, no cache"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 12: Update app.js openSession — Remove /connect POST
|
|
|
|
**Files:**
|
|
- Modify: `muxplex/frontend/app.js` (openSession at line 2840, closeSession at line 2963)
|
|
|
|
**Step 1: Update openSession**
|
|
|
|
In `openSession` (line 2840), replace the block that does the `/connect` POST and cache check (lines 2922-2940) with a simpler version that doesn't POST to `/connect`:
|
|
|
|
Find this block (approximately lines 2922-2940):
|
|
```javascript
|
|
// Skip /connect POST if the session is already cached with a live WebSocket —
|
|
// this is the biggest win, bypassing the entire backend round-trip.
|
|
var sessionKey = _deviceId ? (_deviceId + ':' + name) : name;
|
|
var isCached = window._isTerminalCached && window._isTerminalCached(sessionKey);
|
|
|
|
if (!isCached) {
|
|
// Spawn ttyd for this session — ensures correct session after service restart or page restore
|
|
try {
|
|
if (_deviceId !== '') {
|
|
// Remote session: route connect POST through same-origin federation proxy
|
|
await api('POST', '/api/federation/' + encodeURIComponent(_deviceId) + '/connect/' + encodeURIComponent(name));
|
|
} else {
|
|
await api('POST', '/api/sessions/' + encodeURIComponent(name) + '/connect');
|
|
}
|
|
} catch (err) {
|
|
showToast(err.message || 'Connection failed');
|
|
return closeSession();
|
|
}
|
|
}
|
|
```
|
|
|
|
Replace with:
|
|
```javascript
|
|
// Remote sessions still need the federation connect POST (remote may use ttyd).
|
|
// Local sessions don't need /connect — muxterm handles attach via WebSocket.
|
|
if (_deviceId !== '') {
|
|
try {
|
|
await api('POST', '/api/federation/' + encodeURIComponent(_deviceId) + '/connect/' + encodeURIComponent(name));
|
|
} catch (err) {
|
|
showToast(err.message || 'Connection failed');
|
|
return closeSession();
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Update closeSession**
|
|
|
|
In `closeSession` (line 2963), remove the `DELETE /api/sessions/current` call since muxterm manages PTY lifecycle. Find:
|
|
```javascript
|
|
// Fire-and-forget DELETE — skip for remote sessions (they don't need to know we stopped watching)
|
|
if (_viewingRemoteId === '') {
|
|
api('DELETE', '/api/sessions/current').catch(function() {});
|
|
}
|
|
```
|
|
|
|
Remove those lines entirely — the detach message is already sent by `closeTerminal()` in the new terminal.js.
|
|
|
|
**Step 3: Verify syntax**
|
|
|
|
Run:
|
|
```bash
|
|
node -c muxplex/frontend/app.js
|
|
```
|
|
Expected: No syntax errors.
|
|
|
|
**Step 4: Commit**
|
|
```bash
|
|
git add muxplex/frontend/app.js && git commit -m "feat: update openSession/closeSession — no /connect POST for local sessions"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 13: Update Frontend Tests
|
|
|
|
**Files:**
|
|
- Modify: `muxplex/tests/test_frontend_js.py`
|
|
|
|
**Step 1: Identify tests that reference deleted code**
|
|
|
|
Run:
|
|
```bash
|
|
grep -n "_terminalCache\|switchTerminal\|_cacheOrder\|_encodePayload\|0x30\|0x31\|ttyd protocol\|tty.*subprotocol\|/connect" muxplex/tests/test_frontend_js.py
|
|
```
|
|
|
|
This will find tests that reference the old terminal cache, ttyd protocol encoding, or /connect POST.
|
|
|
|
**Step 2: Update or remove each failing test**
|
|
|
|
For each test that references deleted code:
|
|
- If it tests terminal cache behavior (`_terminalCache`, `switchTerminal` cache path, `_cacheOrder`, `destroyCachedEntry`, `_MAX_CACHED`): **delete the test** — no cache exists anymore.
|
|
- If it tests ttyd protocol encoding (`0x30`, `0x31`, `_encodePayload`): **delete the test** — no prefix encoding in muxterm protocol.
|
|
- If it tests the `/connect` POST in openSession: **update** to verify it's NOT called for local sessions (only for remote/federation sessions).
|
|
- If it tests `connectWebSocket` URL construction: **update** to verify the muxterm WebSocket URL pattern (direct to muxterm port, not `/terminal/ws` proxy path).
|
|
|
|
**Step 3: Add new tests for the muxterm protocol**
|
|
|
|
Add tests verifying the new terminal.js:
|
|
|
|
```python
|
|
# ——— terminal.js: muxterm protocol (no ttyd prefix encoding) ———————
|
|
|
|
def test_terminal_js_no_ttyd_prefix_encoding() -> None:
|
|
"""terminal.js must NOT use ttyd 0x30/0x31 prefix encoding."""
|
|
assert "0x30" not in _TERMINAL_JS, (
|
|
"terminal.js must not use 0x30 prefix — muxterm uses raw binary frames"
|
|
)
|
|
assert "0x31" not in _TERMINAL_JS, (
|
|
"terminal.js must not use 0x31 prefix — muxterm uses JSON text frames for resize"
|
|
)
|
|
|
|
|
|
def test_terminal_js_no_terminal_cache() -> None:
|
|
"""terminal.js must not have terminal instance cache."""
|
|
assert "_terminalCache" not in _TERMINAL_JS, (
|
|
"terminal.js must not have _terminalCache — muxterm does server-side switching"
|
|
)
|
|
assert "_cacheOrder" not in _TERMINAL_JS, (
|
|
"terminal.js must not have _cacheOrder — no client-side cache"
|
|
)
|
|
|
|
|
|
def test_terminal_js_uses_json_attach() -> None:
|
|
"""terminal.js must send JSON attach messages for session switching."""
|
|
assert '{ attach:' in _TERMINAL_JS or '"attach"' in _TERMINAL_JS or 'attach:' in _TERMINAL_JS, (
|
|
"terminal.js must send {attach: sessionName} for session switching"
|
|
)
|
|
|
|
|
|
def test_terminal_js_uses_json_resize() -> None:
|
|
"""terminal.js must send JSON resize messages."""
|
|
assert 'resize' in _TERMINAL_JS and 'cols' in _TERMINAL_JS and 'rows' in _TERMINAL_JS, (
|
|
"terminal.js must send {resize: {cols, rows}} for terminal resize"
|
|
)
|
|
|
|
|
|
def test_terminal_js_fetches_terminal_token() -> None:
|
|
"""terminal.js must fetch /api/terminal-token for muxterm auth."""
|
|
assert "/api/terminal-token" in _TERMINAL_JS, (
|
|
"terminal.js must fetch /api/terminal-token to get muxterm auth token"
|
|
)
|
|
```
|
|
|
|
**Step 4: Run the frontend tests**
|
|
|
|
Run:
|
|
```bash
|
|
.venv/bin/python -m pytest muxplex/tests/test_frontend_js.py -x -q --timeout=30
|
|
```
|
|
Expected: All pass. Fix any failures from tests that still reference deleted code.
|
|
|
|
**Step 5: Commit**
|
|
```bash
|
|
git add muxplex/tests/test_frontend_js.py && git commit -m "test: update frontend tests for muxterm protocol, remove cache/ttyd tests"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 14: Run Full Test Suite + Fix Remaining Issues
|
|
|
|
**Files:**
|
|
- Potentially any file with remaining breakage
|
|
|
|
**Step 1: Run the complete test suite**
|
|
|
|
Run:
|
|
```bash
|
|
.venv/bin/python -m pytest muxplex/tests/ -x -q --timeout=30
|
|
```
|
|
|
|
**Step 2: Fix any failures**
|
|
|
|
Common issues to watch for:
|
|
- Tests importing from `muxplex.ttyd` (deleted) — remove those tests or imports
|
|
- Tests referencing `terminal_ws_proxy` (deleted) — remove
|
|
- Tests referencing `connect_session` endpoint (deleted) — remove
|
|
- Tests that check for `/terminal/ws` route existence — remove or update
|
|
- Tests that mock `get_or_spawn` or `kill_session` — remove
|
|
- Tests checking `delete_current_session` calls `kill_session` — update
|
|
|
|
**Step 3: Run Go tests too**
|
|
|
|
Run:
|
|
```bash
|
|
cd muxterm && go test -short -v ./...
|
|
```
|
|
Expected: All Go tests pass.
|
|
|
|
**Step 4: Final commit**
|
|
```bash
|
|
git add -A && git commit -m "fix: resolve remaining test failures from muxterm migration"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 15: Build and Smoke Test
|
|
|
|
**Files:**
|
|
- No new files
|
|
|
|
**Step 1: Build the muxterm binary**
|
|
|
|
Run:
|
|
```bash
|
|
cd muxterm && go build -o muxterm .
|
|
```
|
|
Expected: Binary compiles successfully.
|
|
|
|
**Step 2: Run the full test suite one more time**
|
|
|
|
Run:
|
|
```bash
|
|
.venv/bin/python -m pytest muxplex/tests/ -x -q --timeout=30
|
|
cd muxterm && go test -short -v ./...
|
|
```
|
|
Expected: All tests pass in both Python and Go.
|
|
|
|
**Step 3: Manual smoke test (if tmux is available)**
|
|
|
|
```bash
|
|
# Create a test tmux session
|
|
tmux new-session -d -s test-muxterm
|
|
|
|
# Start muxterm
|
|
cd muxterm && MUXTERM_SECRET=smoke-test ./muxterm &
|
|
MUXTERM_PID=$!
|
|
|
|
# Verify health
|
|
curl -s http://127.0.0.1:7682/health
|
|
# Expected: {"status":"ok"}
|
|
|
|
# Clean up
|
|
kill $MUXTERM_PID
|
|
tmux kill-session -t test-muxterm
|
|
```
|
|
|
|
**Step 4: Final commit with summary**
|
|
```bash
|
|
git add -A && git commit -m "feat: Phase 2 complete — muxterm Go binary replaces ttyd + Python WS proxy
|
|
|
|
- muxterm: Go binary with PTY pool, WebSocket server, HMAC token auth
|
|
- Python: token endpoint, process supervision, ttyd.py deleted
|
|
- Frontend: single WS connection, JSON control protocol, no terminal cache
|
|
- Deleted: ttyd.py, WS proxy, /connect endpoint, terminal cache"
|
|
```
|