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

@@ -305,6 +305,50 @@ func (o *Orchestrator) Spawn(count int) error {
return nil
}
// SpawnSingle spawns exactly one new clone and returns its ID.
// It is safe to call from multiple goroutines (nextCloneID is serialised by the
// filesystem scan, and each clone gets its own directory/tap).
func (o *Orchestrator) SpawnSingle() (int, error) {
goldenDir := o.goldenDir()
for _, f := range []string{"vmstate", "mem"} {
if _, err := os.Stat(filepath.Join(goldenDir, f)); err != nil {
return 0, fmt.Errorf("golden %s not found — run golden first", f)
}
}
os.MkdirAll(o.clonesDir(), 0o755)
os.MkdirAll(o.pidsDir(), 0o755)
if o.cfg.Bridge != "none" {
if err := o.setupBridge(); err != nil {
return 0, err
}
}
id := o.nextCloneID()
if err := o.spawnOne(id); err != nil {
return 0, err
}
return id, nil
}
// KillClone kills a single clone by ID: terminates its proxy process,
// destroys its tap device, and removes its working directory.
func (o *Orchestrator) KillClone(id int) error {
pidFile := filepath.Join(o.pidsDir(), fmt.Sprintf("clone-%d.proxy.pid", id))
if data, err := os.ReadFile(pidFile); err == nil {
if pid, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil {
if p, err := os.FindProcess(pid); err == nil {
_ = p.Kill()
o.log.Infof("clone %d: killed proxy pid %d", id, pid)
}
}
os.Remove(pidFile) //nolint:errcheck
}
tapName := fmt.Sprintf("fctap%d", id)
destroyTap(tapName)
os.RemoveAll(filepath.Join(o.clonesDir(), strconv.Itoa(id))) //nolint:errcheck
o.log.Infof("clone %d: destroyed", id)
return nil
}
func (o *Orchestrator) spawnOne(id int) error {
goldenDir := o.goldenDir()
cloneDir := filepath.Join(o.clonesDir(), strconv.Itoa(id))