Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pkg/agent/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ func registerSharedTools(
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
Proxy: cfg.Tools.Web.Proxy,
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Proxy: field in this struct literal is mis-indented compared to the other fields and will be reformatted by gofmt; if CI enforces formatting, this may fail. Please run gofmt (or fix indentation) so the struct literal field alignment is consistent.

Suggested change
Proxy: cfg.Tools.Web.Proxy,
Proxy: cfg.Tools.Web.Proxy,

Copilot uses AI. Check for mistakes.
}); searchTool != nil {
agent.Tools.Register(searchTool)
}
Expand Down
1 change: 1 addition & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,7 @@ type WebToolsConfig struct {
Tavily TavilyConfig `json:"tavily"`
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
Perplexity PerplexityConfig `json:"perplexity"`
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
}

type CronToolsConfig struct {
Expand Down
1 change: 1 addition & 0 deletions pkg/config/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ func DefaultConfig() *Config {
},
Tools: ToolsConfig{
Web: WebToolsConfig{
Proxy: "",
Brave: BraveConfig{
Enabled: false,
APIKey: "",
Expand Down
95 changes: 87 additions & 8 deletions pkg/tools/web.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ import (
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"time"

"golang.org/x/net/proxy"
)

const (
Expand All @@ -23,6 +26,7 @@ type SearchProvider interface {

type BraveSearchProvider struct {
apiKey string
proxy string
}

func (p *BraveSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
Expand All @@ -37,7 +41,10 @@ func (p *BraveSearchProvider) Search(ctx context.Context, query string, count in
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Subscription-Token", p.apiKey)

client := &http.Client{Timeout: 10 * time.Second}
client, err := clientForProxy(p.proxy, 10*time.Second)
if err != nil {
return "", fmt.Errorf("failed to create http client: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
Expand Down Expand Up @@ -88,6 +95,7 @@ func (p *BraveSearchProvider) Search(ctx context.Context, query string, count in
type TavilySearchProvider struct {
apiKey string
baseURL string
proxy string
}

func (p *TavilySearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
Expand Down Expand Up @@ -119,7 +127,10 @@ func (p *TavilySearchProvider) Search(ctx context.Context, query string, count i
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", userAgent)

client := &http.Client{Timeout: 10 * time.Second}
client, err := clientForProxy(p.proxy, 10*time.Second)
if err != nil {
return "", fmt.Errorf("failed to create http client: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
Expand Down Expand Up @@ -167,7 +178,10 @@ func (p *TavilySearchProvider) Search(ctx context.Context, query string, count i
return strings.Join(lines, "\n"), nil
}

type DuckDuckGoSearchProvider struct{}
// duckduckgo provider may use an optional proxy (socks5 or http)
type DuckDuckGoSearchProvider struct {
proxy string
}

func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
searchURL := fmt.Sprintf("https://html.duckduckgo.com/html/?q=%s", url.QueryEscape(query))
Expand All @@ -179,7 +193,10 @@ func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, cou

req.Header.Set("User-Agent", userAgent)

client := &http.Client{Timeout: 10 * time.Second}
client, err := clientForProxy(p.proxy, 10*time.Second)
if err != nil {
return "", fmt.Errorf("failed to create http client: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
Expand Down Expand Up @@ -259,8 +276,64 @@ func stripTags(content string) string {
return re.ReplaceAllString(content, "")
}

// clientForProxy returns an *http.Client honoring the provided proxy string.
// Supported formats:
// - "socks5://host:port" or plain "host:port" (treated as socks5)
// - any URL with scheme (http://, https://) will be used as an HTTP proxy via ProxyURL
func clientForProxy(proxyStr string, timeout time.Duration) (*http.Client, error) {
transport := &http.Transport{
TLSHandshakeTimeout: 15 * time.Second,
MaxIdleConns: 10,
IdleConnTimeout: 30 * time.Second,
DisableCompression: false,
}
Comment on lines +279 to +289
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Proxy support is newly introduced here (via Proxy option and clientForProxy), but the existing pkg/tools/web_test.go suite doesn’t appear to cover any proxy behaviors (e.g., HTTP proxy URL parsing, SOCKS5 dialer selection, or error handling on invalid proxy strings). Adding focused unit tests for clientForProxy (and at least one provider using it) would help prevent regressions.

Copilot uses AI. Check for mistakes.

if proxyStr == "" {
return &http.Client{Timeout: timeout, Transport: transport}, nil
}

// If no scheme provided, assume socks5
if !strings.Contains(proxyStr, "://") {
// treat as socks5 host:port
dialer, err := proxy.SOCKS5("tcp", proxyStr, nil, proxy.Direct)
if err != nil {
return nil, err
}
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.Dial(network, addr)
}
return &http.Client{Timeout: timeout, Transport: transport}, nil
Comment on lines +302 to +305
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This DialContext implementation ignores the provided ctx and calls dialer.Dial directly. That prevents request cancellation/timeouts from aborting an in-progress SOCKS5 dial, which can cause hangs under network issues. Prefer using a dialer that supports context (e.g., if the returned dialer implements proxy.ContextDialer, use DialContext), or otherwise implement a ctx-aware cancellation wrapper.

Copilot uses AI. Check for mistakes.
}

u, err := url.Parse(proxyStr)
if err != nil {
return nil, err
}

switch u.Scheme {
case "socks5", "socks5h":
var auth *proxy.Auth
if u.User != nil {
password, _ := u.User.Password()
auth = &proxy.Auth{User: u.User.Username(), Password: password}
}
dialer, err := proxy.SOCKS5("tcp", u.Host, auth, proxy.Direct)
if err != nil {
return nil, err
}
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.Dial(network, addr)
}
Comment on lines +324 to +326
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue here: DialContext ignores ctx, so cancelling the request context may not stop the SOCKS5 connection attempt. Consider using proxy.ContextDialer when available or otherwise honoring ctx to avoid stuck dials.

Copilot uses AI. Check for mistakes.
return &http.Client{Timeout: timeout, Transport: transport}, nil
default:
transport.Proxy = http.ProxyURL(u)
return &http.Client{Timeout: timeout, Transport: transport}, nil
}
}

type PerplexitySearchProvider struct {
apiKey string
proxy string
}

func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
Expand Down Expand Up @@ -295,7 +368,11 @@ func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, cou
req.Header.Set("Authorization", "Bearer "+p.apiKey)
req.Header.Set("User-Agent", userAgent)

client := &http.Client{Timeout: 30 * time.Second}
client, err := clientForProxy(p.proxy, 30*time.Second)
if err != nil {
return "", fmt.Errorf("failed to create http client: %w", err)
}

resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
Expand Down Expand Up @@ -348,6 +425,7 @@ type WebSearchToolOptions struct {
PerplexityAPIKey string
PerplexityMaxResults int
PerplexityEnabled bool
Proxy string
}

func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool {
Expand All @@ -356,25 +434,26 @@ func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool {

// Priority: Perplexity > Brave > Tavily > DuckDuckGo
if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" {
provider = &PerplexitySearchProvider{apiKey: opts.PerplexityAPIKey}
provider = &PerplexitySearchProvider{apiKey: opts.PerplexityAPIKey, proxy: opts.Proxy}
if opts.PerplexityMaxResults > 0 {
maxResults = opts.PerplexityMaxResults
}
} else if opts.BraveEnabled && opts.BraveAPIKey != "" {
provider = &BraveSearchProvider{apiKey: opts.BraveAPIKey}
provider = &BraveSearchProvider{apiKey: opts.BraveAPIKey, proxy: opts.Proxy}
if opts.BraveMaxResults > 0 {
maxResults = opts.BraveMaxResults
}
} else if opts.TavilyEnabled && opts.TavilyAPIKey != "" {
provider = &TavilySearchProvider{
apiKey: opts.TavilyAPIKey,
baseURL: opts.TavilyBaseURL,
proxy: opts.Proxy,
}
if opts.TavilyMaxResults > 0 {
maxResults = opts.TavilyMaxResults
}
} else if opts.DuckDuckGoEnabled {
provider = &DuckDuckGoSearchProvider{}
provider = &DuckDuckGoSearchProvider{proxy: opts.Proxy}
if opts.DuckDuckGoMaxResults > 0 {
maxResults = opts.DuckDuckGoMaxResults
}
Expand Down
Loading