Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
202 lines
6.8 KiB
Go
202 lines
6.8 KiB
Go
package browser
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/tutti-os/tutti/packages/agent/daemon/httpx"
|
|
)
|
|
|
|
const browserNodeResponseLimit = 64 * 1024 * 1024
|
|
|
|
type browserNodeBackend interface {
|
|
Call(context.Context, string, string, string, string, map[string]any) (ToolResult, error)
|
|
ReleaseAgent(context.Context, string) error
|
|
}
|
|
|
|
func (b *browserNodeHTTPBackend) ReleaseAgent(ctx context.Context, agentSessionID string) error {
|
|
agentSessionID = strings.TrimSpace(agentSessionID)
|
|
if agentSessionID == "" {
|
|
return errors.New("BrowserNode Agent session ID is required")
|
|
}
|
|
info, err := b.readListenerInfo()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
payload, err := json.Marshal(map[string]string{"agentSessionId": agentSessionID})
|
|
if err != nil {
|
|
return fmt.Errorf("encode BrowserNode Agent release: %w", err)
|
|
}
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://"+info.Address+"/v1/release-agent", bytes.NewReader(payload))
|
|
if err != nil {
|
|
return fmt.Errorf("create BrowserNode Agent release request: %w", err)
|
|
}
|
|
request.Header.Set("Authorization", "Bearer "+info.Token)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
response, err := b.client.Do(request)
|
|
if err != nil {
|
|
return fmt.Errorf("BrowserNode desktop host is unavailable: %w", err)
|
|
}
|
|
defer response.Body.Close()
|
|
body, err := io.ReadAll(io.LimitReader(response.Body, browserNodeResponseLimit+1))
|
|
if err != nil {
|
|
return fmt.Errorf("read BrowserNode Agent release response: %w", err)
|
|
}
|
|
if len(body) > browserNodeResponseLimit {
|
|
return errors.New("BrowserNode Agent release response is too large")
|
|
}
|
|
if response.StatusCode != http.StatusOK {
|
|
var decoded browserNodeCallResponse
|
|
_ = json.Unmarshal(body, &decoded)
|
|
if decoded.Error != nil && strings.TrimSpace(decoded.Error.Message) != "" {
|
|
return errors.New(decoded.Error.Message)
|
|
}
|
|
return errors.New("BrowserNode Agent release failed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type browserNodeHTTPBackend struct {
|
|
listenerInfoPath string
|
|
client *http.Client
|
|
}
|
|
|
|
// CheckReady performs a bounded, side-effect-free probe of the BrowserNode
|
|
// listener. It intentionally only verifies the authenticated loopback host is
|
|
// reachable; page leases and CDP targets remain per-agent call concerns.
|
|
func (b *browserNodeHTTPBackend) CheckReady(ctx context.Context) error {
|
|
info, err := b.readListenerInfo()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
dialer := net.Dialer{}
|
|
connection, err := dialer.DialContext(ctx, "tcp", info.Address)
|
|
if err != nil {
|
|
return fmt.Errorf("BrowserNode desktop host is unavailable: %w", err)
|
|
}
|
|
_ = connection.Close()
|
|
return nil
|
|
}
|
|
|
|
type browserNodeListenerInfo struct {
|
|
Address string `json:"address"`
|
|
Token string `json:"token"`
|
|
Version int `json:"version"`
|
|
}
|
|
|
|
type browserNodeCallResponse struct {
|
|
Result *struct {
|
|
ScreenshotData string `json:"screenshotData,omitempty"`
|
|
Text string `json:"text"`
|
|
} `json:"result,omitempty"`
|
|
Error *struct {
|
|
Message string `json:"message"`
|
|
} `json:"error,omitempty"`
|
|
}
|
|
|
|
func newBrowserNodeHTTPBackend(listenerInfoPath string) browserNodeBackend {
|
|
return &browserNodeHTTPBackend{
|
|
listenerInfoPath: strings.TrimSpace(listenerInfoPath),
|
|
client: httpx.Default(),
|
|
}
|
|
}
|
|
|
|
func (b *browserNodeHTTPBackend) Call(ctx context.Context, workspaceID, agentSessionID, agentTurnID, tool string, args map[string]any) (ToolResult, error) {
|
|
info, err := b.readListenerInfo()
|
|
if err != nil {
|
|
return ToolResult{}, err
|
|
}
|
|
payload, err := json.Marshal(map[string]any{
|
|
"agentSessionId": strings.TrimSpace(agentSessionID),
|
|
"agentTurnId": strings.TrimSpace(agentTurnID),
|
|
"args": args,
|
|
"tool": tool,
|
|
"workspaceId": strings.TrimSpace(workspaceID),
|
|
})
|
|
if err != nil {
|
|
return ToolResult{}, fmt.Errorf("encode BrowserNode automation call: %w", err)
|
|
}
|
|
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://"+info.Address+"/v1/call", bytes.NewReader(payload))
|
|
if err != nil {
|
|
return ToolResult{}, fmt.Errorf("create BrowserNode automation request: %w", err)
|
|
}
|
|
request.Header.Set("Authorization", "Bearer "+info.Token)
|
|
request.Header.Set("Content-Type", "application/json")
|
|
response, err := b.client.Do(request)
|
|
if err != nil {
|
|
return ToolResult{}, fmt.Errorf("BrowserNode desktop host is unavailable: %w", err)
|
|
}
|
|
defer response.Body.Close()
|
|
body, err := io.ReadAll(io.LimitReader(response.Body, browserNodeResponseLimit+1))
|
|
if err != nil {
|
|
return ToolResult{}, fmt.Errorf("read BrowserNode automation response: %w", err)
|
|
}
|
|
if len(body) > browserNodeResponseLimit {
|
|
return ToolResult{}, errors.New("BrowserNode automation response is too large")
|
|
}
|
|
var decoded browserNodeCallResponse
|
|
if err := json.Unmarshal(body, &decoded); err != nil {
|
|
return ToolResult{}, fmt.Errorf("decode BrowserNode automation response: %w", err)
|
|
}
|
|
if response.StatusCode != http.StatusOK || decoded.Error != nil {
|
|
message := "BrowserNode automation request failed"
|
|
if decoded.Error != nil && strings.TrimSpace(decoded.Error.Message) != "" {
|
|
message = decoded.Error.Message
|
|
}
|
|
return ToolResult{}, errors.New(message)
|
|
}
|
|
if decoded.Result == nil {
|
|
return ToolResult{}, errors.New("BrowserNode automation response is missing a result")
|
|
}
|
|
|
|
result := ToolResult{Text: decoded.Result.Text}
|
|
if decoded.Result.ScreenshotData == "" {
|
|
return result, nil
|
|
}
|
|
if filePath, _ := args["filePath"].(string); strings.TrimSpace(filePath) != "" {
|
|
image, err := base64.StdEncoding.DecodeString(decoded.Result.ScreenshotData)
|
|
if err != nil {
|
|
return ToolResult{}, fmt.Errorf("decode BrowserNode screenshot: %w", err)
|
|
}
|
|
if err := os.WriteFile(filePath, image, 0o600); err != nil {
|
|
return ToolResult{}, fmt.Errorf("write BrowserNode screenshot: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|
|
result.Images = []ToolImage{{Data: decoded.Result.ScreenshotData, MimeType: "image/png"}}
|
|
return result, nil
|
|
}
|
|
|
|
func (b *browserNodeHTTPBackend) readListenerInfo() (browserNodeListenerInfo, error) {
|
|
body, err := os.ReadFile(b.listenerInfoPath)
|
|
if err != nil {
|
|
return browserNodeListenerInfo{}, fmt.Errorf("BrowserNode desktop host is unavailable: %w", err)
|
|
}
|
|
var info browserNodeListenerInfo
|
|
if err := json.Unmarshal(body, &info); err != nil {
|
|
return browserNodeListenerInfo{}, fmt.Errorf("decode BrowserNode listener info: %w", err)
|
|
}
|
|
if info.Version != 1 || strings.TrimSpace(info.Token) == "" {
|
|
return browserNodeListenerInfo{}, errors.New("BrowserNode listener info is invalid")
|
|
}
|
|
host, _, err := net.SplitHostPort(info.Address)
|
|
if err != nil {
|
|
return browserNodeListenerInfo{}, errors.New("BrowserNode listener address is invalid")
|
|
}
|
|
ip := net.ParseIP(host)
|
|
if ip == nil || !ip.IsLoopback() {
|
|
return browserNodeListenerInfo{}, errors.New("BrowserNode listener must use a loopback address")
|
|
}
|
|
return info, nil
|
|
}
|