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:])
}