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:
parent
7bf6cbe1fa
commit
9d2a5bcab2
9 changed files with 2268 additions and 0 deletions
|
|
@ -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") {
|
if cfg.Tools.IsToolEnabled("edit_file") {
|
||||||
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths))
|
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -875,6 +875,7 @@ type ToolsConfig struct {
|
||||||
Subagent ToolConfig `json:"subagent" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"`
|
Subagent ToolConfig `json:"subagent" yaml:"-" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"`
|
||||||
WebFetch ToolConfig `json:"web_fetch" yaml:"-" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"`
|
WebFetch ToolConfig `json:"web_fetch" yaml:"-" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"`
|
||||||
WriteFile ToolConfig `json:"write_file" yaml:"-" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"`
|
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
|
// 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"`
|
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 {
|
type SkillsRegistriesConfig struct {
|
||||||
ClawHub ClawHubRegistryConfig `json:"clawhub" yaml:"clawhub,omitempty"`
|
ClawHub ClawHubRegistryConfig `json:"clawhub" yaml:"clawhub,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
@ -1364,6 +1375,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool {
|
||||||
return t.WriteFile.Enabled
|
return t.WriteFile.Enabled
|
||||||
case "mcp":
|
case "mcp":
|
||||||
return t.MCP.Enabled
|
return t.MCP.Enabled
|
||||||
|
case "browser":
|
||||||
|
return t.Browser.Enabled
|
||||||
default:
|
default:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
889
pkg/tools/browser.go
Normal file
889
pkg/tools/browser.go
Normal 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
530
pkg/tools/browser_cdp.go
Normal 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
|
||||||
|
}
|
||||||
98
pkg/tools/browser_cdp_util.go
Normal file
98
pkg/tools/browser_cdp_util.go
Normal 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
|
||||||
|
}
|
||||||
250
pkg/tools/browser_integration_test.go
Normal file
250
pkg/tools/browser_integration_test.go
Normal 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
|
||||||
|
}
|
||||||
234
pkg/tools/browser_stealth.go
Normal file
234
pkg/tools/browser_stealth.go
Normal 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
42
pkg/tools/browser_stub.go
Normal 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
202
pkg/tools/browser_test.go
Normal 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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue