From f13bf04402f263c586dcea33e13a7ea906dedd68 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Fri, 24 Apr 2026 09:42:13 +0200 Subject: [PATCH 1/8] feat(config): add browser Backend field + GOCLAW_BROWSER_BACKEND env Introduces an explicit backend selector for the browser automation tool ("chrome" or "lightpanda"). Empty means auto-detect, which will be wired in a later commit. Chrome remains the default behavior. --- internal/config/config_channels.go | 1 + internal/config/config_load.go | 1 + 2 files changed, 2 insertions(+) diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index 41c8353f..0095bac0 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -523,6 +523,7 @@ type WebFetchPolicyConfig struct { // BrowserToolConfig controls the browser automation tool. type BrowserToolConfig struct { Enabled bool `json:"enabled"` // enable the browser tool (default false) + Backend string `json:"backend,omitempty"` // "chrome" (default) or "lightpanda"; auto-detected from /json/version if empty 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" ActionTimeoutMs int `json:"action_timeout_ms,omitempty"` // per-action timeout in ms (default 30000) diff --git a/internal/config/config_load.go b/internal/config/config_load.go index 07429b63..075500e0 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -390,6 +390,7 @@ func (c *Config) applyEnvOverrides() { // Browser (for Docker-compose browser sidecar overlay) envStr("GOCLAW_BROWSER_REMOTE_URL", &c.Tools.Browser.RemoteURL) + envStr("GOCLAW_BROWSER_BACKEND", &c.Tools.Browser.Backend) if c.Tools.Browser.RemoteURL != "" { c.Tools.Browser.Enabled = true } From f4c7fe2c91b7798772c7624d96ceac030c3d4ac7 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Fri, 24 Apr 2026 09:49:35 +0200 Subject: [PATCH 2/8] feat(browser): add Lightpanda backend support Lightpanda requires one CDP connection per tab (vs Chrome's shared-WS model). Add a Backend selector in pkg/browser/, branched lifecycle, and per-tab connection tracking via pageConns. - Backend type (chrome | lightpanda), WithBackend option, Backend() accessor. Auto-detected from /json/version when unset. - Lightpanda OpenTab mints a fresh conn, calls Target.createBrowserContext (required per-conn) + createTarget. - ListTabs / getPage are local-map only on Lightpanda (no /json/list, no auto-reconnect). - Shared closeManagedPageLocked helper used by CloseTab, evict, and the idle-page reaper. - Screenshot tool action errors out on Lightpanda (returns a placeholder image upstream; route the agent to snapshot instead). --- pkg/browser/browser.go | 188 ++++++++++++++++++++++++++++++---- pkg/browser/browser_reaper.go | 19 +--- pkg/browser/browser_remote.go | 51 +++++++-- pkg/browser/browser_tabs.go | 139 ++++++++++++++++++++++--- pkg/browser/tool.go | 4 + 5 files changed, 346 insertions(+), 55 deletions(-) diff --git a/pkg/browser/browser.go b/pkg/browser/browser.go index 8db1726f..6d69682f 100644 --- a/pkg/browser/browser.go +++ b/pkg/browser/browser.go @@ -2,8 +2,11 @@ package browser import ( "context" + "encoding/json" "fmt" "log/slog" + "net/url" + "strings" "sync" "time" @@ -11,17 +14,37 @@ import ( "github.com/go-rod/rod/lib/launcher" ) -// Manager handles the Chrome browser lifecycle and page management. +// Backend identifies the CDP browser goclaw is talking to. +// Chrome multiplexes all tabs/contexts over a single WS; Lightpanda requires +// one CDP connection per tab (each connection is its own browser). +type Backend string + +const ( + BackendChrome Backend = "chrome" + BackendLightpanda Backend = "lightpanda" +) + +// Manager handles the browser lifecycle and page management. +// +// Two backends are supported: +// - Chrome (default): one shared *rod.Browser, tabs are pages within it, +// tenants are isolated via Incognito browser contexts. +// - Lightpanda: no shared browser. Each tab mints its own CDP connection +// (tracked in pageConns). Tenant isolation is implicit — every connection +// is a fresh browser server-side. type Manager struct { mu sync.Mutex - browser *rod.Browser + browser *rod.Browser // chrome only; nil on lightpanda launcher *launcher.Launcher // retained for PID-based cleanup on crash refs *RefStore pages map[string]*rod.Page // targetID → page + pageConns map[string]*rod.Browser // lightpanda only: targetID → dedicated CDP conn console map[string][]ConsoleMessage // targetID → console messages - tenantCtxs map[string]*rod.Browser // browser scope key → incognito browser context + tenantCtxs map[string]*rod.Browser // chrome only: browser scope key → incognito browser context pageTenants map[string]string // targetID → browser scope key (for filtering) pageLastUsed map[string]time.Time // targetID → last access time + backend Backend // "chrome" or "lightpanda"; auto-detected in Start() if empty + cdpURL string // resolved CDP WS URL for remote sidecar; used to mint new conns on lightpanda headless bool remoteURL string // CDP endpoint for remote Chrome (sidecar); skips local launcher actionTimeout time.Duration // per-action context timeout (default 30s) @@ -41,11 +64,18 @@ func WithHeadless(h bool) Option { } // WithRemoteURL sets a remote CDP endpoint (e.g. "ws://chrome:9222"). -// When set, Start() connects to the remote Chrome instead of launching locally. +// When set, Start() connects to the remote sidecar instead of launching locally. func WithRemoteURL(url string) Option { return func(m *Manager) { m.remoteURL = url } } +// WithBackend sets the browser backend explicitly ("chrome" or "lightpanda"). +// If unset and RemoteURL is configured, Start() probes /json/version and +// auto-detects. Local (non-remote) launches are always Chrome. +func WithBackend(b Backend) Option { + return func(m *Manager) { m.backend = b } +} + // WithLogger sets a custom logger. func WithLogger(l *slog.Logger) Option { return func(m *Manager) { m.logger = l } @@ -76,6 +106,7 @@ func New(opts ...Option) *Manager { m := &Manager{ refs: NewRefStore(), pages: make(map[string]*rod.Page), + pageConns: make(map[string]*rod.Browser), console: make(map[string][]ConsoleMessage), tenantCtxs: make(map[string]*rod.Browser), pageTenants: make(map[string]string), @@ -103,6 +134,64 @@ func (m *Manager) SetCookieProvider(p CookieProvider) { m.cookieProvider = p } +// Backend returns the current backend (resolved after Start()). +func (m *Manager) Backend() Backend { + m.mu.Lock() + defer m.mu.Unlock() + return m.backend +} + +// isRunningLocked reports whether the manager has an active backend connection. +// Must be called with mu held. +func (m *Manager) isRunningLocked() bool { + if m.backend == BackendLightpanda { + return m.cdpURL != "" + } + return m.browser != nil +} + +// resetPageMapsLocked clears all page-related tracking. Must be called with mu held. +func (m *Manager) resetPageMapsLocked() { + m.pages = make(map[string]*rod.Page) + m.pageConns = make(map[string]*rod.Browser) + m.console = make(map[string][]ConsoleMessage) + m.pageTenants = make(map[string]string) + m.pageLastUsed = make(map[string]time.Time) +} + +// probeBackendLocked queries /json/version to detect whether the remote is +// Lightpanda or Chrome. Must be called with mu held. Falls back to Chrome on +// any error or ambiguity — Chrome is the safe default. +func (m *Manager) probeBackendLocked() Backend { + if m.remoteURL == "" { + return BackendChrome // local launcher is Chrome + } + parsed, err := url.Parse(m.remoteURL) + if err != nil { + return BackendChrome + } + host := parsed.Hostname() + port := parsed.Port() + if port == "" { + port = "9222" + } + resp, err := cdpHTTPClient.Get(fmt.Sprintf("http://%s:%s/json/version", host, port)) //nolint:gosec // user-configured remote URL + if err != nil { + return BackendChrome + } + defer resp.Body.Close() + var ver struct { + Browser string `json:"Browser"` + } + if err := json.NewDecoder(resp.Body).Decode(&ver); err != nil { + return BackendChrome + } + if strings.Contains(strings.ToLower(ver.Browser), "lightpanda") { + return BackendLightpanda + } + return BackendChrome +} + // touchPageLocked updates the last-used timestamp for a page. Must be called with mu held. func (m *Manager) touchPageLocked(targetID string) { m.pageLastUsed[targetID] = time.Now() @@ -110,10 +199,31 @@ func (m *Manager) touchPageLocked(targetID string) { // Start launches a local Chrome browser or connects to a remote one. // If already connected but the connection is dead, it reconnects automatically. +// +// On Lightpanda there is no persistent shared browser — Start() only resolves +// the CDP URL and (optionally) auto-detects the backend. Each tab will mint +// its own CDP connection when opened. func (m *Manager) Start(ctx context.Context) error { m.mu.Lock() defer m.mu.Unlock() + // Lightpanda path: no persistent browser. Resolve CDP URL once, start reaper. + if m.backend == BackendLightpanda { + if m.cdpURL == "" { + u, err := resolveRemoteCDP(m.remoteURL) + if err != nil { + return fmt.Errorf("resolve remote CDP at %s: %w", m.remoteURL, err) + } + m.cdpURL = u + m.logger.Info("lightpanda backend ready", "cdp", m.cdpURL) + } + if m.idleTimeout > 0 && m.stopReaper == nil { + m.stopReaper = make(chan struct{}) + go m.runReaper() + } + return nil + } + // If browser exists, check if connection is still alive if m.browser != nil { if _, err := m.browser.Pages(); err == nil { @@ -127,12 +237,27 @@ func (m *Manager) Start(ctx context.Context) error { var controlURL string if m.remoteURL != "" { - // Remote Chrome sidecar — query /json/version and fix host for Docker networking + // Remote 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) + return fmt.Errorf("resolve remote CDP at %s: %w", m.remoteURL, err) } controlURL = u + m.cdpURL = u + + // Auto-detect backend if caller didn't set one explicitly. + if m.backend == "" { + m.backend = m.probeBackendLocked() + m.logger.Info("auto-detected browser backend", "backend", m.backend) + if m.backend == BackendLightpanda { + // Switch to lightpanda-style lifecycle: no persistent browser. + if m.idleTimeout > 0 && m.stopReaper == nil { + m.stopReaper = make(chan struct{}) + go m.runReaper() + } + return nil + } + } m.logger.Info("connecting to remote Chrome", "cdp", controlURL, "remote", m.remoteURL) } else { // Local Chrome — launch via rod launcher with stability flags @@ -160,6 +285,7 @@ func (m *Manager) Start(ctx context.Context) error { } controlURL = u m.launcher = l + m.backend = BackendChrome // local launcher is always Chrome m.logger.Info("Chrome launched", "cdp", controlURL, "headless", m.headless, "pid", l.PID()) } @@ -188,7 +314,9 @@ func (m *Manager) Start(ctx context.Context) error { return nil } -// Stop closes the Chrome browser (local) or disconnects (remote sidecar). +// Stop closes the browser (local) or disconnects (remote sidecar). +// On Lightpanda, closes every per-tab CDP connection (which tears down each +// browser server-side). func (m *Manager) Stop(ctx context.Context) error { // Grab and nil-out stopReaper under the lock, then close outside to avoid // deadlock (reaper goroutine also acquires mu). @@ -203,7 +331,20 @@ func (m *Manager) Stop(ctx context.Context) error { m.mu.Lock() defer m.mu.Unlock() - if m.browser == nil { + if !m.isRunningLocked() { + return nil + } + + // Lightpanda: close every per-tab connection. Lightpanda auto-cleans the + // browser on disconnect, so no page.Close() is required. + if m.backend == BackendLightpanda { + for tid, conn := range m.pageConns { + if err := conn.Close(); err != nil { + m.logger.Warn("lightpanda: failed to close page conn", "targetId", tid, "error", err) + } + } + m.cdpURL = "" + m.resetPageMapsLocked() return nil } @@ -223,10 +364,7 @@ func (m *Manager) Stop(ctx context.Context) error { // 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) - m.pageTenants = make(map[string]string) - m.pageLastUsed = make(map[string]time.Time) + m.resetPageMapsLocked() return err } @@ -249,11 +387,13 @@ func (m *Manager) cleanupDeadBrowserLocked() { m.launcher.Cleanup() m.launcher = nil } + // Lightpanda: also drop any dedicated page conns (will be torn down when + // the underlying WS dies anyway, but keep state consistent). + for _, conn := range m.pageConns { + _ = conn.Close() + } m.browser = nil - m.pages = make(map[string]*rod.Page) - m.console = make(map[string][]ConsoleMessage) - m.pageTenants = make(map[string]string) - m.pageLastUsed = make(map[string]time.Time) + m.resetPageMapsLocked() m.refs = NewRefStore() } @@ -291,7 +431,7 @@ func (m *Manager) Status() *StatusInfo { defer m.mu.Unlock() info := &StatusInfo{ - Running: m.browser != nil, + Running: m.isRunningLocked(), Headless: m.headless, RemoteURL: m.remoteURL, ActionTimeoutMs: int(m.actionTimeout / time.Millisecond), @@ -301,7 +441,19 @@ func (m *Manager) Status() *StatusInfo { CookieSync: m.cookieProvider != nil, } - if m.browser == nil { + if !m.isRunningLocked() { + return info + } + + // Lightpanda: no upstream /json/list, report from the local map. + if m.backend == BackendLightpanda { + info.Tabs = len(m.pages) + for _, p := range m.pages { + if pi, err := p.Info(); err == nil && pi != nil { + info.URL = pi.URL + break + } + } return info } diff --git a/pkg/browser/browser_reaper.go b/pkg/browser/browser_reaper.go index 010fa21e..92a1a749 100644 --- a/pkg/browser/browser_reaper.go +++ b/pkg/browser/browser_reaper.go @@ -23,7 +23,7 @@ func (m *Manager) reapIdlePages() { m.mu.Lock() defer m.mu.Unlock() - if m.browser == nil { + if !m.isRunningLocked() { return } @@ -33,22 +33,13 @@ func (m *Manager) reapIdlePages() { continue } - page, ok := m.pages[targetID] - if !ok { + if _, ok := m.pages[targetID]; !ok { delete(m.pageLastUsed, targetID) continue } - if err := page.Close(); err != nil { - m.logger.Warn("reaper: failed to close idle page", "targetId", targetID, "error", err) - continue - } - - delete(m.pages, targetID) - delete(m.console, targetID) - delete(m.pageTenants, targetID) - delete(m.pageLastUsed, targetID) - m.refs.Remove(targetID) - m.logger.Info("reaper: closed idle page", "targetId", targetID, "idle", now.Sub(lastUsed).Round(time.Second)) + idleFor := now.Sub(lastUsed).Round(time.Second) + m.closeManagedPageLocked(targetID) + m.logger.Info("reaper: closed idle page", "targetId", targetID, "idle", idleFor) } } diff --git a/pkg/browser/browser_remote.go b/pkg/browser/browser_remote.go index c3b5628a..042008a7 100644 --- a/pkg/browser/browser_remote.go +++ b/pkg/browser/browser_remote.go @@ -16,13 +16,14 @@ import ( // reconnectLocked re-establishes the CDP connection to a remote Chrome. // Must be called with m.mu held. Only works when remoteURL is set. +// Not supported on Lightpanda (each page is its own connection). func (m *Manager) reconnectLocked() error { + if m.backend == BackendLightpanda { + return fmt.Errorf("reconnect not supported on lightpanda backend") + } m.closeTenantContextsLocked() m.browser = nil - m.pages = make(map[string]*rod.Page) - m.console = make(map[string][]ConsoleMessage) - m.pageTenants = make(map[string]string) - m.pageLastUsed = make(map[string]time.Time) + m.resetPageMapsLocked() m.refs = NewRefStore() controlURL, err := resolveRemoteCDP(m.remoteURL) @@ -38,14 +39,52 @@ func (m *Manager) reconnectLocked() error { return nil } +// mostRecentPageLocked returns the page most recently accessed (via pageLastUsed). +// Returns nil if no pages are open. Must be called with mu held. +func (m *Manager) mostRecentPageLocked() *rod.Page { + var newestID string + var newestTime time.Time + for tid, lu := range m.pageLastUsed { + if newestID == "" || lu.After(newestTime) { + newestID = tid + newestTime = lu + } + } + if newestID == "" { + // Fallback: any page in the map (no last-used recorded). + for _, p := range m.pages { + return p + } + return nil + } + return m.pages[newestID] +} + // getPage looks up a page by targetID. If targetID is empty, returns the first available page. // Must be called with m.mu held. If the connection is dead and remoteURL is set, -// it attempts one automatic reconnect. +// it attempts one automatic reconnect (chrome only — lightpanda pages can't be +// reconnected because each page IS a connection). func (m *Manager) getPage(targetID string) (*rod.Page, error) { - if m.browser == nil { + if !m.isRunningLocked() { return nil, fmt.Errorf("browser not running") } + // Lightpanda: local map is authoritative (no /json/list upstream). No + // auto-reconnect — a dead WS means the browser is gone server-side. + if m.backend == BackendLightpanda { + if targetID != "" { + if p, ok := m.pages[targetID]; ok { + return p, nil + } + return nil, fmt.Errorf("tab not found: %s", targetID) + } + // No targetID: most-recently-used page from the local map. + if p := m.mostRecentPageLocked(); p != nil { + return p, nil + } + return nil, fmt.Errorf("no tabs open") + } + // If targetID specified, look in cache first if targetID != "" { if p, ok := m.pages[targetID]; ok { diff --git a/pkg/browser/browser_tabs.go b/pkg/browser/browser_tabs.go index 3fc91e62..85da2ef0 100644 --- a/pkg/browser/browser_tabs.go +++ b/pkg/browser/browser_tabs.go @@ -5,6 +5,7 @@ import ( "fmt" "time" + "github.com/go-rod/rod" "github.com/go-rod/rod/lib/proto" ) @@ -13,12 +14,28 @@ func (m *Manager) ListTabs(ctx context.Context) ([]TabInfo, error) { m.mu.Lock() defer m.mu.Unlock() - if m.browser == nil { + if !m.isRunningLocked() { return nil, fmt.Errorf("browser not running") } tenantID := tenantIDFromCtx(ctx) + // Lightpanda: no server-side enumeration — return what we're tracking. + if m.backend == BackendLightpanda { + tabs := make([]TabInfo, 0, len(m.pages)) + for tid, p := range m.pages { + if !m.pageVisibleToTenantLocked(tid, tenantID) { + continue + } + info, err := p.Info() + if err != nil || info == nil { + continue + } + tabs = append(tabs, TabInfo{TargetID: tid, URL: info.URL, Title: info.Title}) + } + return tabs, nil + } + // Use tenant-scoped browser context for page listing b, err := m.tenantBrowserLocked(tenantID) if err != nil { @@ -66,9 +83,27 @@ func (m *Manager) ListTabs(ctx context.Context) ([]TabInfo, error) { return tabs, nil } +// pageVisibleToTenantLocked reports whether a page is accessible to the given tenant. +// Master tenant and empty context see everything; scoped tenants see only their own pages. +// Must be called with mu held. +func (m *Manager) pageVisibleToTenantLocked(targetID, tenantID string) bool { + if tenantID == "" || tenantID == MasterTenantID { + return true + } + owner, ok := m.pageTenants[targetID] + if !ok { + return false // scoped tenant can't see pages with no recorded owner + } + return owner == tenantID +} + // OpenTab opens a new tab with the given URL. // Pages are created within the tenant's incognito browser context for isolation. // If the tenant already has maxPages open, the oldest idle page is closed first. +// +// Lightpanda: each tab gets its own dedicated CDP connection (lightpanda +// requires 1 conn per tab, and each connection is its own browser, so tenant +// isolation is automatic). func (m *Manager) OpenTab(ctx context.Context, url string) (*TabInfo, error) { scope := scopeFromCtx(ctx) cookies, err := m.cookiesForURL(ctx, scope, url) @@ -86,6 +121,10 @@ func (m *Manager) OpenTab(ctx context.Context, url string) (*TabInfo, error) { m.evictOldestIfOverLimitLocked(tenantID) } + if m.backend == BackendLightpanda { + return m.openTabLightpandaLocked(ctx, tenantID, url) + } + b, err := m.tenantBrowserLocked(tenantID) if err != nil { return nil, err @@ -139,6 +178,60 @@ func (m *Manager) OpenTab(ctx context.Context, url string) (*TabInfo, error) { return tab, nil } +// openTabLightpandaLocked mints a fresh CDP connection, creates a browser +// context (required per-conn on lightpanda), creates a target, and attaches +// the resulting rod.Page. Must be called with mu held. +func (m *Manager) openTabLightpandaLocked(ctx context.Context, tenantID, targetURL string) (*TabInfo, error) { + conn := rod.New().Context(ctx).ControlURL(m.cdpURL) + if err := conn.Connect(); err != nil { + return nil, fmt.Errorf("lightpanda: connect: %w", err) + } + + // Lightpanda requires Target.createBrowserContext per connection. + bc, err := proto.TargetCreateBrowserContext{}.Call(conn) + if err != nil { + _ = conn.Close() + return nil, fmt.Errorf("lightpanda: create browser context: %w", err) + } + + tgt, err := proto.TargetCreateTarget{ + URL: targetURL, + BrowserContextID: bc.BrowserContextID, + }.Call(conn) + if err != nil { + _ = conn.Close() + return nil, fmt.Errorf("lightpanda: create target: %w", err) + } + + page, err := conn.PageFromTarget(tgt.TargetID) + if err != nil { + _ = conn.Close() + return nil, fmt.Errorf("lightpanda: page from target: %w", err) + } + + // Best-effort stability wait — lightpanda may not fire every lifecycle event. + stopWatchdog := watchPageClose(ctx, page) + _ = page.WaitStable(300 * time.Millisecond) + stopWatchdog() + + info, _ := page.Info() + tid := string(tgt.TargetID) + m.pages[tid] = page + m.pageConns[tid] = conn + m.touchPageLocked(tid) + if tenantID != "" { + m.pageTenants[tid] = tenantID + } + m.setupConsoleListener(page, tid) + + tab := &TabInfo{TargetID: tid, URL: targetURL} + if info != nil { + tab.URL = info.URL + tab.Title = info.Title + } + return tab, nil +} + // evictOldestIfOverLimitLocked closes the oldest idle page for a tenant if at or over maxPages. // Must be called with mu held. func (m *Manager) evictOldestIfOverLimitLocked(tenantID string) { @@ -182,15 +275,25 @@ func (m *Manager) evictOldestIfOverLimitLocked(tenantID string) { return } - if page, ok := m.pages[oldestID]; ok { + m.closeManagedPageLocked(oldestID) + m.logger.Info("evicted oldest page (max pages reached)", "targetId", oldestID, "tenant", tenantID) +} + +// closeManagedPageLocked closes a page and removes all associated tracking. +// On Lightpanda, closing the dedicated CDP connection tears down the browser +// server-side (no page.Close() needed). Must be called with mu held. +func (m *Manager) closeManagedPageLocked(targetID string) { + if conn, ok := m.pageConns[targetID]; ok { + _ = conn.Close() + delete(m.pageConns, targetID) + } else if page, ok := m.pages[targetID]; ok { _ = page.Close() } - delete(m.pages, oldestID) - delete(m.console, oldestID) - delete(m.pageTenants, oldestID) - delete(m.pageLastUsed, oldestID) - m.refs.Remove(oldestID) - m.logger.Info("evicted oldest page (max pages reached)", "targetId", oldestID, "tenant", tenantID) + delete(m.pages, targetID) + delete(m.console, targetID) + delete(m.pageTenants, targetID) + delete(m.pageLastUsed, targetID) + m.refs.Remove(targetID) } // FocusTab activates a tab. @@ -214,17 +317,19 @@ func (m *Manager) CloseTab(ctx context.Context, targetID string) error { m.mu.Lock() defer m.mu.Unlock() - page, err := m.getPageForTenant(targetID, tenantID) - if err != nil { - return err + // Tenant ownership check — same semantics as getPageForTenant but without + // the upstream refresh, so it works uniformly for both backends. + if tenantID != "" && tenantID != MasterTenantID { + if owner, ok := m.pageTenants[targetID]; ok && owner != tenantID { + return fmt.Errorf("tab not found: %s", targetID) + } + } + if _, ok := m.pages[targetID]; !ok { + return fmt.Errorf("tab not found: %s", targetID) } - delete(m.pages, targetID) - delete(m.console, targetID) - delete(m.pageTenants, targetID) - delete(m.pageLastUsed, targetID) - m.refs.Remove(targetID) - return page.Close() + m.closeManagedPageLocked(targetID) + return nil } // ConsoleMessages returns captured console messages for a tab. diff --git a/pkg/browser/tool.go b/pkg/browser/tool.go index 1a81023d..bf8e6745 100644 --- a/pkg/browser/tool.go +++ b/pkg/browser/tool.go @@ -275,6 +275,10 @@ func (t *BrowserTool) handleSnapshot(ctx context.Context, args map[string]any) * } func (t *BrowserTool) handleScreenshot(ctx context.Context, args map[string]any) *tools.Result { + if t.manager.Backend() == BackendLightpanda { + return tools.ErrorResult("screenshot is not supported on the lightpanda backend (returns a placeholder image); use the 'snapshot' action for an accessibility-tree view of the page") + } + targetID, _ := args["targetId"].(string) fullPage, _ := args["fullPage"].(bool) From 74c1d3a7be2d46345ff989bd313f683b17695bab Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Fri, 24 Apr 2026 10:00:15 +0200 Subject: [PATCH 3/8] feat(gateway): wire browser Backend option through config Pass cfg.Tools.Browser.Backend to browser.WithBackend when set, so the "chrome" or "lightpanda" selector from config / GOCLAW_BROWSER_BACKEND reaches the manager. Empty value still triggers /json/version auto-detection in browser.Start(). --- cmd/gateway_setup.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/gateway_setup.go b/cmd/gateway_setup.go index 3f6ac2a4..5a95e7d3 100644 --- a/cmd/gateway_setup.go +++ b/cmd/gateway_setup.go @@ -94,9 +94,12 @@ func setupToolRegistry( // Browser automation tool if cfg.Tools.Browser.Enabled { var opts []browser.Option + if cfg.Tools.Browser.Backend != "" { + opts = append(opts, browser.WithBackend(browser.Backend(cfg.Tools.Browser.Backend))) + } if cfg.Tools.Browser.RemoteURL != "" { opts = append(opts, browser.WithRemoteURL(cfg.Tools.Browser.RemoteURL)) - slog.Info("browser tool enabled", "remote", cfg.Tools.Browser.RemoteURL) + slog.Info("browser tool enabled", "remote", cfg.Tools.Browser.RemoteURL, "backend", cfg.Tools.Browser.Backend) } else { opts = append(opts, browser.WithHeadless(cfg.Tools.Browser.Headless)) slog.Info("browser tool enabled", "headless", cfg.Tools.Browser.Headless) From 2e35a3faace2c222733dedd53a6e2c7cd1401997 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Fri, 24 Apr 2026 10:07:42 +0200 Subject: [PATCH 4/8] test(browser): add Lightpanda integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build-tag gated (//go:build integration), skipped unless LIGHTPANDA_CDP_URL env is set. Covers: - Golden path: open → snapshot → list → close - Multi-tenant isolation: each tenant sees only its own tabs; cross-tenant CloseTab is rejected - Backend() reports lightpanda after Start - Screenshot tool action returns an IsError result mentioning lightpanda + snapshot --- tests/integration/browser_lightpanda_test.go | 156 +++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 tests/integration/browser_lightpanda_test.go diff --git a/tests/integration/browser_lightpanda_test.go b/tests/integration/browser_lightpanda_test.go new file mode 100644 index 00000000..691de8f6 --- /dev/null +++ b/tests/integration/browser_lightpanda_test.go @@ -0,0 +1,156 @@ +//go:build integration + +package integration + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "github.com/nextlevelbuilder/goclaw/pkg/browser" +) + +// These tests require a running Lightpanda CDP sidecar. Set +// LIGHTPANDA_CDP_URL=ws://localhost:9222 (or wherever it lives) before running: +// +// docker run -d --rm -p 9222:9222 lightpanda/browser:latest \ +// serve --host 0.0.0.0 --port 9222 +// LIGHTPANDA_CDP_URL=ws://localhost:9222 \ +// go test -tags integration -run Lightpanda ./tests/integration/ + +func newLightpandaManager(t *testing.T) *browser.Manager { + t.Helper() + url := mustEnv(t, "LIGHTPANDA_CDP_URL") + + m := browser.New( + browser.WithRemoteURL(url), + browser.WithBackend(browser.BackendLightpanda), + browser.WithActionTimeout(15*time.Second), + browser.WithIdleTimeout(0), // disable reaper for predictable tests + browser.WithMaxPages(10), + ) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := m.Start(ctx); err != nil { + t.Fatalf("manager start: %v", err) + } + t.Cleanup(func() { + _ = m.Stop(context.Background()) + }) + return m +} + +func mustEnv(t *testing.T, key string) string { + t.Helper() + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + t.Skipf("%s not set; skipping lightpanda integration test", key) + } + return v +} + +func TestLightpanda_SingleTenant_Golden(t *testing.T) { + m := newLightpandaManager(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + tab, err := m.OpenTab(ctx, "https://example.com") + if err != nil { + t.Fatalf("open tab: %v", err) + } + if tab.TargetID == "" { + t.Fatal("expected non-empty TargetID") + } + + snap, err := m.Snapshot(ctx, tab.TargetID, browser.DefaultSnapshotOptions()) + if err != nil { + t.Fatalf("snapshot: %v", err) + } + if snap.Snapshot == "" { + t.Fatal("expected non-empty AX snapshot") + } + + tabs, err := m.ListTabs(ctx) + if err != nil { + t.Fatalf("list tabs: %v", err) + } + if len(tabs) != 1 { + t.Fatalf("expected 1 tab, got %d", len(tabs)) + } + + if err := m.CloseTab(ctx, tab.TargetID); err != nil { + t.Fatalf("close tab: %v", err) + } + tabs, _ = m.ListTabs(ctx) + if len(tabs) != 0 { + t.Fatalf("expected 0 tabs after close, got %d", len(tabs)) + } +} + +func TestLightpanda_MultiTenant_Isolation(t *testing.T) { + m := newLightpandaManager(t) + + tenantA := "11111111-1111-1111-1111-111111111111" + tenantB := "22222222-2222-2222-2222-222222222222" + base := context.Background() + ctxA := browser.WithTenantID(base, tenantA) + ctxB := browser.WithTenantID(base, tenantB) + + tabA, err := m.OpenTab(ctxA, "https://example.com") + if err != nil { + t.Fatalf("tenant A open: %v", err) + } + tabB, err := m.OpenTab(ctxB, "https://example.com") + if err != nil { + t.Fatalf("tenant B open: %v", err) + } + + listA, _ := m.ListTabs(ctxA) + if len(listA) != 1 || listA[0].TargetID != tabA.TargetID { + t.Errorf("tenant A should see only its own tab; got %v", listA) + } + listB, _ := m.ListTabs(ctxB) + if len(listB) != 1 || listB[0].TargetID != tabB.TargetID { + t.Errorf("tenant B should see only its own tab; got %v", listB) + } + + // Tenant A must not be able to close tenant B's tab. + if err := m.CloseTab(ctxA, tabB.TargetID); err == nil { + t.Error("expected error when tenant A closes tenant B's tab; got nil") + } +} + +func TestLightpanda_Backend_ReportedCorrectly(t *testing.T) { + m := newLightpandaManager(t) + if got := m.Backend(); got != browser.BackendLightpanda { + t.Errorf("expected backend %q, got %q", browser.BackendLightpanda, got) + } +} + +func TestLightpanda_Screenshot_BlockedByToolGuard(t *testing.T) { + m := newLightpandaManager(t) + tool := browser.NewBrowserTool(m) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + tab, err := m.OpenTab(ctx, "https://example.com") + if err != nil { + t.Fatalf("open tab: %v", err) + } + + res := tool.Execute(ctx, map[string]any{ + "action": "screenshot", + "targetId": tab.TargetID, + }) + if res == nil || !res.IsError { + t.Fatal("expected screenshot to return an error result on lightpanda") + } + msg := strings.ToLower(res.ForLLM) + if !strings.Contains(msg, "lightpanda") || !strings.Contains(msg, "snapshot") { + t.Errorf("expected error to mention lightpanda + snapshot; got %q", res.ForLLM) + } +} From 890ced810d152ffdb1294a7fab9c72b4005bedfc Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Fri, 24 Apr 2026 10:10:12 +0200 Subject: [PATCH 5/8] docs(browser): add Lightpanda sidecar overlay + backends doc - docker-compose.lightpanda.yml: opt-in overlay running lightpanda/browser:latest, wires GOCLAW_BROWSER_REMOTE_URL and GOCLAW_BROWSER_BACKEND so the manager picks the right code path. - docs/browser-backends.md: compatibility matrix vs Chrome (screenshot, multi-tab, cookie sharing, etc.) and guidance on when to pick which. --- docker-compose.lightpanda.yml | 38 +++++++++++++++++++++++ docs/browser-backends.md | 57 +++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 docker-compose.lightpanda.yml create mode 100644 docs/browser-backends.md diff --git a/docker-compose.lightpanda.yml b/docker-compose.lightpanda.yml new file mode 100644 index 00000000..ccbb2147 --- /dev/null +++ b/docker-compose.lightpanda.yml @@ -0,0 +1,38 @@ +# Lightpanda sidecar overlay — lightweight CDP browser. +# +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.lightpanda.yml up -d --build +# +# Lightpanda (https://lightpanda.io) is a low-memory headless browser with CDP +# support. Opt-in alternative to the Chrome sidecar (docker-compose.browser.yml). +# See docs/browser-backends.md for the compatibility matrix. + +services: + lightpanda: + # Official Lightpanda CDP image: https://hub.docker.com/r/lightpanda/browser + image: lightpanda/browser:latest + ports: + - "127.0.0.1:${LIGHTPANDA_CDP_PORT:-9222}:9222" + healthcheck: + # Minimal TCP probe via sh's /dev/tcp. If the image is scratch-based + # without a shell, drop healthcheck and change depends_on below to + # `condition: service_started`. + test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9222 || exit 1"] + interval: 5s + timeout: 3s + retries: 5 + deploy: + resources: + limits: + # Lightpanda is ~10x lighter than Chrome. + memory: 512M + cpus: '1.0' + restart: unless-stopped + + goclaw: + environment: + - GOCLAW_BROWSER_REMOTE_URL=ws://lightpanda:9222 + - GOCLAW_BROWSER_BACKEND=lightpanda + depends_on: + lightpanda: + condition: service_healthy diff --git a/docs/browser-backends.md b/docs/browser-backends.md new file mode 100644 index 00000000..d8d3f2f6 --- /dev/null +++ b/docs/browser-backends.md @@ -0,0 +1,57 @@ +# Browser Backends + +Goclaw's browser automation tool (`pkg/browser/`) connects to any CDP-compatible browser. Two backends are supported: + +| Backend | Image | Overlay | Status | +|---|---|---|---| +| **Chrome** (default) | `chromedp/headless-shell:latest` | `docker-compose.browser.yml` | Stable | +| **Lightpanda** | `lightpanda/browser:latest` | `docker-compose.lightpanda.yml` | Experimental | + +## Switching backends + +Both overlays set `GOCLAW_BROWSER_REMOTE_URL` to their respective sidecar. Only one should be active at a time. + +```bash +# Chrome (default) +docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.browser.yml up -d + +# Lightpanda +docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.lightpanda.yml up -d +``` + +Set `GOCLAW_BROWSER_BACKEND=chrome|lightpanda` to pick the backend explicitly. If unset, goclaw probes `/json/version` on the remote and auto-detects from the `Browser` field. + +## Compatibility matrix + +| Feature | Chrome | Lightpanda | Notes | +|---|---|---|---| +| Navigate / reload | ✅ | ✅ | | +| AX snapshot (`Accessibility.getFullAXTree`) | ✅ | ✅ | Primary "see the page" path for the agent | +| Click / type / hover / press | ✅ | ✅ | | +| Wait (text / URL / stable) | ✅ | ✅ | | +| Evaluate JS | ✅ | ✅ | Subset on Lightpanda — see upstream docs | +| Screenshot (`Page.captureScreenshot`) | ✅ | ❌ | Lightpanda returns a placeholder image. The tool returns an error on Lightpanda directing the agent to use `snapshot` instead | +| Multiple tabs per connection | ✅ | ❌ | Lightpanda: 1 CDP connection = 1 tab. Goclaw opens a fresh connection per tab transparently | +| Browser contexts / incognito | ✅ | Implicit | On Lightpanda every connection is already a fresh browser — isolation is automatic, no `Target.createBrowserContext` multiplexing | +| Cookies / localStorage shared across tabs | ✅ within a context | ❌ | Lightpanda: each tab is a fresh browser. A login on one tab is not visible to another | +| List open tabs from server | ✅ | ❌ | Lightpanda: no `/json/list`. Goclaw tracks tabs in its local map | +| Auto-reconnect on WS drop | ✅ | ❌ | Lightpanda: connection death = that tab is gone server-side. Goclaw drops the tab from the map and surfaces a clear error | + +## When to choose which + +**Lightpanda:** +- Memory-constrained deployments (desktop / Lite edition, small VPS). +- Stateless workflows — navigate, snapshot, extract, done. +- Cases where per-tab isolation is a feature (every tab is a fresh browser). + +**Chrome:** +- Multi-tab flows (OAuth popup → main window, tab-to-tab navigation). +- Long-lived sessions with shared login / cookies / localStorage. +- Screenshot-based workflows. +- Anything requiring full JS engine fidelity. + +## References + +- Lightpanda: https://lightpanda.io +- Lightpanda + go-rod demos: https://github.com/lightpanda-io/demo/tree/main/rod +- Tracking issue: https://github.com/nextlevelbuilder/goclaw/issues/223 From 4147fee4e527f922b92eb2cee745f2aede970a0d Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Fri, 24 Apr 2026 11:16:20 +0200 Subject: [PATCH 6/8] fix(browser): make Lightpanda backend usable end-to-end Live testing surfaced three issues: - Lightpanda numbers targets per-browser, and each conn is its own browser, so every tab gets the same upstream targetID ("FID-0000000001"). Synthesize globally-unique "lp-N" keys for our internal map so multi-tenant tab tracking works. - page.Info() returns valid data once post-open then errors on subsequent calls, which made ListTabs silently drop tabs. Cache URL and Title at OpenTab time and read from the cache in ListTabs. - rod.Browser.Close() calls Browser.close which Lightpanda doesn't implement; the WS drops cleanly anyway. Swallow the error to quiet the noisy log line. Two Lightpanda upstream bugs are documented in docs/browser-backends.md and exercised by TestLightpanda_KnownUpstreamGaps: 1. Accessibility.getFullAXTree returns nodeId as a JSON number (CDP spec: string) 2. Runtime.evaluate rejects go-rod's function-apply wrapper --- docs/browser-backends.md | 15 +++++-- pkg/browser/browser.go | 14 ++++--- pkg/browser/browser_tabs.go | 29 +++++++++---- tests/integration/browser_lightpanda_test.go | 44 +++++++++++++++++--- 4 files changed, 81 insertions(+), 21 deletions(-) diff --git a/docs/browser-backends.md b/docs/browser-backends.md index d8d3f2f6..8df52ed0 100644 --- a/docs/browser-backends.md +++ b/docs/browser-backends.md @@ -26,17 +26,26 @@ Set `GOCLAW_BROWSER_BACKEND=chrome|lightpanda` to pick the backend explicitly. I | Feature | Chrome | Lightpanda | Notes | |---|---|---|---| | Navigate / reload | ✅ | ✅ | | -| AX snapshot (`Accessibility.getFullAXTree`) | ✅ | ✅ | Primary "see the page" path for the agent | +| AX snapshot (`Accessibility.getFullAXTree`) | ✅ | ⚠️ | Lightpanda upstream bug: returns `nodeId` as a JSON number, but CDP spec defines `AXNodeId` as a string. go-rod's typed decoder rejects it. Tracked as a known gap (see below) | | Click / type / hover / press | ✅ | ✅ | | | Wait (text / URL / stable) | ✅ | ✅ | | -| Evaluate JS | ✅ | ✅ | Subset on Lightpanda — see upstream docs | +| Evaluate JS | ✅ | ⚠️ | Lightpanda upstream bug: go-rod wraps eval JS in a function-apply call (`(function(){…}).apply(this, args)`); Lightpanda's runtime rejects it with "is not a function". Direct `Runtime.evaluate` works; go-rod's `Page.Eval` does not | | Screenshot (`Page.captureScreenshot`) | ✅ | ❌ | Lightpanda returns a placeholder image. The tool returns an error on Lightpanda directing the agent to use `snapshot` instead | | Multiple tabs per connection | ✅ | ❌ | Lightpanda: 1 CDP connection = 1 tab. Goclaw opens a fresh connection per tab transparently | | Browser contexts / incognito | ✅ | Implicit | On Lightpanda every connection is already a fresh browser — isolation is automatic, no `Target.createBrowserContext` multiplexing | | Cookies / localStorage shared across tabs | ✅ within a context | ❌ | Lightpanda: each tab is a fresh browser. A login on one tab is not visible to another | -| List open tabs from server | ✅ | ❌ | Lightpanda: no `/json/list`. Goclaw tracks tabs in its local map | +| List open tabs from server | ✅ | ❌ | Lightpanda: no `/json/list`. Goclaw tracks tabs in its local map (URL/title cached at OpenTab time, since `page.Info()` is also unreliable post-open) | | Auto-reconnect on WS drop | ✅ | ❌ | Lightpanda: connection death = that tab is gone server-side. Goclaw drops the tab from the map and surfaces a clear error | +## Known upstream Lightpanda gaps + +The two ⚠️ rows above are **Lightpanda-side bugs** that goclaw cannot work around without writing a custom CDP decoder. They prevent the agent's primary "see the page" workflow from functioning on Lightpanda today: + +1. **`Accessibility.getFullAXTree` non-conformance.** CDP defines `AXNodeId` as a string; Lightpanda emits it as a JSON number. The `Snapshot` tool action fails with `cannot unmarshal number into Go struct field AccessibilityAXNode.nodes.nodeId of type proto.AccessibilityAXNodeID`. +2. **`Runtime.evaluate` function-wrapper.** go-rod's `page.Eval()` issues `Runtime.callFunctionOn` with a wrapped function expression. Lightpanda's runtime cannot invoke it (`TypeError: ... is not a function`). The `Evaluate` tool action and any internal `WaitStable`/`WaitNavigation` paths that use eval are affected. + +Both gaps are exercised by `TestLightpanda_KnownUpstreamGaps` in `tests/integration/browser_lightpanda_test.go` — when Lightpanda fixes them, that test will start logging "remove this gap test" and we can drop the workarounds. + ## When to choose which **Lightpanda:** diff --git a/pkg/browser/browser.go b/pkg/browser/browser.go index 6d69682f..587adb02 100644 --- a/pkg/browser/browser.go +++ b/pkg/browser/browser.go @@ -39,6 +39,7 @@ type Manager struct { refs *RefStore pages map[string]*rod.Page // targetID → page pageConns map[string]*rod.Browser // lightpanda only: targetID → dedicated CDP conn + pageInfos map[string]TabInfo // lightpanda only: cached URL/Title (page.Info() is unreliable upstream) console map[string][]ConsoleMessage // targetID → console messages tenantCtxs map[string]*rod.Browser // chrome only: browser scope key → incognito browser context pageTenants map[string]string // targetID → browser scope key (for filtering) @@ -53,6 +54,7 @@ type Manager struct { cookieProvider CookieProvider stopReaper chan struct{} // signal to stop the reaper goroutine logger *slog.Logger + nextLpTabSeq uint64 // lightpanda only: monotonic counter for synthetic tab IDs } // Option configures a Manager. @@ -107,6 +109,7 @@ func New(opts ...Option) *Manager { refs: NewRefStore(), pages: make(map[string]*rod.Page), pageConns: make(map[string]*rod.Browser), + pageInfos: make(map[string]TabInfo), console: make(map[string][]ConsoleMessage), tenantCtxs: make(map[string]*rod.Browser), pageTenants: make(map[string]string), @@ -154,6 +157,7 @@ func (m *Manager) isRunningLocked() bool { func (m *Manager) resetPageMapsLocked() { m.pages = make(map[string]*rod.Page) m.pageConns = make(map[string]*rod.Browser) + m.pageInfos = make(map[string]TabInfo) m.console = make(map[string][]ConsoleMessage) m.pageTenants = make(map[string]string) m.pageLastUsed = make(map[string]time.Time) @@ -336,12 +340,12 @@ func (m *Manager) Stop(ctx context.Context) error { } // Lightpanda: close every per-tab connection. Lightpanda auto-cleans the - // browser on disconnect, so no page.Close() is required. + // browser on disconnect, so no page.Close() is required. rod.Browser.Close + // calls Browser.close which Lightpanda doesn't implement (UnknownMethod); + // the WS drops regardless, so swallow the error. if m.backend == BackendLightpanda { - for tid, conn := range m.pageConns { - if err := conn.Close(); err != nil { - m.logger.Warn("lightpanda: failed to close page conn", "targetId", tid, "error", err) - } + for _, conn := range m.pageConns { + _ = conn.Close() } m.cdpURL = "" m.resetPageMapsLocked() diff --git a/pkg/browser/browser_tabs.go b/pkg/browser/browser_tabs.go index 85da2ef0..baf08387 100644 --- a/pkg/browser/browser_tabs.go +++ b/pkg/browser/browser_tabs.go @@ -21,17 +21,19 @@ func (m *Manager) ListTabs(ctx context.Context) ([]TabInfo, error) { tenantID := tenantIDFromCtx(ctx) // Lightpanda: no server-side enumeration — return what we're tracking. + // Use the cache populated at OpenTab; page.Info() is unreliable upstream + // after the initial call. if m.backend == BackendLightpanda { tabs := make([]TabInfo, 0, len(m.pages)) - for tid, p := range m.pages { + for tid := range m.pages { if !m.pageVisibleToTenantLocked(tid, tenantID) { continue } - info, err := p.Info() - if err != nil || info == nil { + if cached, ok := m.pageInfos[tid]; ok { + tabs = append(tabs, cached) continue } - tabs = append(tabs, TabInfo{TargetID: tid, URL: info.URL, Title: info.Title}) + tabs = append(tabs, TabInfo{TargetID: tid}) } return tabs, nil } @@ -215,7 +217,12 @@ func (m *Manager) openTabLightpandaLocked(ctx context.Context, tenantID, targetU stopWatchdog() info, _ := page.Info() - tid := string(tgt.TargetID) + // Lightpanda numbers targets per-browser, and each conn is its own browser, + // so every conn's first target is "FID-0000000001". Synthesize a globally + // unique key for our maps; the upstream targetID is only needed inside this + // function (for createTarget / PageFromTarget). + m.nextLpTabSeq++ + tid := fmt.Sprintf("lp-%d", m.nextLpTabSeq) m.pages[tid] = page m.pageConns[tid] = conn m.touchPageLocked(tid) @@ -224,12 +231,16 @@ func (m *Manager) openTabLightpandaLocked(ctx context.Context, tenantID, targetU } m.setupConsoleListener(page, tid) - tab := &TabInfo{TargetID: tid, URL: targetURL} + tab := TabInfo{TargetID: tid, URL: targetURL} if info != nil { tab.URL = info.URL tab.Title = info.Title } - return tab, nil + // Cache for ListTabs — page.Info() is unreliable on Lightpanda after the + // initial post-open call. + m.pageInfos[tid] = tab + tabCopy := tab + return &tabCopy, nil } // evictOldestIfOverLimitLocked closes the oldest idle page for a tenant if at or over maxPages. @@ -284,12 +295,16 @@ func (m *Manager) evictOldestIfOverLimitLocked(tenantID string) { // server-side (no page.Close() needed). Must be called with mu held. func (m *Manager) closeManagedPageLocked(targetID string) { if conn, ok := m.pageConns[targetID]; ok { + // Lightpanda: rod.Browser.Close() calls Browser.close which Lightpanda + // rejects with UnknownMethod; the WS drops anyway and Lightpanda + // auto-cleans the browser. Swallow the error. _ = conn.Close() delete(m.pageConns, targetID) } else if page, ok := m.pages[targetID]; ok { _ = page.Close() } delete(m.pages, targetID) + delete(m.pageInfos, targetID) delete(m.console, targetID) delete(m.pageTenants, targetID) delete(m.pageLastUsed, targetID) diff --git a/tests/integration/browser_lightpanda_test.go b/tests/integration/browser_lightpanda_test.go index 691de8f6..f793a1f1 100644 --- a/tests/integration/browser_lightpanda_test.go +++ b/tests/integration/browser_lightpanda_test.go @@ -64,13 +64,11 @@ func TestLightpanda_SingleTenant_Golden(t *testing.T) { if tab.TargetID == "" { t.Fatal("expected non-empty TargetID") } - - snap, err := m.Snapshot(ctx, tab.TargetID, browser.DefaultSnapshotOptions()) - if err != nil { - t.Fatalf("snapshot: %v", err) + if tab.URL != "https://example.com" { + t.Errorf("expected URL https://example.com, got %q", tab.URL) } - if snap.Snapshot == "" { - t.Fatal("expected non-empty AX snapshot") + if tab.Title == "" { + t.Errorf("expected non-empty Title (page.Info() should populate from initial post-open call)") } tabs, err := m.ListTabs(ctx) @@ -80,6 +78,9 @@ func TestLightpanda_SingleTenant_Golden(t *testing.T) { if len(tabs) != 1 { t.Fatalf("expected 1 tab, got %d", len(tabs)) } + if tabs[0].Title != tab.Title { + t.Errorf("ListTabs should return cached title %q, got %q", tab.Title, tabs[0].Title) + } if err := m.CloseTab(ctx, tab.TargetID); err != nil { t.Fatalf("close tab: %v", err) @@ -90,6 +91,37 @@ func TestLightpanda_SingleTenant_Golden(t *testing.T) { } } +// TestLightpanda_KnownUpstreamGaps documents Lightpanda CDP-conformance bugs +// that goclaw cannot work around without a custom decoder. Filed for awareness; +// these need fixing in Lightpanda upstream. +func TestLightpanda_KnownUpstreamGaps(t *testing.T) { + m := newLightpandaManager(t) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + tab, err := m.OpenTab(ctx, "https://example.com") + if err != nil { + t.Fatalf("open tab: %v", err) + } + + // Bug 1: Accessibility.getFullAXTree returns nodeId as JSON number. + // CDP spec says AXNodeId is a string. go-rod's typed proto fails to decode. + if _, err := m.Snapshot(ctx, tab.TargetID, browser.DefaultSnapshotOptions()); err == nil { + t.Log("Lightpanda has fixed AX tree decoding — remove this gap test") + } else if !strings.Contains(err.Error(), "AccessibilityAXNodeID") { + t.Logf("AX tree failed differently than expected (Lightpanda may have updated): %v", err) + } + + // Bug 2: Runtime.evaluate via go-rod's function-wrapper fails. + // go-rod wraps JS as a callable; Lightpanda's runtime rejects it. + if _, err := m.Evaluate(ctx, tab.TargetID, "document.title"); err == nil { + t.Log("Lightpanda Evaluate works — remove this gap test") + } else if !strings.Contains(err.Error(), "is not a function") { + t.Logf("Evaluate failed differently than expected: %v", err) + } +} + func TestLightpanda_MultiTenant_Isolation(t *testing.T) { m := newLightpandaManager(t) From 62e8359a21392eed9acaae8727599c85b4763923 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Fri, 24 Apr 2026 12:11:54 +0200 Subject: [PATCH 7/8] fix(browser): correct Lightpanda Eval claims + add missing compose command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Eval works on Lightpanda when called with go-rod's expected function form (e.g. "() => document.title"); only bare expressions fail, and they fail on Chrome too. Fix the integration gap test and docs that incorrectly attributed this to a Lightpanda bug. - docker-compose.lightpanda.yml: add the missing command: lightpanda serve ... — the image's entrypoint isn't lightpanda, so without an explicit command the sidecar wouldn't start. --- docker-compose.lightpanda.yml | 3 +++ docs/browser-backends.md | 11 ++++---- tests/integration/browser_lightpanda_test.go | 28 +++++++++++++------- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/docker-compose.lightpanda.yml b/docker-compose.lightpanda.yml index ccbb2147..f494bff8 100644 --- a/docker-compose.lightpanda.yml +++ b/docker-compose.lightpanda.yml @@ -11,6 +11,9 @@ services: lightpanda: # Official Lightpanda CDP image: https://hub.docker.com/r/lightpanda/browser image: lightpanda/browser:latest + # Invoke the lightpanda binary explicitly — the image's default entrypoint + # isn't `lightpanda`. Verify against image docs if upgrading. + command: ["lightpanda", "serve", "--host", "0.0.0.0", "--port", "9222"] ports: - "127.0.0.1:${LIGHTPANDA_CDP_PORT:-9222}:9222" healthcheck: diff --git a/docs/browser-backends.md b/docs/browser-backends.md index 8df52ed0..bf403234 100644 --- a/docs/browser-backends.md +++ b/docs/browser-backends.md @@ -29,7 +29,7 @@ Set `GOCLAW_BROWSER_BACKEND=chrome|lightpanda` to pick the backend explicitly. I | AX snapshot (`Accessibility.getFullAXTree`) | ✅ | ⚠️ | Lightpanda upstream bug: returns `nodeId` as a JSON number, but CDP spec defines `AXNodeId` as a string. go-rod's typed decoder rejects it. Tracked as a known gap (see below) | | Click / type / hover / press | ✅ | ✅ | | | Wait (text / URL / stable) | ✅ | ✅ | | -| Evaluate JS | ✅ | ⚠️ | Lightpanda upstream bug: go-rod wraps eval JS in a function-apply call (`(function(){…}).apply(this, args)`); Lightpanda's runtime rejects it with "is not a function". Direct `Runtime.evaluate` works; go-rod's `Page.Eval` does not | +| Evaluate JS | ✅ | ✅ | go-rod's `Page.Eval` requires a function form (`() => document.title`), not a bare expression — same on both backends | | Screenshot (`Page.captureScreenshot`) | ✅ | ❌ | Lightpanda returns a placeholder image. The tool returns an error on Lightpanda directing the agent to use `snapshot` instead | | Multiple tabs per connection | ✅ | ❌ | Lightpanda: 1 CDP connection = 1 tab. Goclaw opens a fresh connection per tab transparently | | Browser contexts / incognito | ✅ | Implicit | On Lightpanda every connection is already a fresh browser — isolation is automatic, no `Target.createBrowserContext` multiplexing | @@ -37,14 +37,13 @@ Set `GOCLAW_BROWSER_BACKEND=chrome|lightpanda` to pick the backend explicitly. I | List open tabs from server | ✅ | ❌ | Lightpanda: no `/json/list`. Goclaw tracks tabs in its local map (URL/title cached at OpenTab time, since `page.Info()` is also unreliable post-open) | | Auto-reconnect on WS drop | ✅ | ❌ | Lightpanda: connection death = that tab is gone server-side. Goclaw drops the tab from the map and surfaces a clear error | -## Known upstream Lightpanda gaps +## Known upstream Lightpanda gap -The two ⚠️ rows above are **Lightpanda-side bugs** that goclaw cannot work around without writing a custom CDP decoder. They prevent the agent's primary "see the page" workflow from functioning on Lightpanda today: +**`Accessibility.getFullAXTree` non-conformance.** CDP defines `AXNodeId` as a string; Lightpanda emits it as a JSON number. The `Snapshot` tool action — the agent's primary "see the page" mechanism — fails with `cannot unmarshal number into Go struct field AccessibilityAXNode.nodes.nodeId of type proto.AccessibilityAXNodeID`. -1. **`Accessibility.getFullAXTree` non-conformance.** CDP defines `AXNodeId` as a string; Lightpanda emits it as a JSON number. The `Snapshot` tool action fails with `cannot unmarshal number into Go struct field AccessibilityAXNode.nodes.nodeId of type proto.AccessibilityAXNodeID`. -2. **`Runtime.evaluate` function-wrapper.** go-rod's `page.Eval()` issues `Runtime.callFunctionOn` with a wrapped function expression. Lightpanda's runtime cannot invoke it (`TypeError: ... is not a function`). The `Evaluate` tool action and any internal `WaitStable`/`WaitNavigation` paths that use eval are affected. +Upstream fix in flight: [lightpanda-io/browser#2232](https://github.com/lightpanda-io/browser/pull/2232). -Both gaps are exercised by `TestLightpanda_KnownUpstreamGaps` in `tests/integration/browser_lightpanda_test.go` — when Lightpanda fixes them, that test will start logging "remove this gap test" and we can drop the workarounds. +Exercised by `TestLightpanda_KnownUpstreamGaps` in `tests/integration/browser_lightpanda_test.go` — when Lightpanda merges the fix, that test will start logging "remove this gap test". ## When to choose which diff --git a/tests/integration/browser_lightpanda_test.go b/tests/integration/browser_lightpanda_test.go index f793a1f1..02f955b1 100644 --- a/tests/integration/browser_lightpanda_test.go +++ b/tests/integration/browser_lightpanda_test.go @@ -92,8 +92,12 @@ func TestLightpanda_SingleTenant_Golden(t *testing.T) { } // TestLightpanda_KnownUpstreamGaps documents Lightpanda CDP-conformance bugs -// that goclaw cannot work around without a custom decoder. Filed for awareness; -// these need fixing in Lightpanda upstream. +// that goclaw cannot work around without a custom decoder. When Lightpanda +// fixes the upstream issue, this test starts logging "remove this gap test" +// and the workaround can be retired. +// +// Tracked upstream: +// - lightpanda-io/browser#2232 — Accessibility.getFullAXTree nodeId encoding func TestLightpanda_KnownUpstreamGaps(t *testing.T) { m := newLightpandaManager(t) @@ -105,20 +109,24 @@ func TestLightpanda_KnownUpstreamGaps(t *testing.T) { t.Fatalf("open tab: %v", err) } - // Bug 1: Accessibility.getFullAXTree returns nodeId as JSON number. - // CDP spec says AXNodeId is a string. go-rod's typed proto fails to decode. + // Accessibility.getFullAXTree returns nodeId as a JSON number, but the + // CDP spec defines AXNodeId as a string. go-rod's typed proto decoder + // rejects it. Fix in flight: lightpanda-io/browser#2232. if _, err := m.Snapshot(ctx, tab.TargetID, browser.DefaultSnapshotOptions()); err == nil { t.Log("Lightpanda has fixed AX tree decoding — remove this gap test") } else if !strings.Contains(err.Error(), "AccessibilityAXNodeID") { t.Logf("AX tree failed differently than expected (Lightpanda may have updated): %v", err) } - // Bug 2: Runtime.evaluate via go-rod's function-wrapper fails. - // go-rod wraps JS as a callable; Lightpanda's runtime rejects it. - if _, err := m.Evaluate(ctx, tab.TargetID, "document.title"); err == nil { - t.Log("Lightpanda Evaluate works — remove this gap test") - } else if !strings.Contains(err.Error(), "is not a function") { - t.Logf("Evaluate failed differently than expected: %v", err) + // Sanity check: Runtime.evaluate works on Lightpanda when called with a + // proper function form (which is the correct go-rod API usage). Bare + // expressions like `document.title` fail on Chrome too. + v, err := m.Evaluate(ctx, tab.TargetID, "() => document.title") + if err != nil { + t.Errorf("Evaluate(function form) should work on Lightpanda: %v", err) + } + if v == "" { + t.Errorf("Evaluate(() => document.title) returned empty value") } } From bb905ad978371f9404469d0c1b1a934faa637a32 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Sat, 25 Apr 2026 17:46:55 +0200 Subject: [PATCH 8/8] test(browser): convert Lightpanda gap test to positive Snapshot+Eval coverage Lightpanda upstream merged the AX-tree nodeId fix in lightpanda-io/browser#2232. The TestLightpanda_KnownUpstreamGaps canary fired on the latest image, so: - Rename to TestLightpanda_Snapshot_AndEval and assert the snapshot returns refs + non-empty text (instead of asserting it fails). - Flip AX-snapshot in the compatibility matrix from gap to fully supported. Replace the "Known upstream gap" section with a "Minimum Lightpanda version" note pointing at the upstream PR. --- docs/browser-backends.md | 10 ++---- tests/integration/browser_lightpanda_test.go | 34 +++++++++----------- 2 files changed, 18 insertions(+), 26 deletions(-) diff --git a/docs/browser-backends.md b/docs/browser-backends.md index bf403234..e63d2d9f 100644 --- a/docs/browser-backends.md +++ b/docs/browser-backends.md @@ -26,7 +26,7 @@ Set `GOCLAW_BROWSER_BACKEND=chrome|lightpanda` to pick the backend explicitly. I | Feature | Chrome | Lightpanda | Notes | |---|---|---|---| | Navigate / reload | ✅ | ✅ | | -| AX snapshot (`Accessibility.getFullAXTree`) | ✅ | ⚠️ | Lightpanda upstream bug: returns `nodeId` as a JSON number, but CDP spec defines `AXNodeId` as a string. go-rod's typed decoder rejects it. Tracked as a known gap (see below) | +| AX snapshot (`Accessibility.getFullAXTree`) | ✅ | ✅ | Primary "see the page" path for the agent. Required Lightpanda fix [lightpanda-io/browser#2232](https://github.com/lightpanda-io/browser/pull/2232) (merged 2026-04) | | Click / type / hover / press | ✅ | ✅ | | | Wait (text / URL / stable) | ✅ | ✅ | | | Evaluate JS | ✅ | ✅ | go-rod's `Page.Eval` requires a function form (`() => document.title`), not a bare expression — same on both backends | @@ -37,13 +37,9 @@ Set `GOCLAW_BROWSER_BACKEND=chrome|lightpanda` to pick the backend explicitly. I | List open tabs from server | ✅ | ❌ | Lightpanda: no `/json/list`. Goclaw tracks tabs in its local map (URL/title cached at OpenTab time, since `page.Info()` is also unreliable post-open) | | Auto-reconnect on WS drop | ✅ | ❌ | Lightpanda: connection death = that tab is gone server-side. Goclaw drops the tab from the map and surfaces a clear error | -## Known upstream Lightpanda gap +## Minimum Lightpanda version -**`Accessibility.getFullAXTree` non-conformance.** CDP defines `AXNodeId` as a string; Lightpanda emits it as a JSON number. The `Snapshot` tool action — the agent's primary "see the page" mechanism — fails with `cannot unmarshal number into Go struct field AccessibilityAXNode.nodes.nodeId of type proto.AccessibilityAXNodeID`. - -Upstream fix in flight: [lightpanda-io/browser#2232](https://github.com/lightpanda-io/browser/pull/2232). - -Exercised by `TestLightpanda_KnownUpstreamGaps` in `tests/integration/browser_lightpanda_test.go` — when Lightpanda merges the fix, that test will start logging "remove this gap test". +The AX-tree (`Accessibility.getFullAXTree`) snapshot path requires Lightpanda with [lightpanda-io/browser#2232](https://github.com/lightpanda-io/browser/pull/2232) merged. Earlier images return `nodeId` as a JSON number (CDP spec: string), causing the typed go-rod decoder to fail. Use `lightpanda/browser:latest` or any image built after that PR landed. ## When to choose which diff --git a/tests/integration/browser_lightpanda_test.go b/tests/integration/browser_lightpanda_test.go index 02f955b1..7eef2179 100644 --- a/tests/integration/browser_lightpanda_test.go +++ b/tests/integration/browser_lightpanda_test.go @@ -91,14 +91,11 @@ func TestLightpanda_SingleTenant_Golden(t *testing.T) { } } -// TestLightpanda_KnownUpstreamGaps documents Lightpanda CDP-conformance bugs -// that goclaw cannot work around without a custom decoder. When Lightpanda -// fixes the upstream issue, this test starts logging "remove this gap test" -// and the workaround can be retired. -// -// Tracked upstream: -// - lightpanda-io/browser#2232 — Accessibility.getFullAXTree nodeId encoding -func TestLightpanda_KnownUpstreamGaps(t *testing.T) { +// TestLightpanda_Snapshot_AndEval covers the agent's primary "see the page" +// workflow: AX-tree snapshot for structure + Eval (function form) for JS +// access. AX-tree decoding required Lightpanda upstream fix +// lightpanda-io/browser#2232. +func TestLightpanda_Snapshot_AndEval(t *testing.T) { m := newLightpandaManager(t) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) @@ -109,21 +106,20 @@ func TestLightpanda_KnownUpstreamGaps(t *testing.T) { t.Fatalf("open tab: %v", err) } - // Accessibility.getFullAXTree returns nodeId as a JSON number, but the - // CDP spec defines AXNodeId as a string. go-rod's typed proto decoder - // rejects it. Fix in flight: lightpanda-io/browser#2232. - if _, err := m.Snapshot(ctx, tab.TargetID, browser.DefaultSnapshotOptions()); err == nil { - t.Log("Lightpanda has fixed AX tree decoding — remove this gap test") - } else if !strings.Contains(err.Error(), "AccessibilityAXNodeID") { - t.Logf("AX tree failed differently than expected (Lightpanda may have updated): %v", err) + snap, err := m.Snapshot(ctx, tab.TargetID, browser.DefaultSnapshotOptions()) + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + if snap.Snapshot == "" { + t.Error("expected non-empty AX snapshot text") + } + if snap.Stats.Refs == 0 { + t.Error("expected at least one ref in snapshot") } - // Sanity check: Runtime.evaluate works on Lightpanda when called with a - // proper function form (which is the correct go-rod API usage). Bare - // expressions like `document.title` fail on Chrome too. v, err := m.Evaluate(ctx, tab.TargetID, "() => document.title") if err != nil { - t.Errorf("Evaluate(function form) should work on Lightpanda: %v", err) + t.Errorf("Evaluate(function form): %v", err) } if v == "" { t.Errorf("Evaluate(() => document.title) returned empty value")