feat(tool): add browser automation via Chrome DevTools Protocol (CDP)

Implement a BrowserTool that provides browser automation capabilities
through direct CDP WebSocket connection to Chrome/Chromium. This
addresses Issue #293 (Browser Automation) on the project roadmap.

Key features:
- Go-native CDP client using gorilla/websocket (zero new dependencies)
- 12 actions: navigate, state, click, type, fill, select, screenshot,
  get_text, scroll, keys, evaluate, close
- [N]-indexed DOM state extraction (borrowed from opencli) for
  LLM-friendly structured output at zero vision token cost
- 13 stealth anti-detection patches (navigator.webdriver, chrome stub,
  plugin spoofing, CDP stack trace cleanup, etc.)
- Cross-platform Chrome auto-detection (macOS/Linux/Windows + CHROME_PATH)
- Screenshot delivery via MediaStore + channel system to users
- Browsing history tracking with compact summary in state output
- SSRF protection using net.ParseIP for full CIDR coverage (private,
  loopback, link-local, metadata endpoints)
- Concurrent-safe lazy CDP connection with sync.Mutex
- Optional via //go:build cdp tag — zero impact on default builds

Build:
- Default: go build — no browser code included (stub returns error)
- Full:    go build -tags cdp — complete browser support enabled

New files:
- pkg/tools/browser.go              — BrowserTool core (Tool interface)
- pkg/tools/browser_cdp.go          — CDP WebSocket client
- pkg/tools/browser_cdp_util.go     — Chrome path detection
- pkg/tools/browser_stealth.go      — Anti-detection JS generator
- pkg/tools/browser_stub.go         — Stub for non-CDP builds
- pkg/tools/browser_test.go         — Unit tests (8 tests)
- pkg/tools/browser_integration_test.go — Integration tests (9 tests)

Modified files:
- pkg/config/config.go   — BrowserToolConfig + IsToolEnabled("browser")
- pkg/agent/instance.go  — Browser tool registration

Ref: #293
This commit is contained in:
zhenghuizli 2026-04-07 20:50:37 +08:00
parent 7bf6cbe1fa
commit 9d2a5bcab2
9 changed files with 2268 additions and 0 deletions

View file

@ -100,6 +100,16 @@ func NewAgentInstance(
}
}
if cfg.Tools.IsToolEnabled("browser") {
browserTool, err := tools.NewBrowserTool(cfg.Tools.Browser)
if err != nil {
logger.WarnCF("agent", "Browser tool unavailable (optional)",
map[string]any{"error": err.Error()})
} else {
toolsRegistry.Register(browserTool)
}
}
if cfg.Tools.IsToolEnabled("edit_file") {
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths))
}

View file

@ -875,6 +875,7 @@ type ToolsConfig struct {
Subagent ToolConfig `json:"subagent" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"`
WebFetch ToolConfig `json:"web_fetch" yaml:"-" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"`
WriteFile ToolConfig `json:"write_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"`
Browser BrowserToolConfig `json:"browser" yaml:"browser,omitempty"`
}
// IsFilterSensitiveDataEnabled returns true if sensitive data filtering is enabled
@ -895,6 +896,16 @@ type SearchCacheConfig struct {
TTLSeconds int `json:"ttl_seconds" env:"PICOCLAW_SKILLS_SEARCH_CACHE_TTL_SECONDS"`
}
// BrowserToolConfig holds configuration for the browser automation tool.
// The browser tool provides CDP-based browser control and requires Chrome/Chromium.
type BrowserToolConfig struct {
ToolConfig `envPrefix:"PICOCLAW_TOOLS_BROWSER_"`
CDPEndpoint string `json:"cdp_endpoint" yaml:"cdp_endpoint,omitempty" env:"PICOCLAW_TOOLS_BROWSER_CDP_ENDPOINT"`
Timeout int `json:"timeout_seconds" yaml:"timeout_seconds,omitempty" env:"PICOCLAW_TOOLS_BROWSER_TIMEOUT"`
Stealth bool `json:"stealth" yaml:"stealth,omitempty" env:"PICOCLAW_TOOLS_BROWSER_STEALTH"`
AllowEval bool `json:"allow_evaluate" yaml:"allow_evaluate,omitempty" env:"PICOCLAW_TOOLS_BROWSER_ALLOW_EVALUATE"`
}
type SkillsRegistriesConfig struct {
ClawHub ClawHubRegistryConfig `json:"clawhub" yaml:"clawhub,omitempty"`
}
@ -1364,6 +1375,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool {
return t.WriteFile.Enabled
case "mcp":
return t.MCP.Enabled
case "browser":
return t.Browser.Enabled
default:
return true
}

889
pkg/tools/browser.go Normal file
View file

@ -0,0 +1,889 @@
//go:build cdp
package tools
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media"
)
// pageVisit records a single page navigation for browsing history.
type pageVisit struct {
URL string
Title string
Timestamp time.Time
}
const maxHistorySize = 50 // keep last 50 pages to bound memory
// BrowserTool provides browser automation capabilities via Chrome DevTools Protocol.
// It enables AI agents to navigate web pages, interact with elements, and extract data.
type BrowserTool struct {
cfg config.BrowserToolConfig
cdp *CDPClient
cdpMu sync.Mutex // guards lazy cdp connection
chromePath string
stealthJS string
mediaStore media.MediaStore
history []pageVisit // browsing history, most recent last
}
// NewBrowserTool creates a new BrowserTool. It verifies that Chrome is available
// and attempts to connect to the CDP endpoint. Returns an error if Chrome is
// not found or the connection fails.
func NewBrowserTool(cfg config.BrowserToolConfig) (*BrowserTool, error) {
chromePath, err := FindChromePath()
if err != nil {
return nil, fmt.Errorf(
"Chrome/Chromium required for browser tool but not found. "+
"Install Chrome and restart, or set CHROME_PATH env var. "+
"Error: %w", err)
}
logger.InfoCF("tool", "Chrome found for browser tool",
map[string]any{"path": chromePath})
var stealthJS string
if cfg.Stealth {
stealthJS = generateStealthJS()
}
return &BrowserTool{
cfg: cfg,
chromePath: chromePath,
stealthJS: stealthJS,
}, nil
}
// connectIfNeeded lazily connects to the CDP endpoint.
// It is safe for concurrent use.
func (t *BrowserTool) connectIfNeeded() error {
t.cdpMu.Lock()
defer t.cdpMu.Unlock()
if t.cdp != nil {
return nil
}
endpoint := t.cfg.CDPEndpoint
if endpoint == "" {
endpoint = "http://127.0.0.1:9222"
}
cdp, err := NewCDPClient(endpoint)
if err != nil {
return fmt.Errorf(
"failed to connect to Chrome CDP at %s. "+
"Make sure Chrome is running with: %s --remote-debugging-port=9222. "+
"Error: %w", endpoint, t.chromePath, err)
}
// Enable required CDP domains
if err := cdp.EnablePage(); err != nil {
cdp.Close()
return fmt.Errorf("failed to enable Page domain: %w", err)
}
if err := cdp.EnableRuntime(); err != nil {
cdp.Close()
return fmt.Errorf("failed to enable Runtime domain: %w", err)
}
if err := cdp.EnableDOM(); err != nil {
cdp.Close()
return fmt.Errorf("failed to enable DOM domain: %w", err)
}
// Inject stealth JS if configured
if t.stealthJS != "" {
if err := cdp.InjectScript(t.stealthJS); err != nil {
logger.WarnCF("tool", "Failed to inject stealth JS",
map[string]any{"error": err.Error()})
}
}
t.cdp = cdp
return nil
}
func (t *BrowserTool) Name() string { return "browser" }
func (t *BrowserTool) Description() string {
return `Browser automation via CDP. Actions: navigate, state, click, type, fill, select, screenshot, get_text, scroll, keys, evaluate, close.
Workflow: navigate url state (get [N] indices) click/type [N] state (verify). Always run state after navigate or click.`
}
func (t *BrowserTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"action": map[string]any{
"type": "string",
"description": "Browser action to perform",
"enum": []string{"navigate", "state", "click", "type", "fill", "select", "screenshot", "get_text", "scroll", "keys", "evaluate", "close"},
},
"url": map[string]any{
"type": "string",
"description": "URL for navigate action",
},
"index": map[string]any{
"type": "integer",
"description": "Element index [N] from state output for click/type/fill/select/get_text",
},
"text": map[string]any{
"type": "string",
"description": "Text for type/fill actions, option value for select, key name for keys",
},
"direction": map[string]any{
"type": "string",
"description": "Scroll direction: up or down",
"enum": []string{"up", "down"},
},
"code": map[string]any{
"type": "string",
"description": "JavaScript code for evaluate action (requires allow_evaluate=true in config)",
},
},
"required": []string{"action"},
}
}
func (t *BrowserTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
action, _ := args["action"].(string)
if action == "" {
return ErrorResult("action is required")
}
// Connect lazily on first use
if action != "close" {
if err := t.connectIfNeeded(); err != nil {
return ErrorResult(err.Error())
}
}
timeout := time.Duration(t.cfg.Timeout) * time.Second
if timeout <= 0 {
timeout = 30 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
switch action {
case "navigate":
return t.executeNavigate(ctx, args)
case "state":
return t.executeState(ctx)
case "click":
return t.executeClick(ctx, args)
case "type":
return t.executeType(ctx, args)
case "fill":
return t.executeFill(ctx, args)
case "select":
return t.executeSelect(ctx, args)
case "screenshot":
return t.executeScreenshot(ctx)
case "get_text":
return t.executeGetText(ctx, args)
case "scroll":
return t.executeScroll(ctx, args)
case "keys":
return t.executeKeys(ctx, args)
case "evaluate":
return t.executeEvaluate(ctx, args)
case "close":
return t.executeClose()
default:
return ErrorResult(fmt.Sprintf("unknown browser action: %s", action))
}
}
// --- Action implementations ---
func (t *BrowserTool) executeNavigate(ctx context.Context, args map[string]any) *ToolResult {
urlStr, _ := args["url"].(string)
if urlStr == "" {
return ErrorResult("url is required for navigate action")
}
// Validate URL
if err := validateBrowserURL(urlStr); err != nil {
return ErrorResult(err.Error())
}
if err := t.cdp.Navigate(ctx, urlStr); err != nil {
return ErrorResult(fmt.Sprintf("navigation failed: %v", err))
}
// Record URL in history (title will be updated when state is called)
t.recordVisit(urlStr, "")
return SilentResult(fmt.Sprintf("Navigated to %s. Run 'state' to inspect page elements.", urlStr))
}
func (t *BrowserTool) executeState(ctx context.Context) *ToolResult {
// JavaScript that extracts interactive elements with [N] indices
js := `(function() {
var selectors = 'a, button, input, select, textarea, [role="button"], [role="link"], [role="tab"], [onclick], [tabindex]:not([tabindex="-1"])';
var elements = document.querySelectorAll(selectors);
var result = [];
var idx = 0;
for (var i = 0; i < elements.length; i++) {
var el = elements[i];
var rect = el.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) continue;
if (getComputedStyle(el).visibility === 'hidden') continue;
if (getComputedStyle(el).display === 'none') continue;
var tag = el.tagName.toLowerCase();
var info = {
i: idx,
tag: tag,
text: (el.textContent || '').trim().slice(0, 80).replace(/\s+/g, ' ')
};
if (el.getAttribute('role')) info.role = el.getAttribute('role');
if (el.getAttribute('type')) info.type = el.getAttribute('type');
if (el.getAttribute('name')) info.name = el.getAttribute('name');
if (el.getAttribute('placeholder')) info.placeholder = el.getAttribute('placeholder');
if (el.value !== undefined && el.value !== '') info.value = String(el.value).slice(0, 80);
if (tag === 'a' && el.getAttribute('href')) info.href = el.getAttribute('href').slice(0, 120);
if (el.getAttribute('aria-label')) info.label = el.getAttribute('aria-label').slice(0, 80);
if (el.disabled) info.disabled = true;
// Store selector path for interaction
el.setAttribute('data-pcw-idx', String(idx));
result.push(info);
idx++;
}
return JSON.stringify({
title: document.title,
url: location.href,
elements: result,
count: idx
});
})();`
raw, err := t.cdp.Evaluate(ctx, js)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to extract page state: %v", err))
}
// Parse the JSON string returned by JS
var stateStr string
if err := json.Unmarshal(raw, &stateStr); err != nil {
return ErrorResult(fmt.Sprintf("failed to parse state JSON: %v", err))
}
var state struct {
Title string `json:"title"`
URL string `json:"url"`
Elements []map[string]any `json:"elements"`
Count int `json:"count"`
}
if err := json.Unmarshal([]byte(stateStr), &state); err != nil {
return ErrorResult(fmt.Sprintf("failed to parse state: %v", err))
}
// Update title in history if it changed (SPA navigations)
t.updateCurrentTitle(state.Title)
// Format output for LLM consumption
var sb strings.Builder
// Include browsing history summary for context
if summary := t.historySummary(); summary != "" {
sb.WriteString(summary)
sb.WriteByte('\n')
}
sb.WriteString(fmt.Sprintf("Current page: %s\nURL: %s\n\n", state.Title, state.URL))
sb.WriteString(fmt.Sprintf("Interactive elements (%d):\n", state.Count))
for _, el := range state.Elements {
idxVal, ok := el["i"].(float64)
if !ok {
continue
}
idx := int(idxVal)
tag, _ := el["tag"].(string)
text, _ := el["text"].(string)
sb.WriteString(fmt.Sprintf("[%d] %s", idx, tag))
if role, ok := el["role"].(string); ok {
sb.WriteString(fmt.Sprintf(" role=%q", role))
}
if typ, ok := el["type"].(string); ok {
sb.WriteString(fmt.Sprintf(" type=%q", typ))
}
if name, ok := el["name"].(string); ok {
sb.WriteString(fmt.Sprintf(" name=%q", name))
}
if placeholder, ok := el["placeholder"].(string); ok {
sb.WriteString(fmt.Sprintf(" placeholder=%q", placeholder))
}
if value, ok := el["value"].(string); ok {
sb.WriteString(fmt.Sprintf(" value=%q", value))
}
if href, ok := el["href"].(string); ok {
sb.WriteString(fmt.Sprintf(" href=%q", href))
}
if label, ok := el["label"].(string); ok {
sb.WriteString(fmt.Sprintf(" aria-label=%q", label))
}
if disabled, ok := el["disabled"].(bool); ok && disabled {
sb.WriteString(" [disabled]")
}
if text != "" {
sb.WriteString(fmt.Sprintf(" %q", text))
}
sb.WriteByte('\n')
}
return SilentResult(sb.String())
}
func (t *BrowserTool) executeClick(ctx context.Context, args map[string]any) *ToolResult {
index, ok := getIntArg(args, "index")
if !ok {
return ErrorResult("index is required for click action (use [N] from state output)")
}
// Click using the data-pcw-idx attribute we set during state
js := fmt.Sprintf(`(function() {
var el = document.querySelector('[data-pcw-idx="%d"]');
if (!el) return JSON.stringify({error: 'Element [%d] not found. Run state to refresh indices.'});
el.scrollIntoView({block: 'center', behavior: 'instant'});
el.click();
return JSON.stringify({ok: true, tag: el.tagName.toLowerCase(), text: (el.textContent || '').trim().slice(0, 40)});
})();`, index, index)
raw, err := t.cdp.Evaluate(ctx, js)
if err != nil {
return ErrorResult(fmt.Sprintf("click failed: %v", err))
}
var resultStr string
if err := json.Unmarshal(raw, &resultStr); err != nil {
return ErrorResult(fmt.Sprintf("failed to parse click result: %v", err))
}
var result struct {
OK bool `json:"ok"`
Error string `json:"error"`
Tag string `json:"tag"`
Text string `json:"text"`
}
if err := json.Unmarshal([]byte(resultStr), &result); err != nil {
return ErrorResult(fmt.Sprintf("failed to parse click result: %v", err))
}
if result.Error != "" {
return ErrorResult(result.Error)
}
return SilentResult(fmt.Sprintf("Clicked [%d] <%s> %q. Run 'state' to see updated page.", index, result.Tag, result.Text))
}
func (t *BrowserTool) executeType(ctx context.Context, args map[string]any) *ToolResult {
index, ok := getIntArg(args, "index")
if !ok {
return ErrorResult("index is required for type action")
}
text, _ := args["text"].(string)
if text == "" {
return ErrorResult("text is required for type action")
}
// Focus the element
focusJS := fmt.Sprintf(`(function() {
var el = document.querySelector('[data-pcw-idx="%d"]');
if (!el) return JSON.stringify({error: 'Element [%d] not found. Run state to refresh indices.'});
el.scrollIntoView({block: 'center', behavior: 'instant'});
el.focus();
return JSON.stringify({ok: true});
})();`, index, index)
raw, err := t.cdp.Evaluate(ctx, focusJS)
if err != nil {
return ErrorResult(fmt.Sprintf("focus failed: %v", err))
}
var focusStr string
if err := json.Unmarshal(raw, &focusStr); err != nil {
return ErrorResult(fmt.Sprintf("failed to parse focus result: %v", err))
}
if strings.Contains(focusStr, "error") {
var result struct{ Error string `json:"error"` }
json.Unmarshal([]byte(focusStr), &result)
if result.Error != "" {
return ErrorResult(result.Error)
}
}
// Type using CDP Input.insertText
if err := t.cdp.InsertText(text); err != nil {
return ErrorResult(fmt.Sprintf("type failed: %v", err))
}
return SilentResult(fmt.Sprintf("Typed %q into [%d].", text, index))
}
func (t *BrowserTool) executeFill(ctx context.Context, args map[string]any) *ToolResult {
index, ok := getIntArg(args, "index")
if !ok {
return ErrorResult("index is required for fill action")
}
text, _ := args["text"].(string)
if text == "" {
return ErrorResult("text is required for fill action")
}
// Focus, select all existing text, then type new text
clearJS := fmt.Sprintf(`(function() {
var el = document.querySelector('[data-pcw-idx="%d"]');
if (!el) return JSON.stringify({error: 'Element [%d] not found.'});
el.scrollIntoView({block: 'center', behavior: 'instant'});
el.focus();
el.value = '';
el.dispatchEvent(new Event('input', {bubbles: true}));
return JSON.stringify({ok: true});
})();`, index, index)
raw, err := t.cdp.Evaluate(ctx, clearJS)
if err != nil {
return ErrorResult(fmt.Sprintf("fill failed (clear step): %v", err))
}
var clearStr string
if err := json.Unmarshal(raw, &clearStr); err != nil {
return ErrorResult(fmt.Sprintf("failed to parse fill result: %v", err))
}
if strings.Contains(clearStr, "error") {
var result struct{ Error string `json:"error"` }
json.Unmarshal([]byte(clearStr), &result)
if result.Error != "" {
return ErrorResult(result.Error)
}
}
// Type new value
if err := t.cdp.InsertText(text); err != nil {
return ErrorResult(fmt.Sprintf("fill failed (type step): %v", err))
}
return SilentResult(fmt.Sprintf("Filled [%d] with %q.", index, text))
}
func (t *BrowserTool) executeSelect(ctx context.Context, args map[string]any) *ToolResult {
index, ok := getIntArg(args, "index")
if !ok {
return ErrorResult("index is required for select action")
}
text, _ := args["text"].(string)
if text == "" {
return ErrorResult("text (option value) is required for select action")
}
textJSON, _ := json.Marshal(text)
js := fmt.Sprintf(`(function() {
var el = document.querySelector('[data-pcw-idx="%d"]');
if (!el) return JSON.stringify({error: 'Element [%d] not found.'});
if (el.tagName.toLowerCase() !== 'select') return JSON.stringify({error: 'Element [%d] is not a <select>'});
var target = %s;
var opts = el.options;
for (var i = 0; i < opts.length; i++) {
if (opts[i].value === target || opts[i].text.trim() === target) {
el.value = opts[i].value;
el.dispatchEvent(new Event('change', {bubbles: true}));
return JSON.stringify({ok: true, selected: opts[i].text.trim()});
}
}
var available = [];
for (var j = 0; j < opts.length; j++) available.push(opts[j].text.trim());
return JSON.stringify({error: 'Option not found. Available: ' + available.join(', ')});
})();`, index, index, index, string(textJSON))
raw, err := t.cdp.Evaluate(ctx, js)
if err != nil {
return ErrorResult(fmt.Sprintf("select failed: %v", err))
}
var resultStr string
if err := json.Unmarshal(raw, &resultStr); err != nil {
return ErrorResult(fmt.Sprintf("failed to parse select result: %v", err))
}
var result struct {
OK bool `json:"ok"`
Error string `json:"error"`
Selected string `json:"selected"`
}
if err := json.Unmarshal([]byte(resultStr), &result); err != nil {
return ErrorResult(fmt.Sprintf("failed to parse select result: %v", err))
}
if result.Error != "" {
return ErrorResult(result.Error)
}
return SilentResult(fmt.Sprintf("Selected %q in [%d].", result.Selected, index))
}
func (t *BrowserTool) executeScreenshot(ctx context.Context) *ToolResult {
data, err := t.cdp.CaptureScreenshot("png", 0)
if err != nil {
return ErrorResult(fmt.Sprintf("screenshot failed: %v", err))
}
// Decode base64 PNG data
pngBytes, err := base64.StdEncoding.DecodeString(data)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to decode screenshot: %v", err))
}
// Write to temporary file
tmpDir := os.TempDir()
tmpFile := filepath.Join(tmpDir, fmt.Sprintf("screenshot-%d.png", time.Now().UnixNano()))
if err := os.WriteFile(tmpFile, pngBytes, 0600); err != nil {
return ErrorResult(fmt.Sprintf("failed to save screenshot: %v", err))
}
// Build scope from channel and chatID context
channel := ToolChannel(ctx)
chatID := ToolChatID(ctx)
scope := fmt.Sprintf("tool:browser:screenshot:%s:%s", channel, chatID)
// Store in media store if available; otherwise return inline info to LLM
if t.mediaStore != nil {
ref, err := t.mediaStore.Store(tmpFile, media.MediaMeta{
Filename: "screenshot.png",
ContentType: "image/png",
Source: "tool:browser.screenshot",
}, scope)
if err != nil {
logger.WarnCF("tool", "Failed to store screenshot",
map[string]any{"error": err.Error()})
// Fall through to inline path
} else {
os.Remove(tmpFile)
return &ToolResult{
ForLLM: "Screenshot captured and sent to user (PNG).",
ForUser: "Screenshot captured",
Media: []string{ref},
ResponseHandled: true,
}
}
}
// No MediaStore or store failed — save to disk and return path as artifact
// so LLM can reference it and user can access it via send_file if needed
return &ToolResult{
ForLLM: fmt.Sprintf("Screenshot saved to %s (%d KB). Use send_file to deliver to user if needed.", tmpFile, len(pngBytes)/1024),
ArtifactTags: []string{fmt.Sprintf("[file:%s]", tmpFile)},
}
}
// SetMediaStore implements the mediaStoreAware interface.
func (t *BrowserTool) SetMediaStore(store media.MediaStore) {
t.mediaStore = store
}
func (t *BrowserTool) executeGetText(ctx context.Context, args map[string]any) *ToolResult {
index, ok := getIntArg(args, "index")
if !ok {
return ErrorResult("index is required for get_text action")
}
js := fmt.Sprintf(`(function() {
var el = document.querySelector('[data-pcw-idx="%d"]');
if (!el) return JSON.stringify({error: 'Element [%d] not found.'});
var text = el.value !== undefined && el.value !== '' ? el.value : el.textContent;
return JSON.stringify({text: (text || '').trim()});
})();`, index, index)
raw, err := t.cdp.Evaluate(ctx, js)
if err != nil {
return ErrorResult(fmt.Sprintf("get_text failed: %v", err))
}
var resultStr string
if err := json.Unmarshal(raw, &resultStr); err != nil {
return ErrorResult(fmt.Sprintf("failed to parse get_text result: %v", err))
}
var result struct {
Text string `json:"text"`
Error string `json:"error"`
}
if err := json.Unmarshal([]byte(resultStr), &result); err != nil {
return ErrorResult(fmt.Sprintf("failed to parse get_text result: %v", err))
}
if result.Error != "" {
return ErrorResult(result.Error)
}
return SilentResult(fmt.Sprintf("[%d] text: %q", index, result.Text))
}
func (t *BrowserTool) executeScroll(ctx context.Context, args map[string]any) *ToolResult {
direction, _ := args["direction"].(string)
if direction == "" {
// Also accept from "text" parameter for convenience
direction, _ = args["text"].(string)
}
switch direction {
case "up":
// ok
case "down":
// ok
default:
return ErrorResult("direction must be 'up' or 'down'")
}
amount := 500 // pixels
if direction == "up" {
amount = -500
}
js := fmt.Sprintf(`window.scrollBy(0, %d); JSON.stringify({scrollY: window.scrollY});`, amount)
_, err := t.cdp.Evaluate(ctx, js)
if err != nil {
return ErrorResult(fmt.Sprintf("scroll failed: %v", err))
}
return SilentResult(fmt.Sprintf("Scrolled %s. Run 'state' to see updated elements.", direction))
}
func (t *BrowserTool) executeKeys(_ context.Context, args map[string]any) *ToolResult {
text, _ := args["text"].(string)
if text == "" {
return ErrorResult("text (key name) is required for keys action. Examples: Enter, Tab, Escape, ArrowDown")
}
// Map common key names
key := text
switch strings.ToLower(text) {
case "enter", "return":
key = "Enter"
case "tab":
key = "Tab"
case "escape", "esc":
key = "Escape"
case "backspace":
key = "Backspace"
case "delete":
key = "Delete"
case "arrowup", "up":
key = "ArrowUp"
case "arrowdown", "down":
key = "ArrowDown"
case "arrowleft", "left":
key = "ArrowLeft"
case "arrowright", "right":
key = "ArrowRight"
}
if err := t.cdp.DispatchKeyEvent("keyDown", key, 0); err != nil {
return ErrorResult(fmt.Sprintf("keyDown failed: %v", err))
}
if err := t.cdp.DispatchKeyEvent("keyUp", key, 0); err != nil {
return ErrorResult(fmt.Sprintf("keyUp failed: %v", err))
}
return SilentResult(fmt.Sprintf("Pressed key: %s", key))
}
func (t *BrowserTool) executeEvaluate(ctx context.Context, args map[string]any) *ToolResult {
if !t.cfg.AllowEval {
return ErrorResult("evaluate action is disabled. Set tools.browser.allow_evaluate=true in config to enable JavaScript execution.")
}
code, _ := args["code"].(string)
if code == "" {
return ErrorResult("code is required for evaluate action")
}
raw, err := t.cdp.Evaluate(ctx, code)
if err != nil {
errMsg := err.Error()
// Provide actionable guidance based on error type
var hint string
if strings.Contains(errMsg, "not a function") || strings.Contains(errMsg, "is not iterable") {
hint = " Hint: the variable type may differ from expected. Use typeof/Array.isArray to check, or try document.querySelectorAll instead."
} else if strings.Contains(errMsg, "already been declared") {
hint = " Hint: wrap your code in an IIFE: (function(){ ... })()"
} else if strings.Contains(errMsg, "Failed to fetch") || strings.Contains(errMsg, "NetworkError") {
hint = " Hint: cross-origin fetch may be blocked. Try extracting data from the DOM directly instead of calling APIs."
} else if strings.Contains(errMsg, "not defined") {
hint = " Hint: the variable/function does not exist on this page. Use 'state' to see available elements."
}
return ErrorResult(fmt.Sprintf("evaluate failed: %v.%s", err, hint))
}
if raw == nil {
return SilentResult("null")
}
result, _ := json.MarshalIndent(json.RawMessage(raw), "", " ")
return SilentResult(string(result))
}
func (t *BrowserTool) executeClose() *ToolResult {
if t.cdp != nil {
t.cdp.Close()
t.cdp = nil
}
t.history = nil
return SilentResult("Browser session closed.")
}
// --- Browsing history helpers ---
// recordVisit adds a page to the browsing history.
func (t *BrowserTool) recordVisit(pageURL, title string) {
// Avoid duplicate consecutive entries (e.g. navigate then state on same page)
if len(t.history) > 0 {
last := &t.history[len(t.history)-1]
if last.URL == pageURL {
if title != "" {
last.Title = title
}
return
}
}
t.history = append(t.history, pageVisit{
URL: pageURL,
Title: title,
Timestamp: time.Now(),
})
// Trim to max size
if len(t.history) > maxHistorySize {
t.history = t.history[len(t.history)-maxHistorySize:]
}
}
// updateCurrentTitle updates the title of the most recent history entry.
// Useful when a SPA changes title after initial navigation.
func (t *BrowserTool) updateCurrentTitle(title string) {
if len(t.history) > 0 && title != "" {
t.history[len(t.history)-1].Title = title
}
}
// historySummary returns a compact browsing history for LLM context.
// Only shown when there are 2+ pages visited (no point showing history for the first page).
func (t *BrowserTool) historySummary() string {
if len(t.history) < 2 {
return ""
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Browsing history (%d pages):\n", len(t.history)))
// Show all entries compactly: just index, title, URL
for i, v := range t.history {
marker := " "
if i == len(t.history)-1 {
marker = "> " // current page marker
}
title := v.Title
if title == "" {
title = "(untitled)"
}
sb.WriteString(fmt.Sprintf("%s%d. %s — %s\n", marker, i+1, title, v.URL))
}
return sb.String()
}
// --- Helpers ---
// validateBrowserURL checks that a URL is safe to navigate to.
// It blocks private networks, loopback, metadata endpoints, and non-HTTP schemes.
func validateBrowserURL(urlStr string) error {
if urlStr == "" {
return fmt.Errorf("empty URL")
}
parsed, err := url.Parse(urlStr)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return fmt.Errorf("only http/https URLs are allowed (got %s://)", parsed.Scheme)
}
if parsed.Host == "" {
return fmt.Errorf("missing host in URL")
}
hostname := parsed.Hostname()
// Block obvious private/local hosts (string check)
if hostname == "localhost" || hostname == "0.0.0.0" ||
hostname == "metadata.google.internal" {
return fmt.Errorf("navigation to %s is not allowed", hostname)
}
// Parse as IP to catch all representations (hex, octal, IPv4-mapped IPv6, etc.)
ip := net.ParseIP(hostname)
if ip != nil {
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() || ip.IsUnspecified() {
return fmt.Errorf("navigation to private/local IP %s is not allowed", hostname)
}
// Block cloud metadata IPs
if ip.Equal(net.ParseIP("169.254.169.254")) {
return fmt.Errorf("navigation to cloud metadata endpoint is not allowed")
}
} else {
// Hostname (not literal IP) — resolve DNS with short timeout to check for private IPs
resolver := &net.Resolver{}
dnsCtx, dnsCancel := context.WithTimeout(context.Background(), 2*time.Second)
defer dnsCancel()
addrs, err := resolver.LookupHost(dnsCtx, hostname)
if err == nil {
for _, addr := range addrs {
resolved := net.ParseIP(addr)
if resolved == nil {
continue
}
if resolved.IsLoopback() || resolved.IsPrivate() || resolved.IsLinkLocalUnicast() ||
resolved.IsLinkLocalMulticast() || resolved.IsUnspecified() {
return fmt.Errorf("hostname %s resolves to private IP %s, navigation not allowed", hostname, addr)
}
if resolved.Equal(net.ParseIP("169.254.169.254")) {
return fmt.Errorf("hostname %s resolves to metadata IP, navigation not allowed", hostname)
}
}
}
}
return nil
}
// getIntArg extracts an integer from args, handling both float64 (JSON) and int types.
func getIntArg(args map[string]any, key string) (int, bool) {
v, ok := args[key]
if !ok {
return 0, false
}
switch n := v.(type) {
case float64:
return int(n), true
case int:
return n, true
case int64:
return int(n), true
}
return 0, false
}

530
pkg/tools/browser_cdp.go Normal file
View file

@ -0,0 +1,530 @@
//go:build cdp
package tools
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sync"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
"github.com/sipeed/picoclaw/pkg/logger"
)
const (
cdpSendTimeout = 30 * time.Second
cdpConnectTimeout = 10 * time.Second
)
// CDPTarget represents a Chrome debuggable target.
type CDPTarget struct {
Type string `json:"type"`
URL string `json:"url"`
Title string `json:"title"`
WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"`
}
// cdpMessage represents a CDP protocol message (request or response).
type cdpMessage struct {
ID int64 `json:"id,omitempty"`
Method string `json:"method,omitempty"`
Params json.RawMessage `json:"params,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *cdpErrorPayload `json:"error,omitempty"`
}
type cdpErrorPayload struct {
Code int `json:"code"`
Message string `json:"message"`
}
// cdpPending tracks an in-flight CDP request.
type cdpPending struct {
ch chan cdpResponse
timer *time.Timer
}
type cdpResponse struct {
Result json.RawMessage
Err error
}
// CDPClient is a lightweight Chrome DevTools Protocol client
// using a single WebSocket connection.
type CDPClient struct {
conn *websocket.Conn
mu sync.Mutex
idCount int64
pending map[int64]*cdpPending
events map[string][]cdpHandler
eventMu sync.RWMutex
handlerSeq int64 // monotonic handler ID
closed atomic.Bool
done chan struct{}
}
// cdpHandler associates a unique ID with an event callback for reliable removal.
type cdpHandler struct {
id int64
fn func(json.RawMessage)
}
// NewCDPClient connects to a Chrome CDP endpoint.
// The endpoint can be:
// - An HTTP URL like "http://127.0.0.1:9222" (will discover targets)
// - A WebSocket URL like "ws://127.0.0.1:9222/devtools/page/..."
func NewCDPClient(endpoint string) (*CDPClient, error) {
wsURL, err := resolveWSEndpoint(endpoint)
if err != nil {
return nil, fmt.Errorf("failed to resolve CDP endpoint: %w", err)
}
dialer := websocket.Dialer{
HandshakeTimeout: cdpConnectTimeout,
}
conn, _, err := dialer.Dial(wsURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to connect to CDP at %s: %w", wsURL, err)
}
c := &CDPClient{
conn: conn,
pending: make(map[int64]*cdpPending),
events: make(map[string][]cdpHandler),
done: make(chan struct{}),
}
go c.readLoop()
return c, nil
}
// Send sends a CDP command and waits for the response.
func (c *CDPClient) Send(method string, params map[string]any) (json.RawMessage, error) {
return c.SendWithTimeout(method, params, cdpSendTimeout)
}
// SendWithTimeout sends a CDP command with a custom timeout.
func (c *CDPClient) SendWithTimeout(method string, params map[string]any, timeout time.Duration) (json.RawMessage, error) {
if c.closed.Load() {
return nil, fmt.Errorf("CDP connection closed")
}
id := atomic.AddInt64(&c.idCount, 1)
// Build request message
msg := map[string]any{
"id": id,
"method": method,
}
if params != nil {
msg["params"] = params
}
data, err := json.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("failed to marshal CDP message: %w", err)
}
// Register pending request
p := &cdpPending{
ch: make(chan cdpResponse, 1),
timer: time.NewTimer(timeout),
}
c.mu.Lock()
c.pending[id] = p
c.mu.Unlock()
// Clean up on return
defer func() {
p.timer.Stop()
c.mu.Lock()
delete(c.pending, id)
c.mu.Unlock()
}()
// Send
c.mu.Lock()
err = c.conn.WriteMessage(websocket.TextMessage, data)
c.mu.Unlock()
if err != nil {
return nil, fmt.Errorf("failed to send CDP message: %w", err)
}
// Wait for response or timeout
select {
case resp := <-p.ch:
return resp.Result, resp.Err
case <-p.timer.C:
return nil, fmt.Errorf("CDP command %q timed out after %v", method, timeout)
case <-c.done:
return nil, fmt.Errorf("CDP connection closed while waiting for %q", method)
}
}
// On registers an event listener for a CDP event.
// Returns a handler ID that can be passed to Off for removal.
func (c *CDPClient) On(event string, handler func(json.RawMessage)) int64 {
c.eventMu.Lock()
defer c.eventMu.Unlock()
id := atomic.AddInt64(&c.handlerSeq, 1)
c.events[event] = append(c.events[event], cdpHandler{id: id, fn: handler})
return id
}
// Off removes an event handler by its ID.
func (c *CDPClient) Off(event string, handlerID int64) {
c.eventMu.Lock()
defer c.eventMu.Unlock()
handlers := c.events[event]
for i, h := range handlers {
if h.id == handlerID {
c.events[event] = append(handlers[:i], handlers[i+1:]...)
return
}
}
}
// WaitForEvent blocks until the named CDP event fires or timeout expires.
func (c *CDPClient) WaitForEvent(event string, timeout time.Duration) (json.RawMessage, error) {
ch := make(chan json.RawMessage, 1)
var once sync.Once
handler := func(params json.RawMessage) {
once.Do(func() {
ch <- params
})
}
handlerID := c.On(event, handler)
defer c.Off(event, handlerID)
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case params := <-ch:
return params, nil
case <-timer.C:
return nil, fmt.Errorf("timeout waiting for CDP event %q after %v", event, timeout)
case <-c.done:
return nil, fmt.Errorf("CDP connection closed while waiting for event %q", event)
}
}
// Close closes the WebSocket connection.
func (c *CDPClient) Close() error {
if c.closed.Swap(true) {
return nil // already closed
}
close(c.done)
// Cancel all pending requests (non-blocking to avoid deadlock)
c.mu.Lock()
for id, p := range c.pending {
select {
case p.ch <- cdpResponse{Err: fmt.Errorf("CDP connection closed")}:
default:
}
delete(c.pending, id)
}
c.mu.Unlock()
return c.conn.Close()
}
// readLoop continuously reads WebSocket messages and dispatches them.
func (c *CDPClient) readLoop() {
defer func() {
// Use Swap to prevent double-close of done channel
if !c.closed.Swap(true) {
close(c.done)
}
}()
for {
_, data, err := c.conn.ReadMessage()
if err != nil {
if !c.closed.Load() {
logger.DebugCF("tool", "CDP WebSocket read error",
map[string]any{"error": err.Error()})
}
return
}
var msg cdpMessage
if err := json.Unmarshal(data, &msg); err != nil {
logger.DebugCF("tool", "CDP failed to parse message",
map[string]any{"error": err.Error()})
continue
}
if msg.ID > 0 {
// Response to a pending request
c.mu.Lock()
p, ok := c.pending[msg.ID]
c.mu.Unlock()
if ok {
if msg.Error != nil {
p.ch <- cdpResponse{
Err: fmt.Errorf("CDP error %d: %s", msg.Error.Code, msg.Error.Message),
}
} else {
p.ch <- cdpResponse{Result: msg.Result}
}
}
} else if msg.Method != "" {
// Event notification
c.eventMu.RLock()
handlers := make([]cdpHandler, len(c.events[msg.Method]))
copy(handlers, c.events[msg.Method])
c.eventMu.RUnlock()
for _, h := range handlers {
h.fn(msg.Params)
}
}
}
}
// resolveWSEndpoint resolves a CDP endpoint to a WebSocket URL.
// If the input is already a ws:// URL, it's returned as-is.
// If it's an http:// URL, it fetches /json/version to discover the WS endpoint.
func resolveWSEndpoint(endpoint string) (string, error) {
parsed, err := url.Parse(endpoint)
if err != nil {
return "", err
}
// Already a WebSocket URL
if parsed.Scheme == "ws" || parsed.Scheme == "wss" {
return endpoint, nil
}
// HTTP endpoint — discover targets
if parsed.Scheme == "http" || parsed.Scheme == "https" {
return discoverWSEndpoint(endpoint)
}
// Try as plain host:port → http
if parsed.Scheme == "" {
return discoverWSEndpoint("http://" + endpoint)
}
return "", fmt.Errorf("unsupported CDP endpoint scheme: %s", parsed.Scheme)
}
// discoverWSEndpoint fetches page targets from a Chrome HTTP endpoint.
// It prefers page-level targets over the browser-level endpoint because
// Page.enable and other page commands only work on page targets.
func discoverWSEndpoint(httpEndpoint string) (string, error) {
client := &http.Client{Timeout: cdpConnectTimeout}
// Try /json first to find a page target (preferred — supports Page.enable)
resp, err := client.Get(httpEndpoint + "/json")
if err == nil {
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err == nil {
var targets []CDPTarget
if err := json.Unmarshal(body, &targets); err == nil {
// Prefer page targets
for _, t := range targets {
if t.Type == "page" && t.WebSocketDebuggerURL != "" {
return t.WebSocketDebuggerURL, nil
}
}
// Any target with a WS URL
for _, t := range targets {
if t.WebSocketDebuggerURL != "" {
return t.WebSocketDebuggerURL, nil
}
}
}
}
}
// Fallback: /json/version gives browser-level WS URL
// Note: browser-level targets don't support Page.enable, so this is
// only useful for browser-wide operations.
resp2, err := client.Get(httpEndpoint + "/json/version")
if err != nil {
return "", fmt.Errorf("failed to discover CDP targets: %w", err)
}
defer resp2.Body.Close()
body2, err := io.ReadAll(resp2.Body)
if err != nil {
return "", fmt.Errorf("failed to read CDP version response: %w", err)
}
var version struct {
WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"`
}
if err := json.Unmarshal(body2, &version); err == nil && version.WebSocketDebuggerURL != "" {
return version.WebSocketDebuggerURL, nil
}
return "", fmt.Errorf("no debuggable targets found at %s", httpEndpoint)
}
// EnablePage enables the Page domain on the CDP session.
func (c *CDPClient) EnablePage() error {
_, err := c.Send("Page.enable", nil)
return err
}
// EnableRuntime enables the Runtime domain on the CDP session.
func (c *CDPClient) EnableRuntime() error {
_, err := c.Send("Runtime.enable", nil)
return err
}
// EnableDOM enables the DOM domain on the CDP session.
func (c *CDPClient) EnableDOM() error {
_, err := c.Send("DOM.enable", nil)
return err
}
// Evaluate executes JavaScript in the page context and returns the result value.
func (c *CDPClient) Evaluate(ctx context.Context, expression string) (json.RawMessage, error) {
result, err := c.Send("Runtime.evaluate", map[string]any{
"expression": expression,
"returnByValue": true,
"awaitPromise": true,
})
if err != nil {
return nil, err
}
// Parse the evaluation result
var evalResult struct {
Result struct {
Value json.RawMessage `json:"value"`
} `json:"result"`
ExceptionDetails *struct {
Exception struct {
Description string `json:"description"`
} `json:"exception"`
} `json:"exceptionDetails"`
}
if err := json.Unmarshal(result, &evalResult); err != nil {
return nil, fmt.Errorf("failed to parse eval result: %w", err)
}
if evalResult.ExceptionDetails != nil {
return nil, fmt.Errorf("JS exception: %s", evalResult.ExceptionDetails.Exception.Description)
}
return evalResult.Result.Value, nil
}
// Navigate navigates the page to the given URL and waits for DOM ready.
// Uses Page.domContentEventFired (DOM parsed) instead of Page.loadEventFired
// (all resources loaded) for faster navigation — we don't need images/CSS to interact.
func (c *CDPClient) Navigate(ctx context.Context, targetURL string) error {
// Use a channel-based one-shot listener for DOM content loaded
domCh := make(chan struct{}, 1)
var domOnce sync.Once
handler := func(_ json.RawMessage) {
domOnce.Do(func() {
domCh <- struct{}{}
})
}
handlerID := c.On("Page.domContentEventFired", handler)
defer c.Off("Page.domContentEventFired", handlerID)
_, err := c.Send("Page.navigate", map[string]any{
"url": targetURL,
})
if err != nil {
return fmt.Errorf("navigation failed: %w", err)
}
// Wait for DOM content loaded with timeout
timer := time.NewTimer(30 * time.Second)
defer timer.Stop()
select {
case <-domCh:
return nil
case <-timer.C:
return fmt.Errorf("page load timed out after 30s")
case <-ctx.Done():
return ctx.Err()
}
}
// CaptureScreenshot takes a screenshot and returns base64-encoded PNG.
func (c *CDPClient) CaptureScreenshot(format string, quality int) (string, error) {
params := map[string]any{
"format": format,
}
if format == "jpeg" && quality > 0 {
params["quality"] = quality
}
result, err := c.Send("Page.captureScreenshot", params)
if err != nil {
return "", err
}
var screenshot struct {
Data string `json:"data"`
}
if err := json.Unmarshal(result, &screenshot); err != nil {
return "", fmt.Errorf("failed to parse screenshot result: %w", err)
}
return screenshot.Data, nil
}
// InjectScript injects JavaScript to be evaluated on every new document.
func (c *CDPClient) InjectScript(source string) error {
_, err := c.Send("Page.addScriptToEvaluateOnNewDocument", map[string]any{
"source": source,
})
return err
}
// DispatchMouseEvent sends a mouse event at the given coordinates.
func (c *CDPClient) DispatchMouseEvent(eventType string, x, y float64, button string, clickCount int) error {
_, err := c.Send("Input.dispatchMouseEvent", map[string]any{
"type": eventType,
"x": x,
"y": y,
"button": button,
"clickCount": clickCount,
})
return err
}
// InsertText inserts text at the current cursor position.
func (c *CDPClient) InsertText(text string) error {
_, err := c.Send("Input.insertText", map[string]any{
"text": text,
})
return err
}
// DispatchKeyEvent sends a keyboard event.
func (c *CDPClient) DispatchKeyEvent(eventType, key string, modifiers int) error {
params := map[string]any{
"type": eventType,
"key": key,
}
if modifiers > 0 {
params["modifiers"] = modifiers
}
_, err := c.Send("Input.dispatchKeyEvent", params)
return err
}

View file

@ -0,0 +1,98 @@
//go:build cdp
package tools
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
)
// FindChromePath attempts to locate a Chrome or Chromium executable on the system.
// It checks (in order): CHROME_PATH env var, platform-specific known paths,
// and finally exec.LookPath for common binary names.
func FindChromePath() (string, error) {
// 1. Check environment variable
if p := os.Getenv("CHROME_PATH"); p != "" {
if _, err := os.Stat(p); err == nil {
return p, nil
}
}
// 2. Platform-specific known paths
var candidates []string
switch runtime.GOOS {
case "darwin":
candidates = []string{
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
"/opt/homebrew/bin/chromium",
"/opt/homebrew/bin/chrome",
"/usr/local/bin/chromium",
"/usr/local/bin/chrome",
}
case "linux":
candidates = []string{
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/snap/bin/chromium",
}
if home := os.Getenv("HOME"); home != "" {
candidates = append(candidates, filepath.Join(home, ".local", "bin", "chromium"))
}
case "windows":
pf := os.Getenv("ProgramFiles")
pfx86 := os.Getenv("ProgramFiles(x86)")
localAppData := os.Getenv("LOCALAPPDATA")
candidates = []string{
filepath.Join(pf, "Google", "Chrome", "Application", "chrome.exe"),
filepath.Join(pfx86, "Google", "Chrome", "Application", "chrome.exe"),
filepath.Join(pf, "Chromium", "Application", "chrome.exe"),
filepath.Join(localAppData, "Google", "Chrome", "Application", "chrome.exe"),
filepath.Join(localAppData, "Chromium", "Application", "chrome.exe"),
}
}
// 3. Check candidate paths
for _, p := range candidates {
if p == "" {
continue
}
if _, err := os.Stat(p); err == nil {
return p, nil
}
}
// 4. Fallback to exec.LookPath
for _, name := range []string{"chromium", "chromium-browser", "google-chrome", "google-chrome-stable", "chrome"} {
if p, err := exec.LookPath(name); err == nil {
return p, nil
}
}
return "", fmt.Errorf(
"Chrome/Chromium not found on this system. " +
"Install from https://google.com/chrome or set CHROME_PATH environment variable")
}
// LaunchChromeArgs returns the command-line arguments to launch Chrome
// with remote debugging enabled.
func LaunchChromeArgs(debugPort int, headless bool) []string {
args := []string{
fmt.Sprintf("--remote-debugging-port=%d", debugPort),
"--no-first-run",
"--no-default-browser-check",
"--disable-background-networking",
"--disable-extensions",
}
if headless {
args = append(args, "--headless=new")
}
return args
}

View file

@ -0,0 +1,250 @@
//go:build cdp && integration
package tools
import (
"context"
"encoding/json"
"os"
"strings"
"testing"
"time"
"github.com/sipeed/picoclaw/pkg/config"
)
// Integration tests require Chrome running with --remote-debugging-port=9222.
// Run with: go test -tags 'goolm,stdjson,cdp,integration' -v -run TestIntegration ./pkg/tools/
func setupBrowserTool(t *testing.T) *BrowserTool {
t.Helper()
cfg := config.BrowserToolConfig{
CDPEndpoint: "http://127.0.0.1:9222",
Timeout: 30,
Stealth: true,
AllowEval: true,
}
cfg.Enabled = true
tool, err := NewBrowserTool(cfg)
if err != nil {
t.Skipf("Skipping: Chrome not available: %v", err)
}
return tool
}
func TestIntegration_NavigateAndState(t *testing.T) {
tool := setupBrowserTool(t)
ctx := context.Background()
// Navigate
result := tool.Execute(ctx, map[string]any{"action": "navigate", "url": "https://example.com"})
if result.IsError {
t.Fatalf("navigate failed: %s", result.ForLLM)
}
// State
result = tool.Execute(ctx, map[string]any{"action": "state"})
if result.IsError {
t.Fatalf("state failed: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "Example Domain") {
t.Errorf("state missing page title, got: %s", result.ForLLM[:200])
}
if !strings.Contains(result.ForLLM, "[0]") {
t.Error("state missing [0] element index")
}
t.Logf("State output:\n%s", result.ForLLM)
// Close
tool.Execute(ctx, map[string]any{"action": "close"})
}
func TestIntegration_GitHubLoginDetection(t *testing.T) {
tool := setupBrowserTool(t)
ctx := context.Background()
result := tool.Execute(ctx, map[string]any{"action": "navigate", "url": "https://github.com/login"})
if result.IsError {
t.Fatalf("navigate failed: %s", result.ForLLM)
}
result = tool.Execute(ctx, map[string]any{"action": "state"})
if result.IsError {
t.Fatalf("state failed: %s", result.ForLLM)
}
// Must detect login form elements
state := result.ForLLM
if !strings.Contains(state, "type=\"password\"") {
t.Error("login page missing password field")
}
if !strings.Contains(state, "type=\"submit\"") && !strings.Contains(state, "Sign in") {
t.Error("login page missing submit button")
}
t.Logf("GitHub login state:\n%s", state)
tool.Execute(ctx, map[string]any{"action": "close"})
}
func TestIntegration_Screenshot(t *testing.T) {
tool := setupBrowserTool(t)
ctx := context.Background()
tool.Execute(ctx, map[string]any{"action": "navigate", "url": "https://example.com"})
result := tool.Execute(ctx, map[string]any{"action": "screenshot"})
if result.IsError {
t.Fatalf("screenshot failed: %s", result.ForLLM)
}
t.Logf("Screenshot result: ForLLM=%s, ForUser=%s, Media=%v",
result.ForLLM, result.ForUser, result.Media)
// Verify temp file was created (even without MediaStore it should have existed briefly)
if !strings.Contains(result.ForLLM, "Screenshot captured") {
t.Error("unexpected screenshot result")
}
tool.Execute(ctx, map[string]any{"action": "close"})
}
func TestIntegration_ClickAndType(t *testing.T) {
tool := setupBrowserTool(t)
ctx := context.Background()
tool.Execute(ctx, map[string]any{"action": "navigate", "url": "https://github.com/login"})
// Get state to assign indices
tool.Execute(ctx, map[string]any{"action": "state"})
// Try typing into username field (should be index 1 based on previous tests)
result := tool.Execute(ctx, map[string]any{"action": "type", "index": float64(1), "text": "testuser"})
if result.IsError {
t.Logf("type failed (may be different index): %s", result.ForLLM)
} else {
t.Logf("type result: %s", result.ForLLM)
}
// Verify with get_text
result = tool.Execute(ctx, map[string]any{"action": "get_text", "index": float64(1)})
t.Logf("get_text result: %s", result.ForLLM)
tool.Execute(ctx, map[string]any{"action": "close"})
}
func TestIntegration_Evaluate(t *testing.T) {
tool := setupBrowserTool(t)
ctx := context.Background()
tool.Execute(ctx, map[string]any{"action": "navigate", "url": "https://example.com"})
result := tool.Execute(ctx, map[string]any{
"action": "evaluate",
"code": "JSON.stringify({title: document.title, url: location.href})",
})
if result.IsError {
t.Fatalf("evaluate failed: %s", result.ForLLM)
}
// evaluate returns JSON-encoded string, need to unwrap
var infoStr string
if err := json.Unmarshal([]byte(result.ForLLM), &infoStr); err != nil {
// ForLLM might already be the raw JSON string
infoStr = result.ForLLM
}
if !strings.Contains(infoStr, "Example Domain") {
t.Errorf("evaluate result missing 'Example Domain': %s", infoStr)
}
t.Logf("evaluate result: %s", result.ForLLM)
tool.Execute(ctx, map[string]any{"action": "close"})
}
func TestIntegration_Stealth(t *testing.T) {
tool := setupBrowserTool(t)
ctx := context.Background()
tool.Execute(ctx, map[string]any{"action": "navigate", "url": "https://example.com"})
result := tool.Execute(ctx, map[string]any{
"action": "evaluate",
"code": "String(navigator.webdriver)",
})
if result.IsError {
t.Fatalf("stealth check failed: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "false") {
t.Errorf("navigator.webdriver = %s, want false", result.ForLLM)
}
t.Logf("navigator.webdriver = %s", result.ForLLM)
tool.Execute(ctx, map[string]any{"action": "close"})
}
func TestIntegration_ScrollAndKeys(t *testing.T) {
tool := setupBrowserTool(t)
ctx := context.Background()
tool.Execute(ctx, map[string]any{"action": "navigate", "url": "https://news.ycombinator.com"})
time.Sleep(500 * time.Millisecond)
result := tool.Execute(ctx, map[string]any{"action": "scroll", "direction": "down"})
if result.IsError {
t.Errorf("scroll failed: %s", result.ForLLM)
}
result = tool.Execute(ctx, map[string]any{"action": "keys", "text": "Tab"})
if result.IsError {
t.Errorf("keys failed: %s", result.ForLLM)
}
tool.Execute(ctx, map[string]any{"action": "close"})
}
func TestIntegration_SSRFBlocking(t *testing.T) {
tool := setupBrowserTool(t)
ctx := context.Background()
blockedURLs := []string{
"http://localhost:8080",
"http://127.0.0.1",
"http://10.0.0.1",
"http://192.168.1.1",
"http://169.254.169.254",
"http://0.0.0.0",
"file:///etc/passwd",
}
for _, u := range blockedURLs {
result := tool.Execute(ctx, map[string]any{"action": "navigate", "url": u})
if !result.IsError {
t.Errorf("SSRF: %s should be blocked but wasn't", u)
}
}
tool.Execute(ctx, map[string]any{"action": "close"})
}
func TestIntegration_TempFileCleanup(t *testing.T) {
tool := setupBrowserTool(t)
ctx := context.Background()
tool.Execute(ctx, map[string]any{"action": "navigate", "url": "https://example.com"})
// Screenshot without MediaStore — temp file should be cleaned up
beforeFiles := countTempScreenshots()
tool.Execute(ctx, map[string]any{"action": "screenshot"})
time.Sleep(100 * time.Millisecond)
afterFiles := countTempScreenshots()
// Since no MediaStore is set, temp file should be deferred for removal
t.Logf("Temp screenshot files: before=%d, after=%d", beforeFiles, afterFiles)
tool.Execute(ctx, map[string]any{"action": "close"})
}
func countTempScreenshots() int {
entries, _ := os.ReadDir(os.TempDir())
count := 0
for _, e := range entries {
if strings.HasPrefix(e.Name(), "screenshot-") && strings.HasSuffix(e.Name(), ".png") {
count++
}
}
return count
}

View file

@ -0,0 +1,234 @@
//go:build cdp
package tools
// generateStealthJS returns a JavaScript snippet that patches common
// automation detection vectors. When injected via Page.addScriptToEvaluateOnNewDocument,
// it runs before any page script and makes CDP-controlled browsers harder to detect.
//
// Borrowed from opencli's stealth module with adaptations for Go embedding.
func generateStealthJS() string {
return `(function() {
// Guard: prevent double injection
var _gProto = EventTarget.prototype;
var _gKey = '__pcw_stealth';
if (_gProto[_gKey]) return 'skipped';
Object.defineProperty(_gProto, _gKey, { value: true, enumerable: false, configurable: true });
// --- Shared toString disguise infrastructure ---
var _origToString = Function.prototype.toString;
var _disguised = new WeakMap();
Object.defineProperty(Function.prototype, 'toString', {
value: function() {
var override = _disguised.get(this);
return override !== undefined ? override : _origToString.call(this);
},
writable: true,
configurable: true
});
function _disguise(fn, name) {
_disguised.set(fn, 'function ' + name + '() { [native code] }');
try { Object.defineProperty(fn, 'name', { value: name, configurable: true }); } catch(e) {}
return fn;
}
// 1. navigator.webdriver → false
Object.defineProperty(navigator, 'webdriver', {
get: function() { return false; },
configurable: true
});
// 2. window.chrome stub
if (!window.chrome) {
window.chrome = {
runtime: {
onConnect: { addListener: function(){}, removeListener: function(){} },
onMessage: { addListener: function(){}, removeListener: function(){} }
},
loadTimes: function() { return {}; },
csi: function() { return {}; }
};
}
// 3. navigator.plugins population
if (!navigator.plugins || navigator.plugins.length === 0) {
var fakePlugins = [
{ name: 'PDF Viewer', filename: 'internal-pdf-viewer', description: 'Portable Document Format' },
{ name: 'Chrome PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
{ name: 'Chromium PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
{ name: 'Microsoft Edge PDF Viewer', filename: 'internal-pdf-viewer', description: '' },
{ name: 'WebKit built-in PDF', filename: 'internal-pdf-viewer', description: '' }
];
fakePlugins.item = function(i) { return fakePlugins[i] || null; };
fakePlugins.namedItem = function(n) { return fakePlugins.find(function(p) { return p.name === n; }) || null; };
fakePlugins.refresh = function() {};
Object.defineProperty(navigator, 'plugins', {
get: function() { return fakePlugins; },
configurable: true
});
}
// 4. navigator.languages guarantee
if (!navigator.languages || navigator.languages.length === 0) {
Object.defineProperty(navigator, 'languages', {
get: function() { return ['en-US', 'en']; },
configurable: true
});
}
// 5. Permissions.query normalization
var origQuery = window.Permissions && window.Permissions.prototype && window.Permissions.prototype.query;
if (origQuery) {
window.Permissions.prototype.query = function(parameters) {
if (parameters && parameters.name === 'notifications') {
return Promise.resolve({ state: Notification.permission, onchange: null });
}
return origQuery.call(this, parameters);
};
}
// 6. Clean automation artifacts
try { delete window.__playwright; } catch(e) {}
try { delete window.__puppeteer; } catch(e) {}
var propNames = Object.getOwnPropertyNames(window);
for (var i = 0; i < propNames.length; i++) {
if (propNames[i].indexOf('cdc_') === 0 || propNames[i].indexOf('__cdc_') === 0) {
try { delete window[propNames[i]]; } catch(e) {}
}
}
// 7. CDP stack trace cleanup
var _origStackDesc = Object.getOwnPropertyDescriptor(Error.prototype, 'stack');
var _cdpPatterns = ['puppeteer_evaluation_script', 'pptr:', 'debugger://', '__playwright', '__puppeteer'];
if (_origStackDesc && _origStackDesc.get) {
Object.defineProperty(Error.prototype, 'stack', {
get: function() {
var raw = _origStackDesc.get.call(this);
if (typeof raw !== 'string') return raw;
return raw.split('\n').filter(function(line) {
for (var j = 0; j < _cdpPatterns.length; j++) {
if (line.indexOf(_cdpPatterns[j]) !== -1) return false;
}
return true;
}).join('\n');
},
configurable: true
});
}
// 8. Anti-debugger statement trap
var _OrigFunction = window.Function;
var _origEval = window.eval;
var _debuggerRe = /(?:^|(?<=[;{}\n\r]))\s*debugger\s*;?/g;
var _cleanDebugger = function(src) {
return typeof src === 'string' ? src.replace(_debuggerRe, '') : src;
};
var _PatchedFunction = function() {
var args = Array.prototype.slice.call(arguments);
if (args.length > 0) {
args[args.length - 1] = _cleanDebugger(args[args.length - 1]);
}
if (this instanceof _PatchedFunction) {
return new (Function.prototype.bind.apply(_OrigFunction, [null].concat(args)))();
}
return _OrigFunction.apply(this, args);
};
_PatchedFunction.prototype = _OrigFunction.prototype;
_disguise(_PatchedFunction, 'Function');
window.Function = _PatchedFunction;
var _patchedEval = function(code) {
return _origEval.call(this, _cleanDebugger(code));
};
_disguise(_patchedEval, 'eval');
window.eval = _patchedEval;
// 9. Console method fingerprinting defense
var _consoleMethods = ['log', 'warn', 'error', 'info', 'debug', 'table',
'trace', 'dir', 'group', 'groupEnd', 'groupCollapsed', 'clear', 'count',
'assert', 'profile', 'profileEnd', 'time', 'timeEnd', 'timeStamp'];
for (var ci = 0; ci < _consoleMethods.length; ci++) {
var _m = _consoleMethods[ci];
if (typeof console[_m] !== 'function') continue;
var _origMethod = console[_m];
var _wrapper = (function(orig) {
return function() { return orig.apply(console, arguments); };
})(_origMethod);
Object.defineProperty(_wrapper, 'length', { value: _origMethod.length || 0, configurable: true });
_disguise(_wrapper, _m);
console[_m] = _wrapper;
}
// 10. window.outerWidth/outerHeight defense
var _normalWidthDelta = window.outerWidth - window.innerWidth;
var _normalHeightDelta = window.outerHeight - window.innerHeight;
if (_normalWidthDelta > 100 || _normalHeightDelta > 200) {
Object.defineProperty(window, 'outerWidth', {
get: function() { return window.innerWidth; },
configurable: true
});
var _heightOffset = Math.max(40, Math.min(120, _normalHeightDelta));
Object.defineProperty(window, 'outerHeight', {
get: function() { return window.innerHeight + _heightOffset; },
configurable: true
});
}
// 11. Performance API cleanup
var _origGetEntries = Performance.prototype.getEntries;
var _origGetByType = Performance.prototype.getEntriesByType;
var _origGetByName = Performance.prototype.getEntriesByName;
var _suspiciousPatterns = ['debugger', 'devtools', '__puppeteer', '__playwright', 'pptr:'];
var _filterEntries = function(entries) {
if (!Array.isArray(entries)) return entries;
return entries.filter(function(e) {
var name = e.name || '';
for (var si = 0; si < _suspiciousPatterns.length; si++) {
if (name.indexOf(_suspiciousPatterns[si]) !== -1) return false;
}
return true;
});
};
Performance.prototype.getEntries = function() { return _filterEntries(_origGetEntries.call(this)); };
Performance.prototype.getEntriesByType = function(type) { return _filterEntries(_origGetByType.call(this, type)); };
Performance.prototype.getEntriesByName = function(name, type) { return _filterEntries(_origGetByName.call(this, name, type)); };
// 12. WebDriver document property defense
var docProps = Object.getOwnPropertyNames(document);
for (var di = 0; di < docProps.length; di++) {
if (docProps[di].indexOf('$cdc_') === 0 || docProps[di].indexOf('$chrome_') === 0) {
try { delete document[docProps[di]]; } catch(e) {}
}
}
// 13. Iframe contentWindow.chrome consistency
var _origHTMLIFrame = HTMLIFrameElement.prototype;
var _origContentWindow = Object.getOwnPropertyDescriptor(_origHTMLIFrame, 'contentWindow');
if (_origContentWindow && _origContentWindow.get) {
Object.defineProperty(_origHTMLIFrame, 'contentWindow', {
get: function() {
var _w = _origContentWindow.get.call(this);
if (_w) {
try {
if (!_w.chrome) {
Object.defineProperty(_w, 'chrome', {
value: window.chrome,
writable: true,
configurable: true
});
}
} catch(e) {}
}
return _w;
},
configurable: true
});
}
return 'ok';
})();`
}

42
pkg/tools/browser_stub.go Normal file
View file

@ -0,0 +1,42 @@
//go:build !cdp
package tools
import (
"context"
"fmt"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/media"
)
// BrowserTool stub for non-CDP builds.
type BrowserTool struct{}
// NewBrowserTool returns an error when compiled without the cdp build tag.
func NewBrowserTool(_ config.BrowserToolConfig) (*BrowserTool, error) {
return nil, fmt.Errorf(
"browser tool not compiled in; rebuild with: go build -tags cdp")
}
// Name implements Tool interface (stub).
func (t *BrowserTool) Name() string { return "browser" }
// Description implements Tool interface (stub).
func (t *BrowserTool) Description() string { return "Browser automation (not compiled)" }
// Parameters implements Tool interface (stub).
func (t *BrowserTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{},
}
}
// Execute implements Tool interface (stub).
func (t *BrowserTool) Execute(_ context.Context, _ map[string]any) *ToolResult {
return ErrorResult("browser tool not compiled in; rebuild with: go build -tags cdp")
}
// SetMediaStore implements mediaStoreAware interface (stub).
func (t *BrowserTool) SetMediaStore(_ media.MediaStore) {}

202
pkg/tools/browser_test.go Normal file
View file

@ -0,0 +1,202 @@
//go:build cdp
package tools
import (
"encoding/json"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestFindChromePath(t *testing.T) {
// This test verifies the Chrome detection logic.
// It may or may not find Chrome depending on the CI/dev environment.
path, err := FindChromePath()
if err != nil {
t.Logf("Chrome not found (expected in some environments): %v", err)
return
}
if path == "" {
t.Error("FindChromePath returned empty path without error")
}
t.Logf("Chrome found at: %s", path)
}
func TestFindChromePath_EnvOverride(t *testing.T) {
t.Setenv("CHROME_PATH", "/nonexistent/chrome")
_, err := FindChromePath()
// Should not use the invalid env path; should fall back or error
if err == nil {
// Chrome was found via some other path; that's fine
return
}
t.Logf("Expected error with invalid CHROME_PATH: %v", err)
}
func TestValidateBrowserURL(t *testing.T) {
tests := []struct {
url string
wantErr bool
desc string
}{
{"https://example.com", false, "valid HTTPS"},
{"http://example.com/path?q=test", false, "valid HTTP with path"},
{"file:///etc/passwd", true, "file protocol blocked"},
{"ftp://example.com", true, "ftp protocol blocked"},
{"javascript:alert(1)", true, "javascript protocol blocked"},
{"http://localhost:8080", true, "localhost blocked"},
{"http://127.0.0.1:9222", true, "loopback blocked"},
{"http://169.254.169.254/metadata", true, "metadata endpoint blocked"},
{"http://0.0.0.0", true, "0.0.0.0 blocked"},
{"http://10.0.0.1", true, "private 10.x blocked"},
{"http://192.168.1.1", true, "private 192.168.x blocked"},
{"http://172.16.0.1", true, "private 172.16.x blocked"},
{"http://[::1]", true, "IPv6 loopback blocked"},
{"not-a-url", true, "invalid URL"},
{"", true, "empty URL"},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
err := validateBrowserURL(tt.url)
if (err != nil) != tt.wantErr {
t.Errorf("validateBrowserURL(%q) error = %v, wantErr = %v", tt.url, err, tt.wantErr)
}
})
}
}
func TestGetIntArg(t *testing.T) {
tests := []struct {
args map[string]any
key string
want int
ok bool
}{
{map[string]any{"index": float64(5)}, "index", 5, true},
{map[string]any{"index": 3}, "index", 3, true},
{map[string]any{"index": int64(7)}, "index", 7, true},
{map[string]any{"other": float64(1)}, "index", 0, false},
{map[string]any{}, "index", 0, false},
{map[string]any{"index": "not a number"}, "index", 0, false},
}
for _, tt := range tests {
got, ok := getIntArg(tt.args, tt.key)
if got != tt.want || ok != tt.ok {
t.Errorf("getIntArg(%v, %q) = (%d, %v), want (%d, %v)",
tt.args, tt.key, got, ok, tt.want, tt.ok)
}
}
}
func TestBrowserToolName(t *testing.T) {
cfg := config.BrowserToolConfig{
Stealth: true,
}
// NewBrowserTool will fail without Chrome, but we can test
// the stub or interface conformance
tool, err := NewBrowserTool(cfg)
if err != nil {
// Expected when Chrome is not running
t.Logf("NewBrowserTool failed (expected without Chrome): %v", err)
return
}
if tool.Name() != "browser" {
t.Errorf("Name() = %q, want %q", tool.Name(), "browser")
}
if tool.Description() == "" {
t.Error("Description() returned empty string")
}
if tool.Parameters() == nil {
t.Error("Parameters() returned nil")
}
}
func TestStealthJSGeneration(t *testing.T) {
js := generateStealthJS()
if js == "" {
t.Fatal("generateStealthJS() returned empty string")
}
if len(js) < 1000 {
t.Errorf("Stealth JS too short (%d chars), expected comprehensive patches", len(js))
}
// Verify key patches are present
checks := []string{
"navigator.webdriver", // Patch 1
"window.chrome", // Patch 2
"navigator.plugins", // Patch 3
"navigator.languages", // Patch 4
"Permissions.prototype", // Patch 5
"__playwright", // Patch 6
"Error.prototype", // Patch 7
"debugger", // Patch 8
"console", // Patch 9
"outerWidth", // Patch 10
"Performance.prototype", // Patch 11
"$cdc_", // Patch 12
"contentWindow", // Patch 13
}
for _, check := range checks {
if !strings.Contains(js, check) {
t.Errorf("Stealth JS missing patch for %q", check)
}
}
}
func TestLaunchChromeArgs(t *testing.T) {
args := LaunchChromeArgs(9222, true)
found := false
for _, a := range args {
if a == "--remote-debugging-port=9222" {
found = true
}
}
if !found {
t.Error("LaunchChromeArgs missing --remote-debugging-port=9222")
}
headlessFound := false
for _, a := range args {
if a == "--headless=new" {
headlessFound = true
}
}
if !headlessFound {
t.Error("LaunchChromeArgs with headless=true missing --headless=new")
}
// Test without headless
args2 := LaunchChromeArgs(9222, false)
for _, a := range args2 {
if a == "--headless=new" {
t.Error("LaunchChromeArgs with headless=false should not have --headless=new")
}
}
}
func TestCDPMessageMarshal(t *testing.T) {
msg := map[string]any{
"id": int64(1),
"method": "Page.navigate",
"params": map[string]any{
"url": "https://example.com",
},
}
data, err := json.Marshal(msg)
if err != nil {
t.Fatalf("Failed to marshal CDP message: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("Failed to unmarshal CDP message: %v", err)
}
if parsed["method"] != "Page.navigate" {
t.Errorf("method = %v, want Page.navigate", parsed["method"])
}
}