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).
This commit is contained in:
Pierre Tachoire committed 2026-07-01 10:23:03 +02:00
1 parent f13bf04402
commit f4c7fe2c91
5 files changed
+346 -55

No files matched your search

+170 -18
View File
@@ -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
}
+5 -14
View File
@@ -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)
}
}
+45 -6
View File
@@ -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 {
+122 -17
View File
@@ -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.
+4
View File
@@ -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)