Files
agent-radar 2ed3415fb7
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
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

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
}