feat: add web terminal UI with WebSocket console and clone management API

Introduces a browser-based terminal interface backed by xterm.js, served
directly from the binary via an embedded HTML asset.

New HTTP server (`serve [addr]`, default :8080):
  GET  /              — xterm.js terminal UI; ?id=N selects a clone
  GET  /clones        — JSON list of running clone IDs
  POST /clones        — spawn a new clone; returns {"id": N}
  DELETE /clones/{id} — destroy a clone by ID
  GET  /ws/{id}       — WebSocket console for clone {id}
                        binary frames = raw PTY I/O
                        text frames   = JSON resize {"rows":N,"cols":M}

Supporting changes:
- orchestrator: add SpawnSingle() and KillClone(id) for per-clone lifecycle
  management from the HTTP layer
- console: add a resize sideband Unix socket (console-resize.sock) that
  accepts newline-delimited JSON {"rows","cols"} messages and applies them
  to the PTY master via pty.Setsize; the WebSocket handler writes to this
  socket on text frames so browser window resizes propagate into the VM
- deps: add gorilla/websocket v1.5.3

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 10:53:41 +00:00
parent 9089cbdbe9
commit 82c11dd2f8
7 changed files with 630 additions and 0 deletions

View File

@@ -1,7 +1,9 @@
package orchestrator
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net"
@@ -150,6 +152,15 @@ func RunConsoleProxy(cfg Config, id int, tapName string) error {
return fmt.Errorf("listen on console socket: %w", err)
}
// --- Create resize sideband socket ---
resizeSockPath := filepath.Join(cloneDir, "console-resize.sock")
os.Remove(resizeSockPath) //nolint:errcheck
resizeListener, err := net.Listen("unix", resizeSockPath)
if err != nil {
logger.Warnf("could not create resize socket, terminal resize will be unavailable: %v", err)
resizeListener = nil
}
// --- Serve until VM exits ---
vmDone := make(chan struct{})
go func() {
@@ -157,11 +168,18 @@ func RunConsoleProxy(cfg Config, id int, tapName string) error {
close(vmDone)
}()
if resizeListener != nil {
go serveResize(resizeListener, ptm, vmDone, logger)
}
serveConsole(listener, ptm, vmDone, logger)
listener.Close()
if resizeListener != nil {
resizeListener.Close()
}
ptm.Close()
os.Remove(consoleSockPath) //nolint:errcheck
os.Remove(resizeSockPath) //nolint:errcheck
if tapName != "" {
destroyTap(tapName)
@@ -171,6 +189,48 @@ func RunConsoleProxy(cfg Config, id int, tapName string) error {
return nil
}
// resizeMsg is the JSON payload sent over the resize sideband socket.
type resizeMsg struct {
Rows uint16 `json:"rows"`
Cols uint16 `json:"cols"`
}
// serveResize accepts connections on the resize sideband listener and applies
// PTY window size changes as JSON resize messages arrive.
func serveResize(listener net.Listener, ptm *os.File, vmDone <-chan struct{}, logger *log.Entry) {
go func() {
<-vmDone
listener.Close()
}()
for {
conn, err := listener.Accept()
if err != nil {
return
}
go handleResize(conn, ptm, logger)
}
}
// handleResize reads newline-delimited JSON resize messages from conn and
// applies each one to the PTY master.
func handleResize(conn net.Conn, ptm *os.File, logger *log.Entry) {
defer conn.Close()
scanner := bufio.NewScanner(conn)
for scanner.Scan() {
var msg resizeMsg
if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil {
logger.Warnf("resize: bad message: %v", err)
continue
}
if msg.Rows == 0 || msg.Cols == 0 {
continue
}
if err := pty.Setsize(ptm, &pty.Winsize{Rows: msg.Rows, Cols: msg.Cols}); err != nil {
logger.Warnf("resize pty: %v", err)
}
}
}
// atomicWriter wraps an io.Writer behind a read/write lock so it can be
// swapped between connections without holding the lock during writes.
type atomicWriter struct {