diff --git a/cmd/gateway.go b/cmd/gateway.go index 8f468eba..65593b2b 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -168,12 +168,17 @@ func runGateway() { // Browser automation tool var browserMgr *browser.Manager if cfg.Tools.Browser.Enabled { - browserMgr = browser.New( - browser.WithHeadless(cfg.Tools.Browser.Headless), - ) + var opts []browser.Option + if cfg.Tools.Browser.RemoteURL != "" { + opts = append(opts, browser.WithRemoteURL(cfg.Tools.Browser.RemoteURL)) + slog.Info("browser tool enabled", "remote", cfg.Tools.Browser.RemoteURL) + } else { + opts = append(opts, browser.WithHeadless(cfg.Tools.Browser.Headless)) + slog.Info("browser tool enabled", "headless", cfg.Tools.Browser.Headless) + } + browserMgr = browser.New(opts...) toolsReg.Register(browser.NewBrowserTool(browserMgr)) defer browserMgr.Close() - slog.Info("browser tool enabled", "headless", cfg.Tools.Browser.Headless) } // Web tools (web_search + web_fetch) diff --git a/cmd/onboard.go b/cmd/onboard.go index 763989aa..58a080cd 100644 --- a/cmd/onboard.go +++ b/cmd/onboard.go @@ -661,7 +661,9 @@ func runOnboard() { } else { fmt.Println(" Memory: disabled") } - if cfg.Tools.Browser.Enabled { + if cfg.Tools.Browser.RemoteURL != "" { + fmt.Printf(" Browser: enabled (remote: %s)\n", cfg.Tools.Browser.RemoteURL) + } else if cfg.Tools.Browser.Enabled { fmt.Println(" Browser: enabled (headless)") } else { fmt.Println(" Browser: disabled") diff --git a/cmd/onboard_auto.go b/cmd/onboard_auto.go index 2511e5b4..f3e0fff7 100644 --- a/cmd/onboard_auto.go +++ b/cmd/onboard_auto.go @@ -261,8 +261,9 @@ func saveCleanConfig(cfgPath string, cfg *config.Config) error { }, }, "browser": map[string]interface{}{ - "enabled": cfg.Tools.Browser.Enabled, - "headless": cfg.Tools.Browser.Headless, + "enabled": cfg.Tools.Browser.Enabled, + "headless": cfg.Tools.Browser.Headless, + "remote_url": cfg.Tools.Browser.RemoteURL, }, "execApproval": map[string]interface{}{ "security": nonEmpty(cfg.Tools.ExecApproval.Security, "full"), diff --git a/docker-compose.browser.yml b/docker-compose.browser.yml new file mode 100644 index 00000000..0a22c2f8 --- /dev/null +++ b/docker-compose.browser.yml @@ -0,0 +1,39 @@ +# Browser sidecar overlay — adds headless Chrome for the browser automation tool. +# +# Usage (with managed mode): +# docker compose -f docker-compose.yml -f docker-compose.managed.yml -f docker-compose.browser.yml up -d --build +# +# The Chrome sidecar exposes CDP (Chrome DevTools Protocol) on port 9222. +# GoClaw connects to it automatically via GOCLAW_BROWSER_REMOTE_URL. + +services: + chrome: + image: zenika/alpine-chrome:124 + command: + - --no-sandbox + - --remote-debugging-address=0.0.0.0 + - --remote-debugging-port=9222 + - --remote-allow-origins=* + - --disable-gpu + - --disable-dev-shm-usage + ports: + - "${CHROME_CDP_PORT:-9222}:9222" + shm_size: 2gb + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9222/json/version >/dev/null 2>&1"] + interval: 5s + timeout: 3s + retries: 5 + deploy: + resources: + limits: + memory: 2G + cpus: '2.0' + restart: unless-stopped + + goclaw: + environment: + - GOCLAW_BROWSER_REMOTE_URL=ws://chrome:9222 + depends_on: + chrome: + condition: service_healthy diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index 541f3e91..287395d3 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -263,8 +263,9 @@ type WebFetchPolicyConfig struct { // BrowserToolConfig controls the browser automation tool. type BrowserToolConfig struct { - Enabled bool `json:"enabled"` // enable the browser tool (default false) - Headless bool `json:"headless,omitempty"` // run Chrome in headless mode + Enabled bool `json:"enabled"` // enable the browser tool (default false) + Headless bool `json:"headless,omitempty"` // run Chrome in headless mode (ignored when RemoteURL is set) + RemoteURL string `json:"remote_url,omitempty"` // CDP endpoint for remote Chrome sidecar, e.g. "ws://chrome:9222" } // ToolPolicySpec defines a tool policy at any level (global, per-agent, per-provider). diff --git a/internal/config/config_load.go b/internal/config/config_load.go index 5c7ba914..2514b619 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -227,6 +227,12 @@ func (c *Config) applyEnvOverrides() { ensureSandbox() c.Agents.Defaults.Sandbox.NetworkEnabled = v == "true" || v == "1" } + + // Browser (for Docker-compose browser sidecar overlay) + envStr("GOCLAW_BROWSER_REMOTE_URL", &c.Tools.Browser.RemoteURL) + if c.Tools.Browser.RemoteURL != "" { + c.Tools.Browser.Enabled = true + } } // applyContextPruningDefaults auto-enables context pruning when the Anthropic diff --git a/pkg/browser/browser.go b/pkg/browser/browser.go index ac0fa716..2ccf9074 100644 --- a/pkg/browser/browser.go +++ b/pkg/browser/browser.go @@ -2,8 +2,12 @@ package browser import ( "context" + "encoding/json" "fmt" "log/slog" + "net" + "net/http" + "net/url" "sync" "time" @@ -14,13 +18,14 @@ import ( // Manager handles the Chrome browser lifecycle and page management. type Manager struct { - mu sync.Mutex - browser *rod.Browser - refs *RefStore - pages map[string]*rod.Page // targetID → page - console map[string][]ConsoleMessage // targetID → console messages - headless bool - logger *slog.Logger + mu sync.Mutex + browser *rod.Browser + refs *RefStore + pages map[string]*rod.Page // targetID → page + console map[string][]ConsoleMessage // targetID → console messages + headless bool + remoteURL string // CDP endpoint for remote Chrome (sidecar); skips local launcher + logger *slog.Logger } // Option configures a Manager. @@ -31,6 +36,12 @@ func WithHeadless(h bool) Option { return func(m *Manager) { m.headless = h } } +// WithRemoteURL sets a remote CDP endpoint (e.g. "ws://chrome:9222"). +// When set, Start() connects to the remote Chrome instead of launching locally. +func WithRemoteURL(url string) Option { + return func(m *Manager) { m.remoteURL = url } +} + // WithLogger sets a custom logger. func WithLogger(l *slog.Logger) Option { return func(m *Manager) { m.logger = l } @@ -50,28 +61,50 @@ func New(opts ...Option) *Manager { return m } -// Start launches a Chrome browser. +// Start launches a local Chrome browser or connects to a remote one. +// If already connected but the connection is dead, it reconnects automatically. func (m *Manager) Start(ctx context.Context) error { m.mu.Lock() defer m.mu.Unlock() + // If browser exists, check if connection is still alive if m.browser != nil { - return fmt.Errorf("browser already running") + if _, err := m.browser.Pages(); err == nil { + return nil // already connected and healthy + } + // Connection dead — clean up and reconnect + m.logger.Info("browser connection lost, reconnecting") + m.browser = nil + m.pages = make(map[string]*rod.Page) + m.console = make(map[string][]ConsoleMessage) } - l := launcher.New(). - Headless(m.headless). - Set("disable-gpu"). - Set("no-first-run"). - Set("no-default-browser-check") + var controlURL string - controlURL, err := l.Launch() - if err != nil { - return fmt.Errorf("launch Chrome: %w", err) + if m.remoteURL != "" { + // Remote Chrome sidecar — query /json/version and fix host for Docker networking + u, err := resolveRemoteCDP(m.remoteURL) + if err != nil { + return fmt.Errorf("resolve remote Chrome at %s: %w", m.remoteURL, err) + } + controlURL = u + m.logger.Info("connecting to remote Chrome", "cdp", controlURL, "remote", m.remoteURL) + } else { + // Local Chrome — launch via rod launcher + l := launcher.New(). + Headless(m.headless). + Set("disable-gpu"). + Set("no-first-run"). + Set("no-default-browser-check") + + u, err := l.Launch() + if err != nil { + return fmt.Errorf("launch Chrome: %w", err) + } + controlURL = u + m.logger.Info("Chrome launched", "cdp", controlURL, "headless", m.headless) } - m.logger.Info("Chrome launched", "cdp", controlURL, "headless", m.headless) - b := rod.New().ControlURL(controlURL) if err := b.Connect(); err != nil { return fmt.Errorf("connect to Chrome: %w", err) @@ -81,7 +114,7 @@ func (m *Manager) Start(ctx context.Context) error { return nil } -// Stop closes the Chrome browser. +// Stop closes the Chrome browser (local) or disconnects (remote sidecar). func (m *Manager) Stop(ctx context.Context) error { m.mu.Lock() defer m.mu.Unlock() @@ -90,7 +123,13 @@ func (m *Manager) Stop(ctx context.Context) error { return nil } - err := m.browser.Close() + var err error + if m.remoteURL == "" { + // Local Chrome — close the browser process + err = m.browser.Close() + } + // Remote Chrome — just drop the connection; sidecar stays alive + m.browser = nil m.pages = make(map[string]*rod.Page) m.console = make(map[string][]ConsoleMessage) @@ -304,8 +343,29 @@ func (m *Manager) Refs() *RefStore { return m.refs } +// reconnectLocked re-establishes the CDP connection to a remote Chrome. +// Must be called with m.mu held. Only works when remoteURL is set. +func (m *Manager) reconnectLocked() error { + m.browser = nil + m.pages = make(map[string]*rod.Page) + m.console = make(map[string][]ConsoleMessage) + + controlURL, err := resolveRemoteCDP(m.remoteURL) + if err != nil { + return err + } + + b := rod.New().ControlURL(controlURL) + if err := b.Connect(); err != nil { + return err + } + m.browser = b + return nil +} + // getPage looks up a page by targetID. If targetID is empty, returns the first available page. -// Must be called with m.mu held. +// Must be called with m.mu held. If the connection is dead and remoteURL is set, +// it attempts one automatic reconnect. func (m *Manager) getPage(targetID string) (*rod.Page, error) { if m.browser == nil { return nil, fmt.Errorf("browser not running") @@ -321,7 +381,19 @@ func (m *Manager) getPage(targetID string) (*rod.Page, error) { // Refresh page list from browser pages, err := m.browser.Pages() if err != nil { - return nil, fmt.Errorf("list pages: %w", err) + // Connection dead — try auto-reconnect for remote Chrome + if m.remoteURL != "" { + if reconnErr := m.reconnectLocked(); reconnErr != nil { + return nil, fmt.Errorf("list pages: %w (reconnect also failed: %v)", err, reconnErr) + } + m.logger.Info("auto-reconnected to remote Chrome") + pages, err = m.browser.Pages() + if err != nil { + return nil, fmt.Errorf("list pages after reconnect: %w", err) + } + } else { + return nil, fmt.Errorf("list pages: %w", err) + } } // Update cache @@ -427,3 +499,94 @@ func (m *Manager) getPageAndResolve(targetID, ref string) (*rod.Page, *rod.Eleme func waitStable(page *rod.Page) { _ = page.WaitStable(300 * time.Millisecond) } + +// resolveRemoteCDP queries a Chrome endpoint's /json/version to get the CDP +// WebSocket URL, resolving the hostname to an IP address. +// +// Chrome (M113+) rejects HTTP/WebSocket requests where the Host header is a +// hostname (not an IP or "localhost") to prevent DNS rebinding attacks. +// In Docker, the service name "chrome" is a hostname, so we resolve it to an +// IP address and use that for all connections. + +// cdpHTTPClient is used for /json/version queries with a reasonable timeout. +var cdpHTTPClient = &http.Client{Timeout: 10 * time.Second} + +func resolveRemoteCDP(remoteURL string) (string, error) { + parsed, err := url.Parse(remoteURL) + if err != nil { + return "", fmt.Errorf("parse remote URL: %w", err) + } + + host := parsed.Hostname() + port := parsed.Port() + if port == "" { + port = "9222" + } + + // Resolve hostname to IP — Chrome M113+ requires IP or "localhost" in + // the Host header to prevent DNS rebinding attacks. + ip, err := resolveToIPv4(host) + if err != nil { + return "", err + } + + // Query /json/version using the IP (so Host header is an IP). + versionURL := fmt.Sprintf("http://%s:%s/json/version", ip, port) + resp, err := cdpHTTPClient.Get(versionURL) //nolint:gosec // resolved from user-configured URL + if err != nil { + return "", fmt.Errorf("query /json/version at %s: %w", versionURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("/json/version returned HTTP %d", resp.StatusCode) + } + + var ver struct { + WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"` + } + if err := json.NewDecoder(resp.Body).Decode(&ver); err != nil { + return "", fmt.Errorf("parse /json/version: %w", err) + } + if ver.WebSocketDebuggerURL == "" { + return "", fmt.Errorf("empty webSocketDebuggerUrl in /json/version response") + } + + // Replace host in returned URL with the resolved IP. + // Chrome returns ws://127.0.0.1/... but we need ws://:/... + wsURL, err := url.Parse(ver.WebSocketDebuggerURL) + if err != nil { + return "", fmt.Errorf("parse webSocketDebuggerUrl: %w", err) + } + wsURL.Host = net.JoinHostPort(ip, port) + return wsURL.String(), nil +} + +// resolveToIPv4 resolves a hostname to an IPv4 address. +// Chrome typically binds on 0.0.0.0 (IPv4), so we prefer IPv4 to avoid +// connection failures when DNS returns IPv6 addresses first. +// If the host is already an IP, it is returned as-is. +func resolveToIPv4(host string) (string, error) { + // Already an IP literal — return as-is. + if net.ParseIP(host) != nil { + return host, nil + } + + ips, err := net.LookupHost(host) + if err != nil { + return "", fmt.Errorf("resolve %s: %w", host, err) + } + + // Prefer IPv4. + for _, ip := range ips { + if parsed := net.ParseIP(ip); parsed != nil && parsed.To4() != nil { + return ip, nil + } + } + + // Fallback: return first address (could be IPv6). + if len(ips) > 0 { + return ips[0], nil + } + return "", fmt.Errorf("no addresses found for %s", host) +} diff --git a/pkg/browser/browser_test.go b/pkg/browser/browser_test.go new file mode 100644 index 00000000..77d250c8 --- /dev/null +++ b/pkg/browser/browser_test.go @@ -0,0 +1,224 @@ +package browser + +import ( + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// --- resolveToIPv4 --- + +func TestResolveToIPv4_IPLiteral(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"127.0.0.1", "127.0.0.1"}, + {"192.168.1.1", "192.168.1.1"}, + {"::1", "::1"}, + } + for _, tt := range tests { + got, err := resolveToIPv4(tt.input) + if err != nil { + t.Errorf("resolveToIPv4(%q) unexpected error: %v", tt.input, err) + continue + } + if got != tt.want { + t.Errorf("resolveToIPv4(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestResolveToIPv4_Localhost(t *testing.T) { + ip, err := resolveToIPv4("localhost") + if err != nil { + t.Fatalf("resolveToIPv4(localhost) error: %v", err) + } + // Should resolve to 127.0.0.1 (IPv4 preferred) + parsed := net.ParseIP(ip) + if parsed == nil { + t.Fatalf("resolveToIPv4(localhost) returned non-IP: %q", ip) + } + if parsed.To4() == nil { + t.Logf("resolveToIPv4(localhost) returned IPv6 %q (no IPv4 available)", ip) + } +} + +func TestResolveToIPv4_UnknownHost(t *testing.T) { + _, err := resolveToIPv4("this-host-definitely-does-not-exist.invalid") + if err == nil { + t.Fatal("expected error for unknown host, got nil") + } +} + +// --- resolveRemoteCDP --- + +func TestResolveRemoteCDP_Success(t *testing.T) { + // Start a fake Chrome /json/version endpoint. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/json/version" { + http.NotFound(w, r) + return + } + json.NewEncoder(w).Encode(map[string]string{ + "webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/browser/abc-123", + }) + })) + defer srv.Close() + + // Extract host:port from test server URL. + wsURL := "ws://" + srv.Listener.Addr().String() + + got, err := resolveRemoteCDP(wsURL) + if err != nil { + t.Fatalf("resolveRemoteCDP(%q) error: %v", wsURL, err) + } + + // Should contain the devtools path. + if !strings.Contains(got, "/devtools/browser/abc-123") { + t.Errorf("resolveRemoteCDP result missing devtools path: %q", got) + } + // Should be a ws:// URL. + if !strings.HasPrefix(got, "ws://") { + t.Errorf("resolveRemoteCDP result should start with ws://: %q", got) + } +} + +func TestResolveRemoteCDP_NonOKStatus(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("Chrome is not ready")) + })) + defer srv.Close() + + wsURL := "ws://" + srv.Listener.Addr().String() + _, err := resolveRemoteCDP(wsURL) + if err == nil { + t.Fatal("expected error for 500 status, got nil") + } + if !strings.Contains(err.Error(), "HTTP 500") { + t.Errorf("error should mention HTTP 500: %v", err) + } +} + +func TestResolveRemoteCDP_EmptyWebSocketURL(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]string{ + "Browser": "HeadlessChrome/124.0", + // webSocketDebuggerUrl intentionally missing + }) + })) + defer srv.Close() + + wsURL := "ws://" + srv.Listener.Addr().String() + _, err := resolveRemoteCDP(wsURL) + if err == nil { + t.Fatal("expected error for empty webSocketDebuggerUrl, got nil") + } + if !strings.Contains(err.Error(), "empty webSocketDebuggerUrl") { + t.Errorf("error should mention empty URL: %v", err) + } +} + +func TestResolveRemoteCDP_InvalidJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("not json")) + })) + defer srv.Close() + + wsURL := "ws://" + srv.Listener.Addr().String() + _, err := resolveRemoteCDP(wsURL) + if err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } +} + +func TestResolveRemoteCDP_ConnectionRefused(t *testing.T) { + // Use a port that's definitely not listening. + _, err := resolveRemoteCDP("ws://127.0.0.1:1") + if err == nil { + t.Fatal("expected error for connection refused, got nil") + } +} + +func TestResolveRemoteCDP_InvalidURL(t *testing.T) { + _, err := resolveRemoteCDP("://invalid") + if err == nil { + t.Fatal("expected error for invalid URL, got nil") + } +} + +func TestResolveRemoteCDP_DefaultPort(t *testing.T) { + // Verify that when port is omitted, 9222 is used. + // This will fail to connect but the error should reference port 9222. + _, err := resolveRemoteCDP("ws://127.0.0.1") + if err == nil { + t.Fatal("expected error (nothing on 9222), got nil") + } + if !strings.Contains(err.Error(), "9222") { + t.Errorf("error should reference default port 9222: %v", err) + } +} + +func TestResolveRemoteCDP_HostReplacement(t *testing.T) { + // Chrome returns ws://127.0.0.1/... but we need the server's actual IP:port. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/json/version" { + http.NotFound(w, r) + return + } + // Simulate Chrome returning localhost in the WS URL. + json.NewEncoder(w).Encode(map[string]string{ + "webSocketDebuggerUrl": "ws://localhost:9999/devtools/browser/xyz", + }) + })) + defer srv.Close() + + wsURL := "ws://" + srv.Listener.Addr().String() + got, err := resolveRemoteCDP(wsURL) + if err != nil { + t.Fatalf("resolveRemoteCDP(%q) error: %v", wsURL, err) + } + + // The host in the result should be the test server's address, NOT localhost:9999. + if strings.Contains(got, "localhost:9999") { + t.Errorf("host should be replaced but still has localhost:9999: %q", got) + } + if !strings.Contains(got, "/devtools/browser/xyz") { + t.Errorf("path should be preserved: %q", got) + } +} + +// --- Manager options --- + +func TestManagerOptions(t *testing.T) { + m := New( + WithHeadless(true), + WithRemoteURL("ws://chrome:9222"), + ) + if !m.headless { + t.Error("WithHeadless(true) not applied") + } + if m.remoteURL != "ws://chrome:9222" { + t.Errorf("WithRemoteURL not applied: %q", m.remoteURL) + } +} + +func TestManagerStopWhenNil(t *testing.T) { + m := New() + // Stop on a fresh manager should be a no-op. + if err := m.Close(); err != nil { + t.Errorf("Close() on nil browser should be nil, got: %v", err) + } +} + +func TestManagerStatusWhenStopped(t *testing.T) { + m := New() + status := m.Status() + if status.Running { + t.Error("Status.Running should be false when browser is nil") + } +} diff --git a/pkg/browser/tool.go b/pkg/browser/tool.go index c6decebb..63e151a0 100644 --- a/pkg/browser/tool.go +++ b/pkg/browser/tool.go @@ -137,6 +137,14 @@ func (t *BrowserTool) Execute(ctx context.Context, args map[string]interface{}) return tools.ErrorResult("action is required") } + // Auto-start browser for actions that need it + switch action { + case "open", "snapshot", "screenshot", "navigate", "act", "tabs", "console": + if err := t.manager.Start(ctx); err != nil { + return tools.ErrorResult(fmt.Sprintf("failed to start browser: %v", err)) + } + } + switch action { case "status": return t.handleStatus()