2026-10-15 13:15:49 -07:00
package agents
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
2026-10-20 13:32:56 -07:00
"net/http"
"net/http/httputil"
2026-10-15 13:15:49 -07:00
"strconv"
"strings"
2026-10-20 13:32:56 -07:00
"sync"
2026-10-15 13:15:49 -07:00
"time"
"unicode/utf8"
2026-10-18 15:32:07 -07:00
"g.fc.run/ai/byre/internal/api"
"g.fc.run/ai/byre/internal/mcp"
"g.fc.run/ai/byre/internal/store"
2026-10-15 13:15:49 -07:00
)
const maxPreviewName = 40
func portEnv ( t api . Task ) [] string {
return [] string { "PORT=" + strconv . Itoa ( t . PortBase )}
}
type exposePortArgs struct {
Name string `json:"name"`
Port int `json:"port"`
}
func ( m * Manager ) exposePortTool ( l * live ) mcp . Tool {
return mcp . Tool {
Name : "expose_port" ,
2026-10-16 07:04:24 -07:00
Description : "Show the human a server you are running, such as a dev server, as a link above the task's tabs that opens it in a new window. " +
2026-10-15 13:15:49 -07:00
"Start the server yourself first (in the background), listening on 127.0.0.1 (not 0.0.0.0 or localhost) on a port in this task's block. " +
2026-10-20 13:32:56 -07:00
"byre then serves it on the same port of byre's tailnet node, and the human's app opens it through a local link. " +
2026-10-15 13:15:49 -07:00
"name labels it in the tab (\"web\", \"storybook\"); exposing a name or port again replaces the earlier one. " +
"The server stops when your process does (a byre restart, compaction, the task closing); the human sees it as unreachable until you start it again." ,
InputSchema : json . RawMessage ( `{
"type": "object",
"properties": {
2026-10-16 07:04:24 -07:00
"name": {"type": "string", "description": "Short label, shown as the link's text."},
2026-10-15 13:15:49 -07:00
"port": {"type": "integer", "description": "The port the server listens on, at 127.0.0.1, within this task's block."}
},
"required": ["name", "port"]
}` ),
Handler : func ( ctx context . Context , raw json . RawMessage ) ( string , error ) {
return m . exposePort ( ctx , l , raw )
},
}
}
func ( m * Manager ) exposePort ( ctx context . Context , l * live , raw json . RawMessage ) ( string , error ) {
args := exposePortArgs {}
err := json . Unmarshal ( raw , & args )
if err != nil {
return "" , fmt . Errorf ( "malformed expose_port call: %w" , err )
}
name := strings . TrimSpace ( args . Name )
if name == "" || strings . ContainsAny ( name , "\r\n" ) || utf8 . RuneCountInString ( name ) > maxPreviewName {
return "" , fmt . Errorf ( "name must be one non-empty line of at most %d characters" , maxPreviewName )
}
task , err := m . store . GetTask ( ctx , l . taskID )
if err != nil {
return "" , err
}
last := task . PortBase + store . PortBlock - 1
if args . Port < task . PortBase || args . Port > last {
return "" , fmt . Errorf ( "port %d is outside this task's block, %d to %d" , args . Port , task . PortBase , last )
}
addr := net . JoinHostPort ( "127.0.0.1" , strconv . Itoa ( args . Port ))
conn , err := net . DialTimeout ( "tcp" , addr , time . Second )
if err != nil {
return "" , fmt . Errorf ( "nothing is listening on %s; start the server listening on 127.0.0.1 first: %w" , addr , err )
}
conn . Close ()
2026-10-20 13:32:56 -07:00
err = m . servePreview ( args . Port )
2026-10-15 13:15:49 -07:00
if err != nil {
return "" , err
}
dropped , err := m . store . SetPreview ( ctx , api . Preview { TaskId : task . Id , Name : name , Port : args . Port , CreatedAt : time . Now (). UTC ()}, agentActor )
if errors . Is ( err , store . ErrNotFound ) {
2026-10-20 13:32:56 -07:00
m . stopPreview ( args . Port )
2026-10-15 13:15:49 -07:00
return "" , errors . New ( "this task is no longer active" )
}
if err != nil {
return "" , err
}
for _ , port := range dropped {
2026-10-20 13:32:56 -07:00
m . stopPreview ( port )
}
return fmt . Sprintf ( "Exposed %q: port %d of byre's tailnet node proxies to %s, and the human sees a link to it above the task's tabs." , name , args . Port , addr ), nil
}
func ( m * Manager ) servePreview ( port int ) error {
m . stopPreview ( port )
ln , err := m . tailnet . Listen ( "tcp" , ":" + strconv . Itoa ( port ))
if err != nil {
return fmt . Errorf ( "listen on the tailnet on port %d: %w" , port , err )
}
lc , err := m . tailnet . LocalClient ()
if err != nil {
ln . Close ()
return err
}
tracked := & previewListener { Listener : ln , conns : map [ net . Conn ] bool {}}
m . previewMu . Lock ()
m . previewListeners [ port ] = tracked
m . previewMu . Unlock ()
target := net . JoinHostPort ( "127.0.0.1" , strconv . Itoa ( port ))
proxy := & httputil . ReverseProxy {
Rewrite : func ( pr * httputil . ProxyRequest ) {
pr . Out . URL . Scheme = "http"
pr . Out . URL . Host = target
},
FlushInterval : - 1 ,
}
handler := http . HandlerFunc ( func ( w http . ResponseWriter , r * http . Request ) {
who , err := lc . WhoIs ( r . Context (), r . RemoteAddr )
2026-10-15 13:15:49 -07:00
if err != nil {
2026-10-20 13:32:56 -07:00
http . Error ( w , err . Error (), http . StatusForbidden )
return
}
2026-10-20 22:40:00 -07:00
if who . Node . IsTagged () {
http . Error ( w , "tagged nodes are not users" , http . StatusForbidden )
return
}
2026-10-20 13:32:56 -07:00
r . Header . Set ( "Tailscale-User-Login" , who . UserProfile . LoginName )
r . Header . Set ( "Tailscale-User-Name" , who . UserProfile . DisplayName )
proxy . ServeHTTP ( w , r )
})
go func () {
err := ( & http . Server { Handler : handler }). Serve ( tracked )
if ! errors . Is ( err , net . ErrClosed ) {
slog . Error ( "serve preview" , "port" , port , "err" , err )
2026-10-15 13:15:49 -07:00
}
2026-10-20 13:32:56 -07:00
}()
return nil
}
func ( m * Manager ) stopPreview ( port int ) {
m . previewMu . Lock ()
ln := m . previewListeners [ port ]
delete ( m . previewListeners , port )
m . previewMu . Unlock ()
if ln != nil {
ln . closeAll ()
2026-10-15 13:15:49 -07:00
}
2026-10-20 13:32:56 -07:00
}
2026-10-15 13:15:49 -07:00
2026-10-20 13:32:56 -07:00
type previewListener struct {
net . Listener
mu sync . Mutex
conns map [ net . Conn ] bool
closed bool
}
func ( l * previewListener ) Accept () ( net . Conn , error ) {
c , err := l . Listener . Accept ()
if err != nil {
return nil , err
}
l . mu . Lock ()
defer l . mu . Unlock ()
if l . closed {
c . Close ()
return nil , net . ErrClosed
}
tc := & previewConn { Conn : c , l : l }
l . conns [ tc ] = true
return tc , nil
}
func ( l * previewListener ) closeAll () {
l . Listener . Close ()
l . mu . Lock ()
l . closed = true
conns := l . conns
l . conns = map [ net . Conn ] bool {}
l . mu . Unlock ()
for c := range conns {
c . Close ()
}
}
type previewConn struct {
net . Conn
l * previewListener
}
func ( c * previewConn ) Close () error {
c . l . mu . Lock ()
delete ( c . l . conns , c )
c . l . mu . Unlock ()
return c . Conn . Close ()
}
func ( m * Manager ) recoverPreviews ( ctx context . Context ) error {
previews , err := m . store . ActivePreviews ( ctx )
if err != nil {
return err
}
for _ , p := range previews {
err = m . servePreview ( p . Port )
if err != nil {
slog . Error ( "serve preview" , "task" , p . TaskId , "port" , p . Port , "err" , err )
}
}
return nil
2026-10-15 13:15:49 -07:00
}
func ( m * Manager ) Unexpose ( ctx context . Context , taskID string ) {
previews , err := m . store . ListPreviews ( ctx , taskID )
if err != nil {
slog . Error ( "list previews" , "task" , taskID , "err" , err )
return
}
for _ , p := range previews {
2026-10-20 13:32:56 -07:00
m . stopPreview ( p . Port )
2026-10-15 13:15:49 -07:00
slog . Info ( "stopped serving preview" , "task" , taskID , "name" , p . Name , "port" , p . Port )
}
}