mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 12:18:59 +00:00
Merge pull request #1028 from krichprollsch/lightpanda
Add Lightpanda as alternative to Chrome for the browser automation tool
This commit is contained in:
11 files changed
+665
-56
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+174
-18
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+137
-17
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user