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)