feat(browser): add remote Chrome sidecar support for Docker deployments (#59)

* feat(browser): add remote Chrome sidecar support for Docker deployments

When running in Docker, Chrome is not installed in the runtime image.
This adds support for connecting to a remote Chrome via CDP (Chrome
DevTools Protocol) using a Docker Compose sidecar overlay, following
the existing pattern used by sandbox, OTel, and Tailscale overlays.

Changes:
- Add RemoteURL field to BrowserToolConfig
- Add GOCLAW_BROWSER_REMOTE_URL env var (auto-enables browser tool)
- Browser Manager: remote CDP connection with hostname-to-IP resolution
  (required by Chrome M113+ DNS rebinding protection), auto-reconnect
  on dead connections, disconnect-only on Stop (sidecar stays alive)
- Auto-start browser on first tool action (no explicit "start" needed)
- Add docker-compose.browser.yml overlay (zenika/alpine-chrome:124)
- Add unit tests for CDP resolution and Manager lifecycle

Usage:
  docker compose -f docker-compose.yml -f docker-compose.managed.yml \
    -f docker-compose.browser.yml up -d --build

Closes #56

* feat(browser): fix onboard summary and config serialization for remote mode

- onboard.go: show "remote: ws://..." instead of "headless" when RemoteURL is set
- onboard_auto.go: serialize remote_url field in generated config

---------

Co-authored-by: Luvu182 <208665161+Luvu182@users.noreply.github.com>
This commit is contained in:
Luvu182andLuvu182 authored and GitHub committed 2026-03-05 10:09:08 +07:00
1 parent 4af4c69d3f
commit 62a6ca9ee5
9 files changed
+481 -32

No files matched your search

+9 -4
View File
@@ -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)
+3 -1
View File
@@ -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")
+3 -2
View File
@@ -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"),
+39
View File
@@ -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
+3 -2
View File
@@ -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).
+6
View File
@@ -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
+186 -23
View File
@@ -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://<container-IP>:<port>/...
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)
}
+224
View File
@@ -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")
}
}
+8
View File
@@ -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()