first commit

This commit is contained in:
2026-08-14 23:41:57 +08:00
commit 086803a8dd
471 changed files with 91938 additions and 0 deletions
+602
View File
@@ -0,0 +1,602 @@
package remotecontrol
import (
"context"
"embed"
"errors"
"fmt"
"io/fs"
"net"
"net/http"
"sync"
"time"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
)
// spaFiles holds the embedded browser SPA. The placeholder shipped here is
// fleshed out by the web-SPA node; the server only needs a valid FS to embed.
//
//go:embed web
var spaFiles embed.FS
// Default configuration values (see tasks/spec-remote-control.md §3.2).
const (
defaultPairTTL = 10 * time.Minute
cookieName = "pigo_rc"
maxFrameBytes = 64 * 1024
// outputFlushInterval is the coalescing tick: adjacent output writes buffered
// within one interval are sent as a single frame, so a fast stream produces a
// few batched frames rather than one per write, while still hitting the PRD's
// sub-100ms latency target (§8.2).
outputFlushInterval = 16 * time.Millisecond
// outputQueueMax bounds the pending buffer. A producer that would push it past
// this blocks until the pump drains, applying backpressure — the terminal
// mirror must not drop bytes (§5.4), so we never discard, only slow down.
outputQueueMax = 256 * 1024
// replayRingBytes bounds the rolling output history kept for late-join /
// reconnect replay so a freshly connected browser sees recent context (§8.2).
replayRingBytes = 256 * 1024
)
// Config controls a remote-control server instance.
type Config struct {
PairTTL time.Duration // pairing-token lifetime; 0 → defaultPairTTL
Host string // LAN IP for the printed URL; "" → auto-detect
Port int // 0 → auto-pick, fall back on conflict
ConfirmTimeout time.Duration // 0 → wait forever for a remote decision
// OnClientConnect, if set, is invoked (on the WebSocket goroutine) when a
// browser pairs and connects, with the client's remote address. The REPL uses
// it to print a one-line terminal notice so the operator notices remote access
// (§7.3). It must not block for long.
OnClientConnect func(remoteAddr string)
// OnClientDisconnect, if set, is invoked when the controlling client's
// WebSocket closes. It must not block for long.
OnClientDisconnect func()
}
// Handler receives frames the browser sends. The REPL bridge implements it;
// tests supply a fake. Callbacks run on the WebSocket read goroutine and must
// not block for long.
type Handler interface {
// OnInput is called when the client submits a prompt line.
OnInput(text string)
// OnDecide is called when the client answers a confirmation request.
OnDecide(confirmID string, approve, always bool)
}
// serverState tracks the lifecycle for gating requests.
type serverState int
const (
stateIdle serverState = iota
stateListening
stateEnded
)
// Server is an in-process HTTP + WebSocket server that mirrors the CLI session
// to a single paired browser on the LAN.
type Server struct {
cfg Config
tokens *TokenStore
handler Handler
spa fs.FS
mu sync.Mutex
state serverState
ln net.Listener
httpServer *http.Server
host string
port int
client *websocket.Conn
clientCtx context.Context
clientCancel context.CancelFunc
writeMu sync.Mutex // serializes writes to client
// Output coalescing + backpressure + replay (#445). outMu guards all of the
// fields below; outCond signals both the pump (new pending output) and any
// producer blocked by backpressure (pump drained pending). The pump is the
// sole sender of output frames, so replay and live output can never interleave
// or duplicate.
outMu sync.Mutex
outCond *sync.Cond
outPending []byte // coalesced, not-yet-sent output
outClosed bool // set on Stop; releases blocked producers
needReplay bool // a fresh client connected; next flush replays the ring
ring ringBuffer // rolling last-N bytes for late-join replay
pumpCancel context.CancelFunc
pumpDone chan struct{}
}
// NewServer builds a server. handler may be nil (frames from the client are
// then ignored), which is useful for output-only smoke tests.
func NewServer(cfg Config, handler Handler) *Server {
if cfg.PairTTL <= 0 {
cfg.PairTTL = defaultPairTTL
}
sub, err := fs.Sub(spaFiles, "web")
if err != nil {
// The embed path is a compile-time constant, so this cannot fail in a
// correctly built binary; fall back to the raw FS defensively.
sub = spaFiles
}
s := &Server{
cfg: cfg,
tokens: NewTokenStore(),
handler: handler,
spa: sub,
state: stateIdle,
ring: ringBuffer{max: replayRingBytes},
}
s.outCond = sync.NewCond(&s.outMu)
return s
}
// SetHandler installs the handler that receives client frames. It exists to
// break the construction cycle between the server (a Bridge's Sink) and the
// Bridge (the server's Handler): build the server, build the Bridge with the
// server as Sink, then SetHandler(bridge). It must be called before Start so
// the WebSocket read goroutine never observes a mid-flight swap.
func (s *Server) SetHandler(h Handler) {
s.mu.Lock()
s.handler = h
s.mu.Unlock()
}
// Start resolves a LAN address, binds a listener, mints a one-time pairing
// token, begins serving, and returns the full pairing URL to print. It is
// non-blocking: the HTTP server runs on its own goroutine.
func (s *Server) Start() (string, error) {
host := s.cfg.Host
if host == "" {
detected, err := DetectRoutableIP()
if err != nil {
return "", err
}
host = detected
}
ln, port, err := ListenFreePort(host, s.cfg.Port)
if err != nil {
return "", err
}
token, err := s.tokens.NewPairing(s.cfg.PairTTL)
if err != nil {
ln.Close()
return "", err
}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", s.handleHealthz)
mux.HandleFunc("/pair", s.handlePair)
mux.HandleFunc("/ws", s.handleWS)
mux.HandleFunc("/", s.handleRoot)
pumpCtx, pumpCancel := context.WithCancel(context.Background())
pumpDone := make(chan struct{})
s.mu.Lock()
s.ln = ln
s.host = host
s.port = port
s.httpServer = &http.Server{Handler: mux}
s.state = stateListening
s.pumpCancel = pumpCancel
s.pumpDone = pumpDone
s.mu.Unlock()
go s.httpServer.Serve(ln)
go s.outputPump(pumpCtx, pumpDone)
return fmt.Sprintf("http://%s:%d/pair?t=%s", host, port, token), nil
}
// Stop notifies the client, closes the WebSocket, shuts down the HTTP server,
// and wipes all tokens. It is safe to call more than once.
func (s *Server) Stop(ctx context.Context) error {
s.mu.Lock()
if s.state == stateEnded {
s.mu.Unlock()
return nil
}
s.state = stateEnded
srv := s.httpServer
client := s.client
cancel := s.clientCancel
pumpCancel := s.pumpCancel
pumpDone := s.pumpDone
s.mu.Unlock()
// Release any producer blocked on backpressure, then stop the output pump and
// wait for its final flush so the last buffered output reaches the client
// before we announce the session end.
s.outMu.Lock()
s.outClosed = true
s.outCond.Broadcast()
s.outMu.Unlock()
if pumpCancel != nil {
pumpCancel()
<-pumpDone
}
if client != nil {
// Best-effort: tell the browser the session ended, then close.
s.writeFrame(client, Frame{Type: FrameStatus, State: StatusEnded})
client.Close(websocket.StatusNormalClosure, "session ended")
}
if cancel != nil {
cancel()
}
s.tokens.Clear()
if srv != nil {
return srv.Shutdown(ctx)
}
return nil
}
func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}
// handlePair validates the one-time token, issues a session cookie, and
// redirects to the SPA root. Invalid/expired/used tokens get 401.
func (s *Server) handlePair(w http.ResponseWriter, r *http.Request) {
if s.ended() {
http.Error(w, "session ended", http.StatusGone)
return
}
token := r.URL.Query().Get("t")
if token == "" || !s.tokens.ConsumePairing(token) {
http.Error(w, "pairing link invalid or expired", http.StatusUnauthorized)
return
}
cred, err := s.tokens.IssueSession()
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: cred,
Path: "/",
HttpOnly: true,
// Lax, not Strict: the pairing link is opened as a top-level navigation from
// a QR scan (no same-site referrer), and some mobile browsers drop a Strict
// cookie across the /pair→/ redirect that follows — sending the browser to
// the unauthenticated page. Lax is sent on top-level GET navigations (which
// is all this cookie is read on) while still withholding it from cross-site
// subrequests, so it fixes the redirect without weakening the guard.
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/", http.StatusFound)
}
// handleRoot serves the SPA to authenticated clients and an instructional page
// otherwise.
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
if s.ended() {
http.Error(w, "session ended", http.StatusGone)
return
}
if !s.authed(r) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`<!doctype html><meta charset="utf-8">` +
`<p>Open the pairing link printed in your terminal to connect.</p>`))
return
}
http.FileServer(http.FS(s.spa)).ServeHTTP(w, r)
}
// handleWS upgrades to a WebSocket for the single controlling client.
func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
if s.ended() {
http.Error(w, "session ended", http.StatusGone)
return
}
if !s.authed(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
s.mu.Lock()
if s.client != nil {
s.mu.Unlock()
http.Error(w, "another device is controlling this session", http.StatusConflict)
return
}
s.mu.Unlock()
conn, err := websocket.Accept(w, r, nil)
if err != nil {
return
}
conn.SetReadLimit(maxFrameBytes)
ctx, cancel := context.WithCancel(r.Context())
s.mu.Lock()
s.client = conn
s.clientCtx = ctx
s.clientCancel = cancel
s.mu.Unlock()
// Announce connection to the client, then arm a replay so the pump resends the
// rolling scrollback (last replayRingBytes) as the first output frame. Live
// output produced after this point is appended behind the snapshot by the
// pump, so ordering is preserved and nothing is duplicated.
s.writeFrame(conn, Frame{Type: FrameStatus, State: StatusConnected})
s.outMu.Lock()
s.needReplay = true
s.outCond.Signal()
s.outMu.Unlock()
// Notify the terminal operator that a client connected (§7.3).
if s.cfg.OnClientConnect != nil {
s.cfg.OnClientConnect(r.RemoteAddr)
}
defer func() {
cancel()
s.mu.Lock()
if s.client == conn {
s.client = nil
s.clientCtx = nil
s.clientCancel = nil
}
s.mu.Unlock()
conn.Close(websocket.StatusNormalClosure, "")
if s.cfg.OnClientDisconnect != nil {
s.cfg.OnClientDisconnect()
}
}()
for {
var f Frame
if err := wsjson.Read(ctx, conn, &f); err != nil {
return // client disconnected or context cancelled
}
s.dispatch(f)
}
}
// dispatch routes an inbound client frame to the handler.
func (s *Server) dispatch(f Frame) {
if s.handler == nil {
return
}
switch f.Type {
case FrameInput:
s.handler.OnInput(f.Text)
case FrameDecide:
s.handler.OnDecide(f.ConfirmID, f.Approve, f.Always)
}
}
// Broadcast sends a frame to the connected client, if any. It is safe to call
// from any goroutine.
func (s *Server) Broadcast(f Frame) {
s.mu.Lock()
conn := s.client
s.mu.Unlock()
if conn == nil {
return
}
s.writeFrame(conn, f)
}
// SendOutput streams session text to the client. It never drops bytes: the text
// is appended to the ring (for replay) and to the pending buffer that the pump
// coalesces and flushes. If pending output has grown past outputQueueMax the
// call blocks until the pump drains it, applying backpressure to the producer
// rather than discarding output (§5.4, §8.4). It returns immediately once the
// server is stopping so a shutting-down producer never wedges.
func (s *Server) SendOutput(text string) {
if text == "" {
return
}
b := []byte(text)
s.outMu.Lock()
defer s.outMu.Unlock()
// Always record into the ring so a late-joining / reconnecting client can be
// replayed the recent scrollback, even while a producer is momentarily blocked
// on backpressure below.
s.ring.write(b)
// Backpressure: wait until the pump has drained enough that appending stays
// within the bound. Bail out if the server is stopping.
for !s.outClosed && len(s.outPending) >= outputQueueMax {
s.outCond.Wait()
}
if s.outClosed {
return
}
s.outPending = append(s.outPending, b...)
s.outCond.Signal()
}
// SendConfirm asks the client to approve a risky tool call.
func (s *Server) SendConfirm(confirmID, tool, summary string) {
s.Broadcast(Frame{Type: FrameConfirm, ConfirmID: confirmID, Tool: tool, Summary: summary})
}
// HasClient reports whether a controlling client is currently connected.
func (s *Server) HasClient() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.client != nil
}
// Addr returns the bound host:port, or "" before Start.
func (s *Server) Addr() string {
s.mu.Lock()
defer s.mu.Unlock()
if s.ln == nil {
return ""
}
return net.JoinHostPort(s.host, fmt.Sprint(s.port))
}
func (s *Server) writeFrame(conn *websocket.Conn, f Frame) {
s.writeMu.Lock()
defer s.writeMu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = wsjson.Write(ctx, conn, f)
}
func (s *Server) ended() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.state == stateEnded
}
func (s *Server) authed(r *http.Request) bool {
c, err := r.Cookie(cookieName)
if err != nil {
return false
}
return s.tokens.ValidateSession(c.Value)
}
// outputPump is the sole producer of output frames. It coalesces buffered
// output on a fixed tick and flushes it as a single frame, so a fast stream
// becomes a few batched frames while staying under the latency target: the
// first write after an idle period flushes immediately, and writes arriving
// during the following outputFlushInterval are batched. It also owns replay:
// when a client
// (re)connects, it prepends the ring snapshot before the live pending buffer.
// Being the only writer of output frames, replay and live output can never
// interleave or duplicate. It exits after a final flush when its context is
// cancelled (on Stop).
func (s *Server) outputPump(ctx context.Context, done chan<- struct{}) {
defer close(done)
ticker := time.NewTicker(outputFlushInterval)
defer ticker.Stop()
// A tiny goroutine wakes the Cond wait below whenever the context is
// cancelled, so the pump does not sleep past shutdown.
go func() {
<-ctx.Done()
s.outMu.Lock()
s.outCond.Broadcast()
s.outMu.Unlock()
}()
for {
s.outMu.Lock()
// Wait until there is something to do: pending output, an armed replay, or
// shutdown.
for len(s.outPending) == 0 && !s.needReplay && ctx.Err() == nil {
s.outCond.Wait()
}
replay := s.needReplay
s.needReplay = false
var snapshot []byte
if replay {
snapshot = s.ring.snapshot()
}
// Coalesce: take everything buffered so far as one batch.
pending := s.outPending
s.outPending = nil
// Wake any producer blocked on backpressure now that pending is drained.
s.outCond.Broadcast()
stopping := ctx.Err() != nil
s.outMu.Unlock()
s.mu.Lock()
conn := s.client
s.mu.Unlock()
if conn != nil {
// Replay first so the reconnecting browser restores context, then the
// live batch. The ring already contains everything appended via
// SendOutput, so on a fresh connection the snapshot covers pending too;
// avoid double-sending by preferring the snapshot when it is present.
if len(snapshot) > 0 {
s.writeFrame(conn, Frame{Type: FrameOutput, Text: string(snapshot)})
} else if len(pending) > 0 {
s.writeFrame(conn, Frame{Type: FrameOutput, Text: string(pending)})
}
}
if stopping {
// Final drain done; exit.
return
}
// Rate the loop on the ticker so bursts coalesce instead of spinning.
select {
case <-ctx.Done():
// Loop once more to perform the final flush of anything buffered while
// we were writing above.
s.finalFlush()
return
case <-ticker.C:
}
}
}
// finalFlush drains any remaining pending output once during shutdown so the
// last bytes reach the client before the session-ended notice.
func (s *Server) finalFlush() {
s.outMu.Lock()
pending := s.outPending
s.outPending = nil
s.outCond.Broadcast()
s.outMu.Unlock()
if len(pending) == 0 {
return
}
s.mu.Lock()
conn := s.client
s.mu.Unlock()
if conn != nil {
s.writeFrame(conn, Frame{Type: FrameOutput, Text: string(pending)})
}
}
// ringBuffer is a bounded rolling byte buffer holding the most recent max bytes
// of session output for late-join / reconnect replay. It is not safe for
// concurrent use; the server guards it with outMu.
type ringBuffer struct {
buf []byte
max int
}
// write appends p, discarding oldest bytes so the buffer never exceeds max.
func (r *ringBuffer) write(p []byte) {
if r.max <= 0 {
return
}
if len(p) >= r.max {
// Keep only the trailing max bytes of the new data.
r.buf = append(r.buf[:0], p[len(p)-r.max:]...)
return
}
r.buf = append(r.buf, p...)
if len(r.buf) > r.max {
// Drop the oldest overflow. Copy down so the backing array does not grow
// without bound over the life of the session.
drop := len(r.buf) - r.max
r.buf = append(r.buf[:0], r.buf[drop:]...)
}
}
// snapshot returns a copy of the current contents.
func (r *ringBuffer) snapshot() []byte {
if len(r.buf) == 0 {
return nil
}
out := make([]byte, len(r.buf))
copy(out, r.buf)
return out
}
// ErrNotStarted is returned by operations that require a running server.
var ErrNotStarted = errors.New("remotecontrol: server not started")