diff --git a/cmd/gateway_setup.go b/cmd/gateway_setup.go index cddb4481..94fb461d 100644 --- a/cmd/gateway_setup.go +++ b/cmd/gateway_setup.go @@ -96,9 +96,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) diff --git a/docker-compose.lightpanda.yml b/docker-compose.lightpanda.yml new file mode 100644 index 00000000..f494bff8 --- /dev/null +++ b/docker-compose.lightpanda.yml @@ -0,0 +1,41 @@ +# 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 + # 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: + # 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..e63d2d9f --- /dev/null +++ b/docs/browser-backends.md @@ -0,0 +1,61 @@ +# 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. 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 | +| 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 (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 | + +## Minimum Lightpanda version + +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 + +**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 diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index c87ee3d4..15825e4e 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -537,6 +537,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 a6befd5c..e99a9db8 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -394,6 +394,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 } diff --git a/pkg/browser/browser.go b/pkg/browser/browser.go index 1dff9616..59d0ed1e 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,38 @@ 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 + 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 // 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) @@ -30,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. @@ -41,11 +66,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 +108,8 @@ func New(opts ...Option) *Manager { m := &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), @@ -103,6 +137,65 @@ 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.pageInfos = make(map[string]TabInfo) + 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 +203,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 +241,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 +289,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()) } @@ -193,7 +323,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). @@ -208,7 +340,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. 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 _, conn := range m.pageConns { + _ = conn.Close() + } + m.cdpURL = "" + m.resetPageMapsLocked() return nil } @@ -228,10 +373,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 } @@ -254,11 +396,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() } @@ -296,7 +440,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), @@ -306,7 +450,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..baf08387 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,30 @@ 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. + // 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 := range m.pages { + if !m.pageVisibleToTenantLocked(tid, tenantID) { + continue + } + if cached, ok := m.pageInfos[tid]; ok { + tabs = append(tabs, cached) + continue + } + tabs = append(tabs, TabInfo{TargetID: tid}) + } + return tabs, nil + } + // Use tenant-scoped browser context for page listing b, err := m.tenantBrowserLocked(tenantID) if err != nil { @@ -66,9 +85,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 +123,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 +180,69 @@ 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() + // 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) + 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 + } + // 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. // Must be called with mu held. func (m *Manager) evictOldestIfOverLimitLocked(tenantID string) { @@ -182,15 +286,29 @@ 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 { + // 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, 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.pageInfos, targetID) + delete(m.console, targetID) + delete(m.pageTenants, targetID) + delete(m.pageLastUsed, targetID) + m.refs.Remove(targetID) } // FocusTab activates a tab. @@ -214,17 +332,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) diff --git a/tests/integration/browser_lightpanda_test.go b/tests/integration/browser_lightpanda_test.go new file mode 100644 index 00000000..7eef2179 --- /dev/null +++ b/tests/integration/browser_lightpanda_test.go @@ -0,0 +1,192 @@ +//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") + } + if tab.URL != "https://example.com" { + t.Errorf("expected URL https://example.com, got %q", tab.URL) + } + if tab.Title == "" { + t.Errorf("expected non-empty Title (page.Info() should populate from initial post-open call)") + } + + 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 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) + } + tabs, _ = m.ListTabs(ctx) + if len(tabs) != 0 { + t.Fatalf("expected 0 tabs after close, got %d", len(tabs)) + } +} + +// 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) + defer cancel() + + tab, err := m.OpenTab(ctx, "https://example.com") + if err != nil { + t.Fatalf("open tab: %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") + } + + v, err := m.Evaluate(ctx, tab.TargetID, "() => document.title") + if err != nil { + t.Errorf("Evaluate(function form): %v", err) + } + if v == "" { + t.Errorf("Evaluate(() => document.title) returned empty value") + } +} + +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) + } +}