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(`` + `

Open the pairing link printed in your terminal to connect.

`)) 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")