first commit
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
// 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)},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user