Files
goclaw/internal/tools/web_fetch.go
T
viettranx bdb60de7ae chore: upgrade Go 1.25 → 1.26 and apply go fix modernizations
- Update go.mod and Dockerfile to Go 1.26
- Apply `go fix ./...` stdlib modernizations across 170+ files
- Add `go fix` to post-implementation checklist in CLAUDE.md
- Fix go fix misapplied rewrite in loop_history.go
2026-03-10 00:09:15 +07:00

324 lines
9.5 KiB
Go

package tools
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// Matching TS src/agents/tools/web-fetch.ts constants.
const (
defaultFetchMaxChars = 50000
defaultFetchMaxRedirect = 3
defaultErrorMaxChars = 4000
fetchTimeoutSeconds = 30
fetchUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
// WebFetchTool implements the web_fetch tool matching TS src/agents/tools/web-fetch.ts.
type WebFetchTool struct {
maxChars int
cache *webCache
policy string // "allow_all" (default), "allowlist"
allowedDomains []string // domains when policy="allowlist" (supports "*.example.com")
blockedDomains []string // always checked regardless of policy (supports "*.example.com")
mu sync.RWMutex
}
// WebFetchConfig holds configuration for the web fetch tool.
type WebFetchConfig struct {
MaxChars int
CacheTTL time.Duration
Policy string // "allow_all" (default), "allowlist"
AllowedDomains []string // domains when policy="allowlist"
BlockedDomains []string // always blocked regardless of policy
}
func NewWebFetchTool(cfg WebFetchConfig) *WebFetchTool {
maxChars := cfg.MaxChars
if maxChars <= 0 {
maxChars = defaultFetchMaxChars
}
ttl := cfg.CacheTTL
if ttl <= 0 {
ttl = defaultCacheTTL
}
policy := cfg.Policy
if policy == "" {
policy = "allow_all"
}
return &WebFetchTool{
maxChars: maxChars,
cache: newWebCache(defaultCacheMaxEntries, ttl),
policy: policy,
allowedDomains: cfg.AllowedDomains,
blockedDomains: cfg.BlockedDomains,
}
}
// UpdatePolicy replaces the domain policy at runtime (called via pub/sub on config change).
func (t *WebFetchTool) UpdatePolicy(policy string, allowed, blocked []string) {
t.mu.Lock()
defer t.mu.Unlock()
if policy == "" {
policy = "allow_all"
}
t.policy = policy
t.allowedDomains = allowed
t.blockedDomains = blocked
slog.Info("web_fetch policy updated", "policy", policy, "allowed", len(allowed), "blocked", len(blocked))
}
// matchDomainList checks if a hostname matches any pattern in the list.
// Supports exact match ("github.com") and wildcard prefix ("*.example.com").
func matchDomainList(hostname string, patterns []string) bool {
hostname = strings.ToLower(hostname)
for _, pattern := range patterns {
pattern = strings.ToLower(strings.TrimSpace(pattern))
if pattern == hostname {
return true
}
// Wildcard: *.example.com matches sub.example.com, a.b.example.com
if strings.HasPrefix(pattern, "*.") {
suffix := pattern[1:] // ".example.com"
if strings.HasSuffix(hostname, suffix) && hostname != suffix[1:] {
return true
}
}
}
return false
}
// isDomainAllowed checks if a hostname matches the allowlist.
func (t *WebFetchTool) isDomainAllowed(hostname string) bool {
t.mu.RLock()
domains := t.allowedDomains
t.mu.RUnlock()
return matchDomainList(hostname, domains)
}
// isDomainBlocked checks if a hostname matches the blocklist.
func (t *WebFetchTool) isDomainBlocked(hostname string) bool {
t.mu.RLock()
domains := t.blockedDomains
t.mu.RUnlock()
return matchDomainList(hostname, domains)
}
func (t *WebFetchTool) Name() string { return "web_fetch" }
func (t *WebFetchTool) Description() string {
return "Fetch a URL and extract its content. Supports HTML (converted to markdown/text), JSON, and plain text. Includes SSRF protection."
}
func (t *WebFetchTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"url": map[string]any{
"type": "string",
"description": "HTTP or HTTPS URL to fetch.",
},
"extractMode": map[string]any{
"type": "string",
"description": `Extraction mode ("markdown" or "text"). Default: "markdown".`,
"enum": []string{"markdown", "text"},
},
"maxChars": map[string]any{
"type": "number",
"description": "Maximum characters to return (truncates when exceeded).",
"minimum": 100.0,
},
},
"required": []string{"url"},
}
}
func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *Result {
rawURL, _ := args["url"].(string)
if rawURL == "" {
return ErrorResult("url is required")
}
// Validate URL scheme
parsed, err := url.Parse(rawURL)
if err != nil {
return ErrorResult(fmt.Sprintf("invalid URL: %v", err))
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return ErrorResult("only http and https URLs are supported")
}
if parsed.Host == "" {
return ErrorResult("missing hostname in URL")
}
// SSRF protection
if err := CheckSSRF(rawURL); err != nil {
return ErrorResult(fmt.Sprintf("SSRF protection: %v", err))
}
hostname := parsed.Hostname()
// Domain blocklist check (always enforced regardless of policy)
if t.isDomainBlocked(hostname) {
return ErrorResult(fmt.Sprintf("domain %q is blocked by policy", hostname))
}
// Domain allowlist check
t.mu.RLock()
policy := t.policy
t.mu.RUnlock()
if policy == "allowlist" && !t.isDomainAllowed(hostname) {
return ErrorResult(fmt.Sprintf("domain %q is not in the allowed domains list", hostname))
}
extractMode := "markdown"
if em, ok := args["extractMode"].(string); ok && (em == "markdown" || em == "text") {
extractMode = em
}
maxChars := t.maxChars
if mc, ok := args["maxChars"].(float64); ok && int(mc) >= 100 {
maxChars = int(mc)
}
// Check cache (scoped per channel to prevent cross-channel cache poisoning)
channel := ToolChannelFromCtx(ctx)
cacheKey := fmt.Sprintf("fetch:%s:%s:%s:%d", channel, rawURL, extractMode, maxChars)
if cached, ok := t.cache.get(cacheKey); ok {
slog.Debug("web_fetch cache hit", "url", rawURL)
return NewResult(cached)
}
// Fetch
result, err := t.doFetch(ctx, rawURL, extractMode, maxChars, policy)
if err != nil {
errMsg := truncateStr(err.Error(), defaultErrorMaxChars)
return ErrorResult(fmt.Sprintf("fetch failed: %s", errMsg))
}
wrapped := wrapExternalContent(result, "Web Fetch", true)
t.cache.set(cacheKey, wrapped)
return NewResult(wrapped)
}
func (t *WebFetchTool) doFetch(ctx context.Context, rawURL, extractMode string, maxChars int, policy string) (string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", rawURL, nil)
if err != nil {
return "", fmt.Errorf("create request: %w", err)
}
req.Header.Set("User-Agent", fetchUserAgent)
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
redirectCount := 0
client := &http.Client{
Timeout: time.Duration(fetchTimeoutSeconds) * time.Second,
Transport: &http.Transport{
ForceAttemptHTTP2: true,
MaxIdleConns: 10,
IdleConnTimeout: 30 * time.Second,
TLSHandshakeTimeout: 15 * time.Second,
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
redirectCount++
if redirectCount > defaultFetchMaxRedirect {
return fmt.Errorf("stopped after %d redirects", defaultFetchMaxRedirect)
}
// Check SSRF on redirect target
if err := CheckSSRF(req.URL.String()); err != nil {
return fmt.Errorf("redirect SSRF protection: %w", err)
}
// Check domain blocklist on redirect target
redirectHost := req.URL.Hostname()
if t.isDomainBlocked(redirectHost) {
return fmt.Errorf("redirect to %q blocked: domain is in blocklist", redirectHost)
}
// Check domain allowlist on redirect target
if policy == "allowlist" && !t.isDomainAllowed(redirectHost) {
return fmt.Errorf("redirect to %q blocked: domain not in allowlist", redirectHost)
}
return nil
},
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
// Read enough HTML to reach <body> content — pages often have 30-50KB+ <head> sections.
readLimit := int64(max(maxChars*10, 512*1024))
limitReader := io.LimitReader(resp.Body, readLimit)
body, err := io.ReadAll(limitReader)
if err != nil {
return "", fmt.Errorf("read body: %w", err)
}
contentType := resp.Header.Get("Content-Type")
finalURL := resp.Request.URL.String()
var text string
var extractor string
switch {
case strings.Contains(contentType, "application/json"):
text, extractor = extractJSON(body)
case strings.Contains(contentType, "text/markdown"):
text = string(body)
extractor = "cf-markdown"
if extractMode == "text" {
text = markdownToText(text)
}
case strings.Contains(contentType, "text/html"),
strings.Contains(contentType, "application/xhtml"):
if extractMode == "markdown" {
text = htmlToMarkdown(string(body))
extractor = "html-to-markdown"
} else {
text = htmlToText(string(body))
extractor = "html-to-text"
}
if text == "" && len(body) > 0 {
text = "[No content extracted. The page may require JavaScript to render, " +
"or returned a bot-protection challenge. Try using browser automation instead.]"
}
default:
text = string(body)
extractor = "raw"
}
// Truncate
truncated := false
if len(text) > maxChars {
text = text[:maxChars]
truncated = true
}
// Format response metadata + content (boundary wrapping handled by wrapExternalContent in Execute)
var sb strings.Builder
sb.WriteString(fmt.Sprintf("URL: %s\n", finalURL))
if finalURL != rawURL {
sb.WriteString(fmt.Sprintf("Redirected from: %s\n", rawURL))
}
sb.WriteString(fmt.Sprintf("Status: %d\n", resp.StatusCode))
sb.WriteString(fmt.Sprintf("Extractor: %s\n", extractor))
if truncated {
sb.WriteString(fmt.Sprintf("Truncated: true (limit: %d chars)\n", maxChars))
}
sb.WriteString(fmt.Sprintf("Length: %d\n", len(text)))
sb.WriteString("\n")
sb.WriteString(text)
return sb.String(), nil
}