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
+180
View File
@@ -0,0 +1,180 @@
package remotecontrol
import (
"context"
"io"
"sync"
)
// Sink is the subset of the server the bridge needs. *Server satisfies it. It
// lets the bridge stream output, request confirmations, and check connectivity
// without importing the HTTP layer directly (and makes the seam unit-testable).
type Sink interface {
SendOutput(text string)
SendConfirm(confirmID, tool, summary string)
HasClient() bool
}
// Decision is the outcome of a confirmation prompt.
type Decision struct {
Approve bool
Always bool
}
// remoteInputBuffer bounds how many un-consumed remote prompts we hold before
// dropping the oldest. Backpressure/coalescing is refined in the hardening node
// (#445); here we simply avoid blocking the WebSocket read goroutine.
const remoteInputBuffer = 32
// Bridge is the seam that merges the local terminal with a remote browser
// controlling the same REPL session. It:
//
// - tees session output to the remote client (OutputWriter),
// - surfaces remote-submitted prompts as an input channel (RemoteInput),
// - routes confirmation prompts to the remote and awaits a decision (Confirm),
// resolvable either remotely (OnDecide) or locally (ResolveConfirm).
//
// A nil *Bridge (deps.remote == nil) means remote control is off; callers must
// guard with Enabled or simply not construct one, so existing behavior is
// byte-identical.
//
// Bridge implements the server Handler interface (OnInput, OnDecide).
type Bridge struct {
sink Sink
inputs chan string
mu sync.Mutex
pending map[string]chan Decision
nextID uint64
}
// NewBridge builds a bridge over sink.
func NewBridge(sink Sink) *Bridge {
return &Bridge{
sink: sink,
inputs: make(chan string, remoteInputBuffer),
pending: make(map[string]chan Decision),
}
}
// Enabled reports whether remote control is active with a connected client.
func (b *Bridge) Enabled() bool {
return b != nil && b.sink != nil && b.sink.HasClient()
}
// OutputWriter returns an io.Writer that streams everything written to it to the
// remote client as output frames. It is intended to be combined with the local
// terminal writer via io.MultiWriter, so local rendering is unchanged and the
// remote receives a copy. Writes never fail and never block on the network
// (delivery is best-effort via the sink).
func (b *Bridge) OutputWriter() io.Writer {
return outputWriter{b}
}
type outputWriter struct{ b *Bridge }
func (w outputWriter) Write(p []byte) (int, error) {
if w.b != nil && w.b.sink != nil {
w.b.sink.SendOutput(string(p))
}
return len(p), nil
}
// RemoteInput exposes prompts submitted by the remote client. The REPL input
// loop selects on this channel alongside local stdin.
func (b *Bridge) RemoteInput() <-chan string { return b.inputs }
// OnInput implements Handler: a remote prompt. Non-blocking so the WebSocket
// read goroutine is never stalled; if the buffer is full the oldest queued
// prompt is dropped to make room (refined in #445).
func (b *Bridge) OnInput(text string) {
for {
select {
case b.inputs <- text:
return
default:
// Drop the oldest to make room, then retry.
select {
case <-b.inputs:
default:
}
}
}
}
// Confirm requests approval for a risky tool call from the remote client and
// blocks until the client decides, the context is cancelled (e.g. answered
// locally instead), or a deadline in ctx fires. The returned bool is true only
// when the decision came from the remote client; on ctx cancellation it is
// false and the caller should fall back to the local answer.
func (b *Bridge) Confirm(ctx context.Context, tool, summary string) (Decision, bool) {
id := b.register()
defer b.unregister(id)
b.sink.SendConfirm(id, tool, summary)
b.mu.Lock()
ch := b.pending[id]
b.mu.Unlock()
select {
case d := <-ch:
return d, true
case <-ctx.Done():
return Decision{}, false
}
}
// OnDecide implements Handler: the remote client's answer to a confirmation.
func (b *Bridge) OnDecide(confirmID string, approve, always bool) {
b.ResolveConfirm(confirmID, approve, always)
}
// ResolveConfirm delivers a decision to a pending Confirm. It returns true if a
// waiting Confirm was resolved. Both the remote path (OnDecide) and the local
// terminal path may call it; the first delivery wins and later ones are no-ops.
func (b *Bridge) ResolveConfirm(confirmID string, approve, always bool) bool {
b.mu.Lock()
ch, ok := b.pending[confirmID]
b.mu.Unlock()
if !ok {
return false
}
select {
case ch <- Decision{Approve: approve, Always: always}:
return true
default:
return false // already resolved
}
}
func (b *Bridge) register() string {
b.mu.Lock()
defer b.mu.Unlock()
b.nextID++
id := "c" + itoa(b.nextID)
b.pending[id] = make(chan Decision, 1)
return id
}
func (b *Bridge) unregister(id string) {
b.mu.Lock()
delete(b.pending, id)
b.mu.Unlock()
}
// itoa avoids importing strconv for a single small conversion.
func itoa(n uint64) string {
if n == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
return string(buf[i:])
}
+192
View File
@@ -0,0 +1,192 @@
package remotecontrol
import (
"context"
"sync"
"testing"
"time"
)
type fakeSink struct {
mu sync.Mutex
outputs []string
confirms []confirmReq
connected bool
}
type confirmReq struct {
id, tool, summary string
}
func (f *fakeSink) SendOutput(text string) {
f.mu.Lock()
defer f.mu.Unlock()
f.outputs = append(f.outputs, text)
}
func (f *fakeSink) SendConfirm(id, tool, summary string) {
f.mu.Lock()
defer f.mu.Unlock()
f.confirms = append(f.confirms, confirmReq{id, tool, summary})
}
func (f *fakeSink) HasClient() bool {
f.mu.Lock()
defer f.mu.Unlock()
return f.connected
}
func (f *fakeSink) lastConfirmID() string {
f.mu.Lock()
defer f.mu.Unlock()
if len(f.confirms) == 0 {
return ""
}
return f.confirms[len(f.confirms)-1].id
}
func TestBridgeOutputWriterTees(t *testing.T) {
sink := &fakeSink{}
b := NewBridge(sink)
w := b.OutputWriter()
n, err := w.Write([]byte("hello world"))
if err != nil || n != len("hello world") {
t.Fatalf("Write = (%d,%v), want (%d,nil)", n, err, len("hello world"))
}
sink.mu.Lock()
defer sink.mu.Unlock()
if len(sink.outputs) != 1 || sink.outputs[0] != "hello world" {
t.Fatalf("outputs = %v, want [hello world]", sink.outputs)
}
}
func TestBridgeRemoteInput(t *testing.T) {
b := NewBridge(&fakeSink{})
b.OnInput("do a thing")
select {
case got := <-b.RemoteInput():
if got != "do a thing" {
t.Fatalf("input = %q, want 'do a thing'", got)
}
case <-time.After(time.Second):
t.Fatal("no input delivered")
}
}
func TestBridgeOnInputDropsOldestWhenFull(t *testing.T) {
b := NewBridge(&fakeSink{})
// Fill beyond capacity; must not block and must retain the newest items.
total := remoteInputBuffer + 10
for i := range total {
b.OnInput(itoa(uint64(i)))
}
// Drain and ensure we get exactly buffer-size items, ending at the newest.
var got []string
for {
select {
case v := <-b.RemoteInput():
got = append(got, v)
continue
default:
}
break
}
if len(got) != remoteInputBuffer {
t.Fatalf("drained %d items, want %d", len(got), remoteInputBuffer)
}
if last := got[len(got)-1]; last != itoa(uint64(total-1)) {
t.Fatalf("newest = %q, want %q", last, itoa(uint64(total-1)))
}
}
func TestBridgeConfirmResolvedRemotely(t *testing.T) {
sink := &fakeSink{connected: true}
b := NewBridge(sink)
done := make(chan struct {
d Decision
remote bool
}, 1)
go func() {
d, remote := b.Confirm(context.Background(), "shell", "rm -rf /tmp/x")
done <- struct {
d Decision
remote bool
}{d, remote}
}()
// Wait for the confirm to be sent, then answer it via OnDecide.
deadline := time.Now().Add(time.Second)
for sink.lastConfirmID() == "" {
if time.Now().After(deadline) {
t.Fatal("confirm never sent")
}
time.Sleep(2 * time.Millisecond)
}
b.OnDecide(sink.lastConfirmID(), true, true)
select {
case r := <-done:
if !r.remote {
t.Fatal("remote flag = false, want true")
}
if !r.d.Approve || !r.d.Always {
t.Fatalf("decision = %+v, want approve+always", r.d)
}
case <-time.After(time.Second):
t.Fatal("Confirm did not return")
}
}
func TestBridgeConfirmCancelledByContext(t *testing.T) {
sink := &fakeSink{connected: true}
b := NewBridge(sink)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan bool, 1)
go func() {
_, remote := b.Confirm(ctx, "shell", "ls")
done <- remote
}()
// Let the confirm register, then cancel (simulating a local answer).
deadline := time.Now().Add(time.Second)
for sink.lastConfirmID() == "" {
if time.Now().After(deadline) {
t.Fatal("confirm never sent")
}
time.Sleep(2 * time.Millisecond)
}
cancel()
select {
case remote := <-done:
if remote {
t.Fatal("remote flag = true, want false on ctx cancel")
}
case <-time.After(time.Second):
t.Fatal("Confirm did not return on cancel")
}
}
func TestBridgeResolveConfirmUnknown(t *testing.T) {
b := NewBridge(&fakeSink{})
if b.ResolveConfirm("nope", true, false) {
t.Fatal("ResolveConfirm on unknown id = true, want false")
}
}
func TestBridgeEnabled(t *testing.T) {
var nilBridge *Bridge
if nilBridge.Enabled() {
t.Fatal("nil bridge Enabled = true, want false")
}
sink := &fakeSink{connected: false}
b := NewBridge(sink)
if b.Enabled() {
t.Fatal("Enabled = true with no client, want false")
}
sink.connected = true
if !b.Enabled() {
t.Fatal("Enabled = false with client, want true")
}
}
+116
View File
@@ -0,0 +1,116 @@
package remotecontrol
import (
"errors"
"fmt"
"net"
)
// ErrNoLAN is returned by DetectRoutableIP when no routable, non-loopback IPv4
// address can be found. Binding loopback would produce a URL the phone cannot
// reach, so the caller must surface this rather than fall back silently.
var ErrNoLAN = errors.New("remotecontrol: no routable LAN address found; are you connected to Wi-Fi?")
// ifaceInfo pairs a network interface's flags with its addresses. DetectRoutableIP
// consumes these so it can skip down / loopback / point-to-point (VPN tunnel)
// interfaces before considering their addresses.
type ifaceInfo struct {
flags net.Flags
addrs []net.Addr
}
// ifaceLister returns the host's interfaces with their addresses. It is a package
// variable so tests can inject a fake set without real hardware.
var ifaceLister = defaultIfaceLister
func defaultIfaceLister() ([]ifaceInfo, error) {
ifaces, err := net.Interfaces()
if err != nil {
return nil, err
}
out := make([]ifaceInfo, 0, len(ifaces))
for _, ifi := range ifaces {
addrs, err := ifi.Addrs()
if err != nil {
continue // an interface whose addresses can't be read is unusable
}
out = append(out, ifaceInfo{flags: ifi.Flags, addrs: addrs})
}
return out, nil
}
// DetectRoutableIP returns a routable IPv4 address suitable for embedding in the
// printed pairing URL — one a phone on the same Wi-Fi can actually reach.
//
// It iterates interfaces (not bare addresses) so it can skip the ones that would
// yield an unreachable URL: interfaces that are down, loopback, or point-to-point
// (VPN utun / tunnel links, whose address the phone cannot route to). Among the
// rest it prefers a private LAN address (RFC1918: 10/8, 172.16/12, 192.168/16),
// which is what home/office Wi-Fi hands out; a non-private routable address is
// used only as a fallback when no private one exists. Callers that need a specific
// interface should override via Config.Host. It returns ErrNoLAN when nothing
// routable exists.
func DetectRoutableIP() (string, error) {
ifaces, err := ifaceLister()
if err != nil {
return "", fmt.Errorf("remotecontrol: list interfaces: %w", err)
}
var fallback string
for _, ifi := range ifaces {
if ifi.flags&net.FlagUp == 0 {
continue // interface is down
}
if ifi.flags&net.FlagLoopback != 0 {
continue
}
if ifi.flags&net.FlagPointToPoint != 0 {
continue // VPN / tunnel link — its address isn't reachable from the LAN
}
for _, a := range ifi.addrs {
var ip net.IP
switch v := a.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
default:
continue
}
v4 := ip.To4()
if v4 == nil {
continue // skip IPv6 for the LAN URL
}
if v4.IsLoopback() || v4.IsLinkLocalUnicast() || v4.IsLinkLocalMulticast() || v4.IsUnspecified() {
continue
}
if v4.IsPrivate() {
return v4.String(), nil
}
if fallback == "" {
fallback = v4.String()
}
}
}
if fallback != "" {
return fallback, nil
}
return "", ErrNoLAN
}
// ListenFreePort binds a TCP listener on host. It first tries the requested
// port; if port is 0 or already in use it falls back to a kernel-assigned free
// port (:0). It returns the listener and the actual port bound.
func ListenFreePort(host string, port int) (net.Listener, int, error) {
if port != 0 {
ln, err := net.Listen("tcp", net.JoinHostPort(host, fmt.Sprint(port)))
if err == nil {
return ln, ln.Addr().(*net.TCPAddr).Port, nil
}
// Requested port unavailable — fall through to an auto-assigned one.
}
ln, err := net.Listen("tcp", net.JoinHostPort(host, "0"))
if err != nil {
return nil, 0, fmt.Errorf("remotecontrol: bind %s: %w", host, err)
}
return ln, ln.Addr().(*net.TCPAddr).Port, nil
}
+182
View File
@@ -0,0 +1,182 @@
package remotecontrol
import (
"net"
"testing"
)
// withIfaceLister swaps the package ifaceLister for the duration of a test.
func withIfaceLister(t *testing.T, fn func() ([]ifaceInfo, error)) {
t.Helper()
orig := ifaceLister
ifaceLister = fn
t.Cleanup(func() { ifaceLister = orig })
}
// ipnets wraps IPs as *net.IPNet addresses for an ifaceInfo.
func ipnets(ips ...string) []net.Addr {
out := make([]net.Addr, 0, len(ips))
for _, s := range ips {
out = append(out, &net.IPNet{IP: net.ParseIP(s)})
}
return out
}
func TestDetectRoutableIPPicksRoutableV4(t *testing.T) {
withIfaceLister(t, func() ([]ifaceInfo, error) {
return []ifaceInfo{{
flags: net.FlagUp | net.FlagBroadcast | net.FlagMulticast,
addrs: ipnets("fe80::1", "127.0.0.1", "169.254.1.5", "192.168.1.42", "10.0.0.9"),
}}, nil
})
ip, err := DetectRoutableIP()
if err != nil {
t.Fatalf("DetectRoutableIP: %v", err)
}
if ip != "192.168.1.42" {
t.Fatalf("ip = %q, want 192.168.1.42 (first private v4)", ip)
}
}
// A VPN utun tunnel (point-to-point) whose address sorts before Wi-Fi must be
// skipped so the QR URL points at the LAN address the phone can reach — the
// white-screen bug this replaces.
func TestDetectRoutableIPSkipsVPNPointToPoint(t *testing.T) {
withIfaceLister(t, func() ([]ifaceInfo, error) {
return []ifaceInfo{
{ // loopback
flags: net.FlagUp | net.FlagLoopback,
addrs: ipnets("127.0.0.1"),
},
{ // VPN tunnel — routable but unreachable from the LAN
flags: net.FlagUp | net.FlagPointToPoint | net.FlagMulticast,
addrs: ipnets("172.31.201.147"),
},
{ // Wi-Fi
flags: net.FlagUp | net.FlagBroadcast | net.FlagMulticast,
addrs: ipnets("192.168.3.54"),
},
}, nil
})
ip, err := DetectRoutableIP()
if err != nil {
t.Fatalf("DetectRoutableIP: %v", err)
}
if ip != "192.168.3.54" {
t.Fatalf("ip = %q, want 192.168.3.54 (Wi-Fi, VPN skipped)", ip)
}
}
// A down interface must not be chosen even if it carries a private address.
func TestDetectRoutableIPSkipsDownInterface(t *testing.T) {
withIfaceLister(t, func() ([]ifaceInfo, error) {
return []ifaceInfo{
{ // down: skipped despite the private address
flags: net.FlagBroadcast | net.FlagMulticast,
addrs: ipnets("192.168.9.9"),
},
{ // up
flags: net.FlagUp | net.FlagBroadcast | net.FlagMulticast,
addrs: ipnets("10.1.2.3"),
},
}, nil
})
ip, err := DetectRoutableIP()
if err != nil {
t.Fatalf("DetectRoutableIP: %v", err)
}
if ip != "10.1.2.3" {
t.Fatalf("ip = %q, want 10.1.2.3 (down iface skipped)", ip)
}
}
// With no private address, a routable public address is used as a fallback.
func TestDetectRoutableIPFallsBackToPublic(t *testing.T) {
withIfaceLister(t, func() ([]ifaceInfo, error) {
return []ifaceInfo{{
flags: net.FlagUp | net.FlagBroadcast | net.FlagMulticast,
addrs: ipnets("203.0.113.7"),
}}, nil
})
ip, err := DetectRoutableIP()
if err != nil {
t.Fatalf("DetectRoutableIP: %v", err)
}
if ip != "203.0.113.7" {
t.Fatalf("ip = %q, want 203.0.113.7 (public fallback)", ip)
}
}
func TestDetectRoutableIPSkipsLoopbackAndLinkLocal(t *testing.T) {
withIfaceLister(t, func() ([]ifaceInfo, error) {
return []ifaceInfo{{
flags: net.FlagUp | net.FlagMulticast,
addrs: ipnets("127.0.0.1", "169.254.1.5"),
}}, nil
})
if _, err := DetectRoutableIP(); err != ErrNoLAN {
t.Fatalf("err = %v, want ErrNoLAN", err)
}
}
func TestDetectRoutableIPNoInterfaces(t *testing.T) {
withIfaceLister(t, func() ([]ifaceInfo, error) {
return nil, nil
})
if _, err := DetectRoutableIP(); err != ErrNoLAN {
t.Fatalf("err = %v, want ErrNoLAN", err)
}
}
func TestListenFreePortAutoAssign(t *testing.T) {
ln, port, err := ListenFreePort("127.0.0.1", 0)
if err != nil {
t.Fatalf("ListenFreePort: %v", err)
}
defer ln.Close()
if port <= 0 {
t.Fatalf("port = %d, want > 0", port)
}
if got := ln.Addr().(*net.TCPAddr).Port; got != port {
t.Fatalf("listener port %d != returned %d", got, port)
}
}
func TestListenFreePortFallsBackWhenOccupied(t *testing.T) {
// Occupy a port, then ask ListenFreePort for that same port; it must fall
// back to a different, free one instead of failing.
occupied, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("occupy: %v", err)
}
defer occupied.Close()
busyPort := occupied.Addr().(*net.TCPAddr).Port
ln, port, err := ListenFreePort("127.0.0.1", busyPort)
if err != nil {
t.Fatalf("ListenFreePort: %v", err)
}
defer ln.Close()
if port == busyPort {
t.Fatalf("port = %d, expected fallback away from occupied %d", port, busyPort)
}
}
func TestListenFreePortUsesRequestedWhenFree(t *testing.T) {
// Find a free port, release it, then request it explicitly.
probe, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("probe: %v", err)
}
want := probe.Addr().(*net.TCPAddr).Port
probe.Close()
ln, port, err := ListenFreePort("127.0.0.1", want)
if err != nil {
t.Fatalf("ListenFreePort: %v", err)
}
defer ln.Close()
if port != want {
t.Fatalf("port = %d, want requested %d", port, want)
}
}
+47
View File
@@ -0,0 +1,47 @@
package remotecontrol
// FrameType enumerates the WebSocket message kinds exchanged between the server
// and the paired browser. It is the wire contract shared with the embedded SPA.
type FrameType string
const (
// FrameOutput streams session text from server to client.
FrameOutput FrameType = "output"
// FrameInput carries a prompt line submitted by the client to the server.
FrameInput FrameType = "input"
// FrameConfirm asks the client to approve/reject a risky tool call.
FrameConfirm FrameType = "confirm"
// FrameDecide carries the client's approval decision back to the server.
FrameDecide FrameType = "decide"
// FrameStatus reports session lifecycle changes to the client.
FrameStatus FrameType = "status"
)
// Status values carried in Frame.State for FrameStatus frames.
const (
StatusConnected = "connected"
StatusEnded = "ended"
StatusDisconnected = "disconnected"
)
// Frame is a single WebSocket message. Fields are populated according to Type;
// unused fields are omitted from the JSON encoding.
type Frame struct {
Type FrameType `json:"type"`
// Output
Text string `json:"text,omitempty"`
// Confirm
ConfirmID string `json:"confirmId,omitempty"`
Tool string `json:"tool,omitempty"`
Summary string `json:"summary,omitempty"`
// Decide
Approve bool `json:"approve,omitempty"`
Always bool `json:"always,omitempty"`
// Status
State string `json:"state,omitempty"` // connected | ended | disconnected
Reason string `json:"reason,omitempty"` // human-readable detail for State
}
+51
View File
@@ -0,0 +1,51 @@
package remotecontrol
import (
"strings"
qrcode "github.com/skip2/go-qrcode"
)
// Render encodes url as a QR code drawn with Unicode half-block characters,
// suitable for scanning off a terminal by a phone camera. Two matrix rows are
// packed into each text line (▀ ▄ █ and space), halving the printed height.
//
// The rendering assumes a dark-background terminal: light QR modules are drawn
// as bright block glyphs and dark modules as the terminal background. go-qrcode
// includes the mandatory quiet-zone border in its bitmap.
//
// On any encoding error Render returns ("", err); callers should degrade
// gracefully by printing the URL alone (the QR is a convenience, not required).
func Render(url string) (string, error) {
q, err := qrcode.New(url, qrcode.Medium)
if err != nil {
return "", err
}
bm := q.Bitmap() // true = dark module; includes quiet-zone border.
var b strings.Builder
for y := 0; y < len(bm); y += 2 {
row := bm[y]
for x := 0; x < len(row); x++ {
// A "light" pixel is drawn as a block; a "dark" pixel is left as
// background. Rows beyond the matrix are treated as light (quiet).
topLight := !bm[y][x]
botLight := true
if y+1 < len(bm) {
botLight = !bm[y+1][x]
}
switch {
case topLight && botLight:
b.WriteRune('█')
case topLight && !botLight:
b.WriteRune('▀')
case !topLight && botLight:
b.WriteRune('▄')
default:
b.WriteByte(' ')
}
}
b.WriteByte('\n')
}
return b.String(), nil
}
+60
View File
@@ -0,0 +1,60 @@
package remotecontrol
import (
"strings"
"testing"
)
func TestRenderProducesBlockOutput(t *testing.T) {
out, err := Render("http://192.168.1.42:8080/pair?t=deadbeef")
if err != nil {
t.Fatalf("Render: %v", err)
}
if out == "" {
t.Fatal("Render returned empty output")
}
// Output must consist only of the half-block glyphs, spaces, and newlines.
for _, r := range out {
switch r {
case '█', '▀', '▄', ' ', '\n':
default:
t.Fatalf("unexpected rune %q in QR output", r)
}
}
// Must contain at least one dark-bearing glyph (not all blanks).
if !strings.ContainsAny(out, "▀▄ ") {
t.Fatal("QR output has no dark modules")
}
}
func TestRenderIsDeterministic(t *testing.T) {
const url = "http://10.0.0.9:5000/pair?t=abc123"
a, err := Render(url)
if err != nil {
t.Fatalf("Render: %v", err)
}
b, err := Render(url)
if err != nil {
t.Fatalf("Render: %v", err)
}
if a != b {
t.Fatal("Render is not deterministic for the same URL")
}
}
func TestRenderSquareRows(t *testing.T) {
out, err := Render("http://127.0.0.1:1/pair?t=x")
if err != nil {
t.Fatalf("Render: %v", err)
}
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
if len(lines) == 0 {
t.Fatal("no lines")
}
width := len([]rune(lines[0]))
for i, ln := range lines {
if got := len([]rune(ln)); got != width {
t.Fatalf("line %d width = %d, want uniform %d", i, got, width)
}
}
}
+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")
+464
View File
@@ -0,0 +1,464 @@
package remotecontrol
import (
"context"
"net/http"
"net/http/cookiejar"
"strings"
"sync"
"testing"
"time"
"github.com/coder/websocket"
"github.com/coder/websocket/wsjson"
)
type fakeHandler struct {
mu sync.Mutex
inputs []string
decides []decision
}
type decision struct {
id string
approve bool
always bool
}
func (h *fakeHandler) OnInput(text string) {
h.mu.Lock()
defer h.mu.Unlock()
h.inputs = append(h.inputs, text)
}
func (h *fakeHandler) OnDecide(id string, approve, always bool) {
h.mu.Lock()
defer h.mu.Unlock()
h.decides = append(h.decides, decision{id, approve, always})
}
func (h *fakeHandler) lastInput() string {
h.mu.Lock()
defer h.mu.Unlock()
if len(h.inputs) == 0 {
return ""
}
return h.inputs[len(h.inputs)-1]
}
// startTestServer boots a server on loopback and returns it plus the pairing
// URL. The caller must Stop it.
func startTestServer(t *testing.T, h Handler) (*Server, string) {
t.Helper()
s := NewServer(Config{Host: "127.0.0.1", Port: 0}, h)
url, err := s.Start()
if err != nil {
t.Fatalf("Start: %v", err)
}
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = s.Stop(ctx)
})
return s, url
}
func TestHealthz(t *testing.T) {
_, pairURL := startTestServer(t, nil)
base := pairURL[:strings.Index(pairURL, "/pair")]
resp, err := http.Get(base + "/healthz")
if err != nil {
t.Fatalf("get healthz: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("healthz status = %d, want 200", resp.StatusCode)
}
}
func TestPairRejectsBadToken(t *testing.T) {
_, pairURL := startTestServer(t, nil)
base := pairURL[:strings.Index(pairURL, "/pair")]
resp, err := http.Get(base + "/pair?t=bogus")
if err != nil {
t.Fatalf("get pair: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("bad-token status = %d, want 401", resp.StatusCode)
}
}
func TestPairSetsCookieAndServesSPA(t *testing.T) {
_, pairURL := startTestServer(t, nil)
jar, _ := cookiejar.New(nil)
client := &http.Client{Jar: jar}
resp, err := client.Get(pairURL)
if err != nil {
t.Fatalf("pair: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { // followed redirect to /
t.Fatalf("pair->root status = %d, want 200", resp.StatusCode)
}
// A second use of the same one-time token must fail.
resp2, err := http.Get(pairURL)
if err != nil {
t.Fatalf("pair reuse: %v", err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusUnauthorized {
t.Fatalf("token reuse status = %d, want 401", resp2.StatusCode)
}
}
// The session cookie must be SameSite=Lax so mobile browsers keep it across the
// QR-scan /pair→/ redirect (Strict is dropped by some, breaking pairing).
func TestPairCookieIsSameSiteLax(t *testing.T) {
_, pairURL := startTestServer(t, nil)
client := &http.Client{
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse // stop at the 302 to read Set-Cookie
},
}
resp, err := client.Get(pairURL)
if err != nil {
t.Fatalf("pair: %v", err)
}
defer resp.Body.Close()
var got *http.Cookie
for _, c := range resp.Cookies() {
if c.Name == cookieName {
got = c
}
}
if got == nil {
t.Fatal("no session cookie issued")
}
if got.SameSite != http.SameSiteLaxMode {
t.Fatalf("SameSite = %v, want Lax", got.SameSite)
}
}
func TestRootRequiresAuth(t *testing.T) {
_, pairURL := startTestServer(t, nil)
base := pairURL[:strings.Index(pairURL, "/pair")]
resp, err := http.Get(base + "/")
if err != nil {
t.Fatalf("get root: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("unauth root status = %d, want 401", resp.StatusCode)
}
}
// sessionCred pairs and extracts the pigo_rc cookie value for WS dialing.
func sessionCred(t *testing.T, pairURL string) (base, cred string) {
t.Helper()
jar, _ := cookiejar.New(nil)
client := &http.Client{
Jar: jar,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse // stop at the 302 to read the cookie
},
}
resp, err := client.Get(pairURL)
if err != nil {
t.Fatalf("pair: %v", err)
}
defer resp.Body.Close()
for _, c := range resp.Cookies() {
if c.Name == cookieName {
cred = c.Value
}
}
if cred == "" {
t.Fatal("no session cookie issued")
}
return pairURL[:strings.Index(pairURL, "/pair")], cred
}
func dialWS(t *testing.T, base, cred string) (*websocket.Conn, *http.Response, error) {
t.Helper()
wsURL := "ws" + strings.TrimPrefix(base, "http") + "/ws"
opts := &websocket.DialOptions{
HTTPHeader: http.Header{"Cookie": []string{cookieName + "=" + cred}},
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
return websocket.Dial(ctx, wsURL, opts)
}
func TestWSRoundTrip(t *testing.T) {
h := &fakeHandler{}
s, pairURL := startTestServer(t, h)
base, cred := sessionCred(t, pairURL)
conn, _, err := dialWS(t, base, cred)
if err != nil {
t.Fatalf("dial ws: %v", err)
}
defer conn.Close(websocket.StatusNormalClosure, "")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// First frame should be the connected status.
var connected Frame
if err := wsjson.Read(ctx, conn, &connected); err != nil {
t.Fatalf("read connected: %v", err)
}
if connected.Type != FrameStatus || connected.State != StatusConnected {
t.Fatalf("first frame = %+v, want status/connected", connected)
}
// Client -> server input reaches the handler.
if err := wsjson.Write(ctx, conn, Frame{Type: FrameInput, Text: "hello"}); err != nil {
t.Fatalf("write input: %v", err)
}
deadline := time.Now().Add(time.Second)
for h.lastInput() != "hello" {
if time.Now().After(deadline) {
t.Fatalf("handler never received input, got %q", h.lastInput())
}
time.Sleep(5 * time.Millisecond)
}
// Server -> client output reaches the browser.
s.SendOutput("world")
var out Frame
if err := wsjson.Read(ctx, conn, &out); err != nil {
t.Fatalf("read output: %v", err)
}
if out.Type != FrameOutput || out.Text != "world" {
t.Fatalf("output frame = %+v, want output/world", out)
}
}
func TestWSRejectsUnauth(t *testing.T) {
_, pairURL := startTestServer(t, nil)
base := pairURL[:strings.Index(pairURL, "/pair")]
_, resp, err := dialWS(t, base, "not-a-valid-cred")
if err == nil {
t.Fatal("dial with bad cred succeeded, want failure")
}
if resp != nil && resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", resp.StatusCode)
}
}
// readOutput reads frames until it sees a FrameOutput and returns its text,
// skipping any interleaved status frames.
func readOutput(t *testing.T, ctx context.Context, conn *websocket.Conn) string {
t.Helper()
for {
var f Frame
if err := wsjson.Read(ctx, conn, &f); err != nil {
t.Fatalf("read output: %v", err)
}
if f.Type == FrameOutput {
return f.Text
}
}
}
// TestOutputCoalesced verifies that a burst of writes is coalesced into a
// single output frame by the pump rather than one frame per write, and that no
// bytes are dropped.
func TestOutputCoalesced(t *testing.T) {
s, pairURL := startTestServer(t, &fakeHandler{})
base, cred := sessionCred(t, pairURL)
conn, _, err := dialWS(t, base, cred)
if err != nil {
t.Fatalf("dial ws: %v", err)
}
defer conn.Close(websocket.StatusNormalClosure, "")
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
// Drain the connected status frame.
var connected Frame
if err := wsjson.Read(ctx, conn, &connected); err != nil {
t.Fatalf("read connected: %v", err)
}
// Wait for the server to register the client so writes are not lost before
// the pump has a live connection.
deadline := time.Now().Add(time.Second)
for !s.HasClient() {
if time.Now().After(deadline) {
t.Fatal("client never registered")
}
time.Sleep(2 * time.Millisecond)
}
// Emit a burst within one flush interval.
const n = 50
want := ""
for i := 0; i < n; i++ {
s.SendOutput("x")
want += "x"
}
// Read frames until we have accumulated all the bytes. They must arrive in
// order and total exactly n bytes (no drops, no duplication). Coalescing
// should produce far fewer than n frames.
got := ""
frames := 0
for len(got) < len(want) {
got += readOutput(t, ctx, conn)
frames++
}
if got != want {
t.Fatalf("coalesced output = %q, want %q", got, want)
}
if frames >= n {
t.Fatalf("got %d frames for %d writes, expected coalescing", frames, n)
}
}
// TestReconnectReplay verifies that a client reconnecting mid-session is
// replayed the recent scrollback from the ring buffer.
func TestReconnectReplay(t *testing.T) {
s, pairURL := startTestServer(t, &fakeHandler{})
base, cred := sessionCred(t, pairURL)
// First client connects, receives some output, then disconnects.
conn1, _, err := dialWS(t, base, cred)
if err != nil {
t.Fatalf("dial ws 1: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var connected Frame
if err := wsjson.Read(ctx, conn1, &connected); err != nil {
t.Fatalf("read connected 1: %v", err)
}
deadline := time.Now().Add(time.Second)
for !s.HasClient() {
if time.Now().After(deadline) {
t.Fatal("client 1 never registered")
}
time.Sleep(2 * time.Millisecond)
}
s.SendOutput("scrollback")
if out := readOutput(t, ctx, conn1); out != "scrollback" {
t.Fatalf("client 1 output = %q, want scrollback", out)
}
conn1.Close(websocket.StatusNormalClosure, "")
// Wait for the server to release the client slot.
deadline = time.Now().Add(time.Second)
for s.HasClient() {
if time.Now().After(deadline) {
t.Fatal("client 1 slot never released")
}
time.Sleep(2 * time.Millisecond)
}
// Second client connects and should be replayed the scrollback.
conn2, _, err := dialWS(t, base, cred)
if err != nil {
t.Fatalf("dial ws 2: %v", err)
}
defer conn2.Close(websocket.StatusNormalClosure, "")
if err := wsjson.Read(ctx, conn2, &connected); err != nil {
t.Fatalf("read connected 2: %v", err)
}
if out := readOutput(t, ctx, conn2); out != "scrollback" {
t.Fatalf("replay output = %q, want scrollback", out)
}
}
// TestClientConnectDisconnectCallbacks verifies the terminal-notice callbacks
// fire on connect and disconnect (§7.3).
func TestClientConnectDisconnectCallbacks(t *testing.T) {
var mu sync.Mutex
var connectedAddr string
connected := make(chan struct{}, 1)
disconnected := make(chan struct{}, 1)
cfg := Config{
Host: "127.0.0.1",
Port: 0,
OnClientConnect: func(addr string) {
mu.Lock()
connectedAddr = addr
mu.Unlock()
connected <- struct{}{}
},
OnClientDisconnect: func() {
disconnected <- struct{}{}
},
}
s := NewServer(cfg, &fakeHandler{})
pairURL, err := s.Start()
if err != nil {
t.Fatalf("Start: %v", err)
}
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = s.Stop(ctx)
})
base, cred := sessionCred(t, pairURL)
conn, _, err := dialWS(t, base, cred)
if err != nil {
t.Fatalf("dial ws: %v", err)
}
select {
case <-connected:
case <-time.After(2 * time.Second):
t.Fatal("OnClientConnect never fired")
}
mu.Lock()
addr := connectedAddr
mu.Unlock()
if addr == "" {
t.Fatal("OnClientConnect got empty remote addr")
}
conn.Close(websocket.StatusNormalClosure, "")
select {
case <-disconnected:
case <-time.After(2 * time.Second):
t.Fatal("OnClientDisconnect never fired")
}
}
func TestWSSingleClient(t *testing.T) {
s, pairURL := startTestServer(t, &fakeHandler{})
base, cred := sessionCred(t, pairURL)
conn1, _, err := dialWS(t, base, cred)
if err != nil {
t.Fatalf("dial first: %v", err)
}
defer conn1.Close(websocket.StatusNormalClosure, "")
// Wait until the server registers the first client.
deadline := time.Now().Add(time.Second)
for !s.HasClient() {
if time.Now().After(deadline) {
t.Fatal("server never registered first client")
}
time.Sleep(5 * time.Millisecond)
}
_, resp, err := dialWS(t, base, cred)
if err == nil {
t.Fatal("second client connected, want rejection")
}
if resp != nil && resp.StatusCode != http.StatusConflict {
t.Fatalf("second-client status = %d, want 409", resp.StatusCode)
}
}
+53
View File
@@ -0,0 +1,53 @@
package remotecontrol
import (
"io/fs"
"strings"
"testing"
)
// TestSPAAssetsEmbedded verifies the browser SPA is compiled into the binary
// and exposes the files the server serves.
func TestSPAAssetsEmbedded(t *testing.T) {
sub, err := fs.Sub(spaFiles, "web")
if err != nil {
t.Fatalf("fs.Sub: %v", err)
}
for _, name := range []string{"index.html", "app.js"} {
b, err := fs.ReadFile(sub, name)
if err != nil {
t.Fatalf("embedded %s missing: %v", name, err)
}
if len(b) == 0 {
t.Fatalf("embedded %s is empty", name)
}
}
}
func TestSPAIndexReferencesApp(t *testing.T) {
b, err := fs.ReadFile(spaFiles, "web/index.html")
if err != nil {
t.Fatalf("read index: %v", err)
}
html := string(b)
for _, want := range []string{`id="output"`, `id="composer"`, `id="confirm"`, "app.js", "viewport"} {
if !strings.Contains(html, want) {
t.Fatalf("index.html missing %q", want)
}
}
}
func TestSPAScriptHandlesFrames(t *testing.T) {
b, err := fs.ReadFile(spaFiles, "web/app.js")
if err != nil {
t.Fatalf("read app.js: %v", err)
}
js := string(b)
// The client must understand every server->client frame type and emit the
// two client->server types.
for _, want := range []string{`"output"`, `"confirm"`, `"status"`, `type: "input"`, `type: "decide"`, "/ws"} {
if !strings.Contains(js, want) {
t.Fatalf("app.js missing handling for %q", want)
}
}
}
+130
View File
@@ -0,0 +1,130 @@
// Package remotecontrol implements the /remote-control feature: an in-process
// web server that lets a phone on the same LAN mirror the CLI session, inject
// prompts, and approve risky tool calls (see tasks/spec-remote-control.md).
//
// This file (node #438) provides the auth substrate: a one-time, TTL-bound
// pairing token that a paired browser exchanges for an opaque session
// credential. All state is in-memory and process-scoped; nothing is persisted
// or logged, and Clear() wipes it on shutdown.
package remotecontrol
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"sync"
"time"
)
// tokenBytes is the entropy of both pairing tokens and session credentials.
// 32 bytes = 256 bits, hex-encoded to 64 characters in the URL/cookie.
const tokenBytes = 32
// pairingToken is a single-use link credential handed to the user (embedded in
// the printed /pair?t=... URL). It is consumed on the first successful pairing
// and rejected thereafter or once expired.
type pairingToken struct {
expiresAt time.Time
used bool
}
// TokenStore holds the outstanding pairing tokens and issued session
// credentials for one remote-control session. It is safe for concurrent use by
// the HTTP handlers. All values are cryptographically random secrets; the store
// keeps only their hex string form and never logs them.
type TokenStore struct {
mu sync.Mutex
pairing map[string]*pairingToken
sessions map[string]struct{}
// now is the clock, injectable so tests can force expiry without sleeping.
now func() time.Time
}
// NewTokenStore returns an empty store using the wall clock.
func NewTokenStore() *TokenStore {
return &TokenStore{
pairing: make(map[string]*pairingToken),
sessions: make(map[string]struct{}),
now: time.Now,
}
}
// randHex returns n cryptographically random bytes, hex-encoded. crypto/rand
// never returns a short read without an error, so a nil error guarantees a full
// buffer.
func randHex(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
// NewPairing mints a fresh one-time pairing token that expires after ttl and
// returns its hex value for embedding in the pairing URL.
func (s *TokenStore) NewPairing(ttl time.Duration) (string, error) {
value, err := randHex(tokenBytes)
if err != nil {
return "", err
}
s.mu.Lock()
defer s.mu.Unlock()
s.pairing[value] = &pairingToken{expiresAt: s.now().Add(ttl)}
return value, nil
}
// ConsumePairing validates a pairing token and, on success, marks it used so it
// can never be redeemed again. It returns false if the token is unknown,
// already used, or expired. The single-use marking happens under the lock, so
// two concurrent /pair requests with the same token cannot both succeed.
func (s *TokenStore) ConsumePairing(value string) bool {
s.mu.Lock()
defer s.mu.Unlock()
tok, ok := s.pairing[value]
if !ok || tok.used || s.now().After(tok.expiresAt) {
return false
}
tok.used = true
return true
}
// IssueSession creates and stores a new opaque session credential (set as the
// pigo_rc cookie after pairing) and returns its hex value.
func (s *TokenStore) IssueSession() (string, error) {
cred, err := randHex(tokenBytes)
if err != nil {
return "", err
}
s.mu.Lock()
defer s.mu.Unlock()
s.sessions[cred] = struct{}{}
return cred, nil
}
// ValidateSession reports whether cred matches a currently-issued session
// credential. Comparison is constant-time to avoid leaking the credential
// through response timing; an empty cred is always rejected.
func (s *TokenStore) ValidateSession(cred string) bool {
if cred == "" {
return false
}
want := []byte(cred)
s.mu.Lock()
defer s.mu.Unlock()
var matched bool
for issued := range s.sessions {
if subtle.ConstantTimeCompare([]byte(issued), want) == 1 {
matched = true
}
}
return matched
}
// Clear wipes all pairing tokens and session credentials. It is called on
// server shutdown so no secret outlives the remote-control session.
func (s *TokenStore) Clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.pairing = make(map[string]*pairingToken)
s.sessions = make(map[string]struct{})
}
+121
View File
@@ -0,0 +1,121 @@
package remotecontrol
import (
"testing"
"time"
)
func TestNewPairingTokenIsHex256Bit(t *testing.T) {
s := NewTokenStore()
value, err := s.NewPairing(time.Minute)
if err != nil {
t.Fatalf("NewPairing: %v", err)
}
// 32 bytes -> 64 hex chars.
if len(value) != tokenBytes*2 {
t.Fatalf("token length = %d, want %d", len(value), tokenBytes*2)
}
for _, c := range value {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
t.Fatalf("token has non-hex char %q", c)
}
}
}
func TestPairingIsSingleUse(t *testing.T) {
s := NewTokenStore()
value, err := s.NewPairing(time.Minute)
if err != nil {
t.Fatalf("NewPairing: %v", err)
}
if !s.ConsumePairing(value) {
t.Fatal("first ConsumePairing = false, want true")
}
if s.ConsumePairing(value) {
t.Fatal("second ConsumePairing = true, want false (single-use)")
}
}
func TestConsumePairingUnknownToken(t *testing.T) {
s := NewTokenStore()
if s.ConsumePairing("deadbeef") {
t.Fatal("ConsumePairing on unknown token = true, want false")
}
}
func TestPairingExpires(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
s := NewTokenStore()
s.now = func() time.Time { return now }
value, err := s.NewPairing(10 * time.Minute)
if err != nil {
t.Fatalf("NewPairing: %v", err)
}
// Advance clock just past the TTL.
now = now.Add(10*time.Minute + time.Second)
if s.ConsumePairing(value) {
t.Fatal("ConsumePairing after expiry = true, want false")
}
}
func TestPairingValidJustBeforeExpiry(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
s := NewTokenStore()
s.now = func() time.Time { return now }
value, err := s.NewPairing(10 * time.Minute)
if err != nil {
t.Fatalf("NewPairing: %v", err)
}
now = now.Add(10*time.Minute - time.Second)
if !s.ConsumePairing(value) {
t.Fatal("ConsumePairing just before expiry = false, want true")
}
}
func TestIssueAndValidateSession(t *testing.T) {
s := NewTokenStore()
cred, err := s.IssueSession()
if err != nil {
t.Fatalf("IssueSession: %v", err)
}
if len(cred) != tokenBytes*2 {
t.Fatalf("cred length = %d, want %d", len(cred), tokenBytes*2)
}
if !s.ValidateSession(cred) {
t.Fatal("ValidateSession(issued) = false, want true")
}
if s.ValidateSession("") {
t.Fatal("ValidateSession(\"\") = true, want false")
}
if s.ValidateSession("not-a-real-cred") {
t.Fatal("ValidateSession(bogus) = true, want false")
}
}
func TestClearWipesState(t *testing.T) {
s := NewTokenStore()
value, _ := s.NewPairing(time.Minute)
cred, _ := s.IssueSession()
s.Clear()
if s.ConsumePairing(value) {
t.Fatal("pairing token survived Clear")
}
if s.ValidateSession(cred) {
t.Fatal("session credential survived Clear")
}
}
func TestTokensAreUnique(t *testing.T) {
s := NewTokenStore()
seen := make(map[string]struct{})
for range 100 {
v, err := s.NewPairing(time.Minute)
if err != nil {
t.Fatalf("NewPairing: %v", err)
}
if _, dup := seen[v]; dup {
t.Fatalf("duplicate token generated: %s", v)
}
seen[v] = struct{}{}
}
}
+153
View File
@@ -0,0 +1,153 @@
// pigo remote-control SPA.
//
// Connects to /ws, renders streamed session output, submits prompts, and
// answers confirmation requests. Vanilla JS, no build step — the server embeds
// this file and serves it as a static asset.
(function () {
"use strict";
var out = document.getElementById("output");
var form = document.getElementById("composer");
var prompt = document.getElementById("prompt");
var sendBtn = document.getElementById("send");
var dot = document.getElementById("dot");
var statusText = document.getElementById("statusText");
var confirmEl = document.getElementById("confirm");
var confirmTool = document.getElementById("confirmTool");
var confirmSummary = document.getElementById("confirmSummary");
var confirmAlways = document.getElementById("confirmAlways");
var btnApprove = document.getElementById("btnApprove");
var btnReject = document.getElementById("btnReject");
var ws = null;
var backoff = 500; // ms, exponential up to 10s
var pendingConfirmId = null;
// Strip ANSI escape sequences so terminal color codes don't clutter mobile.
var ansi = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
function clean(s) { return s.replace(ansi, ""); }
function atBottom() {
return out.scrollHeight - out.scrollTop - out.clientHeight < 40;
}
function appendOutput(text) {
var stick = atBottom();
out.appendChild(document.createTextNode(clean(text)));
// Cap the scrollback so long sessions don't exhaust memory.
while (out.childNodes.length > 4000) {
out.removeChild(out.firstChild);
}
if (stick) out.scrollTop = out.scrollHeight;
}
function setStatus(state, label) {
dot.className = state === "on" ? "on" : state === "off" ? "off" : "";
statusText.textContent = label;
var live = state === "on";
prompt.disabled = !live;
sendBtn.disabled = !live;
}
function send(obj) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(obj));
return true;
}
return false;
}
function showConfirm(frame) {
pendingConfirmId = frame.confirmId;
confirmTool.textContent = frame.tool || "tool";
confirmSummary.textContent = clean(frame.summary || "");
confirmAlways.checked = false;
confirmEl.classList.add("show");
}
function hideConfirm() {
confirmEl.classList.remove("show");
pendingConfirmId = null;
}
function decide(approve) {
if (pendingConfirmId == null) return;
send({ type: "decide", confirmId: pendingConfirmId, approve: approve, always: confirmAlways.checked });
hideConfirm();
}
btnApprove.addEventListener("click", function () { decide(true); });
btnReject.addEventListener("click", function () { decide(false); });
function handleFrame(frame) {
switch (frame.type) {
case "output":
appendOutput(frame.text || "");
break;
case "confirm":
showConfirm(frame);
break;
case "status":
if (frame.state === "connected") {
setStatus("on", "connected");
} else if (frame.state === "ended") {
setStatus("off", "session ended");
} else if (frame.state === "disconnected") {
setStatus("off", frame.reason || "disconnected");
}
break;
}
}
function connect() {
var proto = location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(proto + "//" + location.host + "/ws");
ws.onopen = function () {
backoff = 500;
setStatus("on", "connected");
};
ws.onmessage = function (ev) {
var frame;
try { frame = JSON.parse(ev.data); } catch (e) { return; }
handleFrame(frame);
};
ws.onclose = function () {
setStatus("off", "reconnecting…");
hideConfirm();
scheduleReconnect();
};
ws.onerror = function () {
try { ws.close(); } catch (e) {}
};
}
function scheduleReconnect() {
setTimeout(function () {
backoff = Math.min(backoff * 2, 10000);
connect();
}, backoff);
}
// Auto-grow the textarea and submit on Enter (Shift+Enter = newline).
prompt.addEventListener("input", function () {
prompt.style.height = "auto";
prompt.style.height = Math.min(prompt.scrollHeight, window.innerHeight * 0.4) + "px";
});
prompt.addEventListener("keydown", function (e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
form.requestSubmit();
}
});
form.addEventListener("submit", function (e) {
e.preventDefault();
var text = prompt.value;
if (!text.trim()) return;
if (send({ type: "input", text: text })) {
prompt.value = "";
prompt.style.height = "auto";
}
});
setStatus("", "connecting…");
connect();
})();
+135
View File
@@ -0,0 +1,135 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0b0f14">
<title>pigo remote</title>
<style>
:root {
--bg: #0b0f14;
--panel: #121822;
--panel-2: #1a2331;
--fg: #e6edf3;
--muted: #8b98a5;
--accent: #4f9cf9;
--ok: #3fb950;
--danger: #f85149;
--border: #223042;
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
html, body {
margin: 0; height: 100%;
background: var(--bg); color: var(--fg);
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
-webkit-text-size-adjust: 100%;
}
body {
display: flex; flex-direction: column;
height: 100dvh;
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
}
header {
display: flex; align-items: center; gap: .5rem;
padding: .6rem .8rem; border-bottom: 1px solid var(--border);
background: var(--panel);
}
header .title { font-weight: 600; letter-spacing: .3px; }
#status {
margin-left: auto; font-size: .78rem; color: var(--muted);
display: inline-flex; align-items: center; gap: .4rem;
}
#dot { width: .6rem; height: .6rem; border-radius: 50%; background: var(--muted); }
#dot.on { background: var(--ok); }
#dot.off { background: var(--danger); }
#output {
flex: 1 1 auto; overflow-y: auto; overflow-x: auto;
padding: .8rem; margin: 0;
font-family: var(--mono); font-size: .84rem; line-height: 1.45;
white-space: pre-wrap; word-break: break-word;
-webkit-overflow-scrolling: touch;
}
form#composer {
display: flex; gap: .5rem; padding: .6rem;
border-top: 1px solid var(--border); background: var(--panel);
}
#prompt {
flex: 1 1 auto; resize: none;
background: var(--panel-2); color: var(--fg);
border: 1px solid var(--border); border-radius: .6rem;
padding: .6rem .7rem; font-size: 1rem; font-family: inherit;
max-height: 40vh; min-height: 2.6rem;
}
button {
border: 0; border-radius: .6rem; padding: 0 1rem;
font-size: .95rem; font-weight: 600; cursor: pointer;
background: var(--accent); color: #04121f;
}
button:disabled { opacity: .5; cursor: not-allowed; }
/* Confirmation sheet */
#confirm {
position: fixed; inset: 0; display: none;
align-items: flex-end; justify-content: center;
background: rgba(0,0,0,.55); z-index: 10;
}
#confirm.show { display: flex; }
.sheet {
width: 100%; max-width: 640px;
background: var(--panel); border: 1px solid var(--border);
border-radius: 1rem 1rem 0 0; padding: 1rem 1rem 1.3rem;
}
.sheet h2 { margin: 0 0 .3rem; font-size: 1rem; }
.sheet .tool { color: var(--accent); font-family: var(--mono); }
.sheet pre {
background: var(--panel-2); border: 1px solid var(--border);
border-radius: .6rem; padding: .6rem; margin: .6rem 0 1rem;
font-family: var(--mono); font-size: .82rem; overflow-x: auto;
white-space: pre-wrap; word-break: break-word; max-height: 40vh;
}
.sheet .row { display: flex; gap: .5rem; }
.sheet .row button { flex: 1; padding: .7rem 0; }
.btn-approve { background: var(--ok); color: #04121f; }
.btn-reject { background: var(--danger); color: #1a0303; }
.btn-always { background: var(--panel-2); color: var(--fg); border: 1px solid var(--border); }
label.always {
display: flex; align-items: center; gap: .5rem;
color: var(--muted); font-size: .85rem; margin-bottom: .8rem;
}
@media (min-width: 700px) {
#confirm { align-items: center; }
.sheet { border-radius: 1rem; }
}
</style>
</head>
<body>
<header>
<span class="title">pigo remote</span>
<span id="status"><span id="dot"></span><span id="statusText">connecting…</span></span>
</header>
<pre id="output" aria-live="polite"></pre>
<form id="composer" autocomplete="off">
<textarea id="prompt" rows="1" placeholder="Type a prompt and press Send…" enterkeyhint="send"></textarea>
<button id="send" type="submit">Send</button>
</form>
<div id="confirm" role="dialog" aria-modal="true" aria-labelledby="confirmTitle">
<div class="sheet">
<h2 id="confirmTitle">Approve <span class="tool" id="confirmTool"></span>?</h2>
<pre id="confirmSummary"></pre>
<label class="always"><input type="checkbox" id="confirmAlways"> Always approve this tool</label>
<div class="row">
<button type="button" class="btn-reject" id="btnReject">Reject</button>
<button type="button" class="btn-approve" id="btnApprove">Approve</button>
</div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>