(ctx context.Context, args []string, onOutput func(string))
| 109 | } |
| 110 | |
| 111 | func (p *BuiltinWebFetchPlugin) ExecuteWithStream(ctx context.Context, args []string, onOutput func(string)) (string, error) { |
| 112 | if len(args) == 0 { |
| 113 | return "", fmt.Errorf("url required. Usage: @webfetch fetch --url <URL>") |
| 114 | } |
| 115 | |
| 116 | parsed, err := parseFetchArgs(args) |
| 117 | if err != nil { |
| 118 | return "", err |
| 119 | } |
| 120 | if parsed.URL == "" { |
| 121 | return "", fmt.Errorf("url required") |
| 122 | } |
| 123 | if parsed.MaxLength <= 0 { |
| 124 | parsed.MaxLength = defaultWebFetchMaxLength |
| 125 | } |
| 126 | |
| 127 | if onOutput != nil { |
| 128 | onOutput(fmt.Sprintf("Fetching %s...", parsed.URL)) |
| 129 | } |
| 130 | |
| 131 | // Create HTTP request with timeout |
| 132 | reqCtx, cancel := context.WithTimeout(ctx, 30*time.Second) |
| 133 | defer cancel() |
| 134 | |
| 135 | // SSRF guard: validate (and canonicalize) the target before it reaches the |
| 136 | // HTTP client. Blocks cloud-metadata/link-local always; private/loopback |
| 137 | // only when CHATCLI_WEBFETCH_BLOCK_PRIVATE is set. |
| 138 | safeURL, err := validateWebTarget(parsed.URL) |
| 139 | if err != nil { |
| 140 | return "", fmt.Errorf("refusing to fetch %q: %w", parsed.URL, err) |
| 141 | } |
| 142 | |
| 143 | // webGet sends a browser UA by default (avoids CDN bot-blocks) and |
| 144 | // auto-retries with a neutral tool UA on a 401/407 gateway challenge — |
| 145 | // the behavior of TLS-intercepting corporate proxies toward "browsers". |
| 146 | resp, err := webGet(reqCtx, safeURL, map[string]string{ |
| 147 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", |
| 148 | "Accept-Language": "en-US,en;q=0.9", |
| 149 | }) |
| 150 | if err != nil { |
| 151 | return "", fmt.Errorf("fetching URL: %w", err) |
| 152 | } |
| 153 | defer resp.Body.Close() |
| 154 | |
| 155 | if resp.StatusCode != 200 { |
| 156 | return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status) |
| 157 | } |
| 158 | |
| 159 | // Read body with hard cap of 10MB to avoid memory blowup. |
| 160 | body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) |
| 161 | if err != nil { |
| 162 | return "", fmt.Errorf("reading body: %w", err) |
| 163 | } |
| 164 | |
| 165 | fullContent := string(body) |
| 166 | if !parsed.Raw { |
| 167 | fullContent = p.extractWithRenderEscalation(reqCtx, parsed, safeURL, fullContent, onOutput) |
| 168 | } |
no test coverage detected