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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user