238 lines
8.4 KiB
Go
238 lines
8.4 KiB
Go
// This file wires the remote-control bridge (internal/remotecontrol, #442) into
|
|
// the interactive REPL (#443). It adds the "/remote-control" command that
|
|
// starts/stops an in-process HTTP+WebSocket server mirroring the session to a
|
|
// paired browser on the LAN, tees REPL output to that browser, merges
|
|
// browser-submitted prompts into the input loop, and routes tool-call
|
|
// confirmations to the browser while a client is connected.
|
|
//
|
|
// The design keeps the non-remote path byte-identical: when no remote session
|
|
// is active, teeWriter forwards only to stdout, the input select degenerates to
|
|
// a plain editor read (the remote channel is nil), and confirmations use the
|
|
// local stdin prompt unchanged.
|
|
package repl
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/smallnest/pigo/internal/agentcore"
|
|
"github.com/smallnest/pigo/internal/remotecontrol"
|
|
"github.com/smallnest/pigo/internal/trust"
|
|
)
|
|
|
|
// teeWriter is an io.Writer that always forwards to a primary writer (the
|
|
// terminal) and, when a secondary is set, mirrors the same bytes to it (the
|
|
// remote browser). It is safe for concurrent Write/setSecondary because the
|
|
// bridge output writer is fed from the REPL goroutine while the secondary is
|
|
// toggled by the /remote-control command on the same goroutine, but writes to
|
|
// the WebSocket happen on other goroutines; the mutex keeps the swap atomic.
|
|
type teeWriter struct {
|
|
primary io.Writer
|
|
mu sync.Mutex
|
|
second io.Writer
|
|
}
|
|
|
|
func newTeeWriter(primary io.Writer) *teeWriter { return &teeWriter{primary: primary} }
|
|
|
|
func (t *teeWriter) setSecondary(w io.Writer) {
|
|
t.mu.Lock()
|
|
t.second = w
|
|
t.mu.Unlock()
|
|
}
|
|
|
|
func (t *teeWriter) Write(p []byte) (int, error) {
|
|
// The primary write is authoritative for the returned count/err so terminal
|
|
// behavior is unchanged; a mirror failure never breaks the local session.
|
|
n, err := t.primary.Write(p)
|
|
t.mu.Lock()
|
|
second := t.second
|
|
t.mu.Unlock()
|
|
if second != nil {
|
|
_, _ = second.Write(p)
|
|
}
|
|
return n, err
|
|
}
|
|
|
|
// remoteSession owns the running server + bridge for one /remote-control
|
|
// activation. It is nil in deps until the command starts a session, and is
|
|
// cleared on stop.
|
|
type remoteSession struct {
|
|
server *remotecontrol.Server
|
|
bridge *remotecontrol.Bridge
|
|
url string
|
|
}
|
|
|
|
// inputChan returns the bridge's remote-input channel while a session is
|
|
// active, or nil when inactive. A nil channel blocks forever in a select, so
|
|
// the input loop transparently ignores remote input when remote control is off.
|
|
func (rs *remoteSession) inputChan() <-chan string {
|
|
if rs == nil || rs.bridge == nil {
|
|
return nil
|
|
}
|
|
return rs.bridge.RemoteInput()
|
|
}
|
|
|
|
// hasClient reports whether a browser is currently paired and connected.
|
|
func (rs *remoteSession) hasClient() bool {
|
|
return rs != nil && rs.bridge != nil && rs.bridge.Enabled()
|
|
}
|
|
|
|
// runRemoteControl handles the "/remote-control" command and its "stop"/"status"
|
|
// subcommands. It mutates deps in place (deps.remote, deps.tee) so the input
|
|
// loop and output tee pick up the change on the next iteration.
|
|
func runRemoteControl(out io.Writer, deps *replDeps, line string) {
|
|
arg := strings.TrimSpace(strings.TrimPrefix(line, "/remote-control"))
|
|
switch arg {
|
|
case "stop":
|
|
stopRemoteControl(out, deps)
|
|
case "status", "":
|
|
if arg == "status" {
|
|
remoteControlStatus(out, deps)
|
|
return
|
|
}
|
|
startRemoteControl(out, deps)
|
|
default:
|
|
fmt.Fprintf(out, "usage: /remote-control [stop|status]\n")
|
|
}
|
|
}
|
|
|
|
func startRemoteControl(out io.Writer, deps *replDeps) {
|
|
if deps.remote != nil {
|
|
fmt.Fprintf(out, "remote control already running: %s\n", deps.remote.url)
|
|
return
|
|
}
|
|
// Handler is set after the server is built (SetHandler), but NewServer takes
|
|
// it up front; the bridge's Sink is the server itself, so build the server
|
|
// first with the bridge as handler once the bridge exists. To break the
|
|
// cycle we construct the server, then the bridge (Sink=server), then tell the
|
|
// server to route client frames to the bridge.
|
|
// The connect/disconnect callbacks print a terminal notice so the operator
|
|
// sees when a browser gains or loses remote access to this session (§7.3).
|
|
// They run on the server's WebSocket goroutine and only write a line, so they
|
|
// don't block.
|
|
cfg := remotecontrol.Config{
|
|
OnClientConnect: func(remoteAddr string) {
|
|
fmt.Fprintf(out, "\n[remote-control] browser connected from %s\n", remoteAddr)
|
|
},
|
|
OnClientDisconnect: func() {
|
|
fmt.Fprintf(out, "\n[remote-control] browser disconnected\n")
|
|
},
|
|
}
|
|
srv := remotecontrol.NewServer(cfg, nil)
|
|
bridge := remotecontrol.NewBridge(srv)
|
|
srv.SetHandler(bridge)
|
|
|
|
url, err := srv.Start()
|
|
if err != nil {
|
|
fmt.Fprintf(out, "remote control: %v\n", err)
|
|
return
|
|
}
|
|
rs := &remoteSession{server: srv, bridge: bridge, url: url}
|
|
deps.remote = rs
|
|
if deps.tee != nil {
|
|
deps.tee.setSecondary(bridge.OutputWriter())
|
|
}
|
|
|
|
fmt.Fprintf(out, "\nRemote control started. Open this URL on a device on the same network:\n\n %s\n\n", url)
|
|
if qr, qerr := remotecontrol.Render(url); qerr == nil {
|
|
fmt.Fprintln(out, qr)
|
|
}
|
|
fmt.Fprintln(out, "Run /remote-control stop to end the session.")
|
|
}
|
|
|
|
func stopRemoteControl(out io.Writer, deps *replDeps) {
|
|
if deps.remote == nil {
|
|
fmt.Fprintln(out, "remote control is not running")
|
|
return
|
|
}
|
|
if deps.tee != nil {
|
|
deps.tee.setSecondary(nil)
|
|
}
|
|
_ = deps.remote.server.Stop(context.Background())
|
|
deps.remote = nil
|
|
fmt.Fprintln(out, "remote control stopped")
|
|
}
|
|
|
|
func remoteControlStatus(out io.Writer, deps *replDeps) {
|
|
if deps.remote == nil {
|
|
fmt.Fprintln(out, "remote control: off")
|
|
return
|
|
}
|
|
state := "waiting for a browser to connect"
|
|
if deps.remote.hasClient() {
|
|
state = "browser connected"
|
|
}
|
|
fmt.Fprintf(out, "remote control: on (%s)\n %s\n", state, deps.remote.url)
|
|
}
|
|
|
|
// beforeToolCall builds the tool-call confirmation seam for a turn. It always
|
|
// constructs the local stdin prompt (trust.BeforeToolCall) and, when a remote
|
|
// session exists, wraps it with bridgeBeforeToolCall so confirmations route to a
|
|
// paired browser while one is connected. When deps.remote is nil the wrapper is
|
|
// skipped entirely, so the returned func is exactly the local seam — the
|
|
// non-remote path is byte-identical to before (#443).
|
|
func beforeToolCall(deps replDeps, out io.Writer) agentcore.BeforeToolCallFunc {
|
|
local := trust.BeforeToolCall(deps.trust, deps.cwd, deps.in, out, deps.confirmMu)
|
|
if deps.remote == nil {
|
|
return local
|
|
}
|
|
return bridgeBeforeToolCall(deps.trust, deps.cwd, deps.remote, out, deps.confirmMu, local)
|
|
}
|
|
|
|
// bridgeBeforeToolCall wraps the local stdin confirmation seam so that while a
|
|
// browser is connected, side-effect tool-call confirmations are routed to the
|
|
// browser instead of blocking on the local terminal. When no browser is
|
|
// connected it delegates to the local prompt so behavior is unchanged.
|
|
//
|
|
// This mirrors trust.BeforeToolCall's gating (side-effect tools only, honoring
|
|
// session trust) but delegates the allow/always decision to the remote client
|
|
// via Bridge.Confirm. A ctx cancellation (e.g. SIGINT) makes Confirm return
|
|
// remote=false, which we treat as a denial so an interrupted run does not
|
|
// silently proceed. Refinements (local-answer race, timeouts) are the hardening
|
|
// node's job (#445).
|
|
func bridgeBeforeToolCall(mgr *trust.Manager, cwd string, rs *remoteSession, out io.Writer, mu *sync.Mutex, local agentcore.BeforeToolCallFunc) agentcore.BeforeToolCallFunc {
|
|
return func(ctx context.Context, call agentcore.AgentToolCall) *agentcore.BeforeToolCallDecision {
|
|
if !rs.hasClient() || mgr == nil {
|
|
if local != nil {
|
|
return local(ctx, call)
|
|
}
|
|
return nil
|
|
}
|
|
if !trust.SideEffectTools[call.Name] {
|
|
return nil
|
|
}
|
|
if mu != nil {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
}
|
|
if mgr.IsTrusted(cwd) {
|
|
return nil
|
|
}
|
|
summary := trust.ToolCallSummary(call)
|
|
fmt.Fprintf(out, "\npigo wants to run %q — approve on the paired device…\n", call.Name)
|
|
d, remote := rs.bridge.Confirm(ctx, call.Name, summary)
|
|
if !remote {
|
|
// Interrupted / cancelled before the browser answered: deny.
|
|
return blockToolCall(call, cwd)
|
|
}
|
|
if d.Always {
|
|
mgr.SetSessionTrust(cwd)
|
|
}
|
|
if !d.Approve {
|
|
return blockToolCall(call, cwd)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func blockToolCall(call agentcore.AgentToolCall, cwd string) *agentcore.BeforeToolCallDecision {
|
|
msg := fmt.Sprintf("tool %q blocked: %s is not trusted (use /trust to trust this project)", call.Name, cwd)
|
|
return &agentcore.BeforeToolCallDecision{
|
|
Block: true,
|
|
Content: &agentcore.ContentList{agentcore.NewTextContent(msg)},
|
|
}
|
|
}
|