first commit
This commit is contained in:
@@ -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:])
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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{}{}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user