diff --git a/internal/browser/fetch.go b/internal/browser/fetch.go index cebdb04..c80191c 100644 --- a/internal/browser/fetch.go +++ b/internal/browser/fetch.go @@ -15,32 +15,42 @@ import ( "git.davoryn.de/calic/claude-statusline/internal/config" ) -// FetchViaChrome navigates to a URL using Chrome with the persistent browser -// profile (which has Cloudflare clearance cookies) and returns the response -// body. Uses non-headless mode with a minimized/hidden window to avoid -// Cloudflare's headless detection, which causes infinite challenge loops. +// FetchViaChrome navigates to a URL using Chrome with a dedicated browser +// profile and returns the response body. Uses stealth flags to avoid +// Cloudflare's automation detection (navigator.webdriver, etc.). func FetchViaChrome(url string) ([]byte, error) { - profileDir := filepath.Join(config.ConfigDir(), "browser-profile") + // Use a dedicated fetch profile separate from the login profile. + profileDir := filepath.Join(config.ConfigDir(), "fetch-profile") if err := os.MkdirAll(profileDir, 0o755); err != nil { - return nil, fmt.Errorf("create browser profile dir: %w", err) + return nil, fmt.Errorf("create fetch profile dir: %w", err) } - // Remove stale lock file from unclean shutdown _ = os.Remove(filepath.Join(profileDir, "SingletonLock")) execPath := findBrowserExec() - // Use non-headless mode: Cloudflare detects headless Chrome and loops - // the JS challenge forever. A real (but hidden) browser window passes. - opts := append(chromedp.DefaultExecAllocatorOptions[:], - chromedp.Flag("headless", false), - chromedp.Flag("window-position", "-32000,-32000"), // off-screen + // Start with minimal options — NOT DefaultExecAllocatorOptions, which + // includes flags that Cloudflare detects (--enable-automation, etc.). + opts := []chromedp.ExecAllocatorOption{ + chromedp.NoFirstRun, + chromedp.NoDefaultBrowserCheck, + chromedp.UserDataDir(profileDir), + + // Stealth: disable automation indicators + chromedp.Flag("disable-blink-features", "AutomationControlled"), + chromedp.Flag("enable-automation", false), + chromedp.Flag("disable-infobars", true), + + // Window: off-screen so it doesn't flash + chromedp.Flag("window-position", "-32000,-32000"), chromedp.Flag("window-size", "1,1"), chromedp.Flag("disable-gpu", true), - chromedp.Flag("no-first-run", true), chromedp.Flag("disable-extensions", true), - chromedp.UserDataDir(profileDir), - ) + chromedp.Flag("no-first-run", true), + + // Use non-headless — Cloudflare detects headless mode + chromedp.Flag("headless", false), + } if execPath != "" { opts = append(opts, chromedp.ExecPath(execPath)) } @@ -51,19 +61,42 @@ func FetchViaChrome(url string) ([]byte, error) { ctx, cancel := chromedp.NewContext(allocCtx) defer cancel() - // Total timeout for the operation ctx, timeoutCancel := context.WithTimeout(ctx, 30*time.Second) defer timeoutCancel() - // Navigate and wait for Cloudflare challenge to resolve. - log.Printf("chrome-fetch: navigating to %s (profile: %s)", url, profileDir) + // Inject the session key cookie before navigating + sessionKey := config.GetSessionKey() + if sessionKey != "" { + err := chromedp.Run(ctx, + chromedp.Navigate("about:blank"), + setCookieAction("sessionKey", sessionKey, ".claude.ai"), + ) + if err != nil { + log.Printf("chrome-fetch: cookie injection failed: %v", err) + } + } + + // Patch navigator.webdriver to false via CDP + err := chromedp.Run(ctx, + chromedp.ActionFunc(func(ctx context.Context) error { + _, _, _, err := chromedp.Targets(ctx) + _ = err + return nil + }), + chromedp.Evaluate(`Object.defineProperty(navigator, 'webdriver', {get: () => undefined})`, nil), + ) + if err != nil { + log.Printf("chrome-fetch: webdriver patch failed: %v", err) + } + + log.Printf("chrome-fetch: navigating to %s (exec: %q)", url, execPath) if err := chromedp.Run(ctx, chromedp.Navigate(url)); err != nil { log.Printf("chrome-fetch: navigate failed: %v", err) return nil, fmt.Errorf("chromedp navigate: %w", err) } log.Printf("chrome-fetch: navigation complete, polling for JSON...") - // Poll for JSON response — Cloudflare challenge takes a few seconds to clear + // Poll for JSON response — Cloudflare challenge takes a few seconds ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() @@ -73,12 +106,10 @@ func FetchViaChrome(url string) ([]byte, error) { return nil, fmt.Errorf("chromedp fetch timed out waiting for JSON response") case <-ticker.C: var body string - // Try
first (Chrome wraps JSON in tags)
err := chromedp.Run(ctx,
chromedp.Text("pre", &body, chromedp.ByQuery),
)
if err != nil || body == "" {
- // Fallback: try body directly
_ = chromedp.Run(ctx,
chromedp.Text("body", &body, chromedp.ByQuery),
)
@@ -88,15 +119,12 @@ func FetchViaChrome(url string) ([]byte, error) {
log.Printf("chrome-fetch: page body empty, waiting...")
continue
}
- // Check if we got actual JSON (starts with [ or {), not a challenge page
if body[0] == '[' || body[0] == '{' {
log.Printf("chrome-fetch: got JSON response (%d bytes)", len(body))
- // Also extract any fresh cookies for future plain HTTP attempts
_ = extractAndSaveCookies(ctx)
- cancel() // graceful close, flushes cookies to profile
+ cancel()
return []byte(body), nil
}
- // Log a snippet of what we got (challenge page, login redirect, etc.)
snippet := body
if len(snippet) > 200 {
snippet = snippet[:200]
@@ -106,8 +134,20 @@ func FetchViaChrome(url string) ([]byte, error) {
}
}
+// setCookieAction sets a cookie via the DevTools protocol.
+func setCookieAction(name, value, domain string) chromedp.Action {
+ return chromedp.ActionFunc(func(ctx context.Context) error {
+ expr := network.SetCookie(name, value).
+ WithDomain(domain).
+ WithPath("/").
+ WithHTTPOnly(true).
+ WithSecure(true)
+ return expr.Do(ctx)
+ })
+}
+
// extractAndSaveCookies saves cf_clearance and other Cloudflare cookies
-// alongside the session key, so plain HTTP requests can try them next time.
+// so plain HTTP requests can try them on subsequent polls.
func extractAndSaveCookies(ctx context.Context) error {
cookies, err := network.GetCookies().Do(ctx)
if err != nil {
@@ -127,7 +167,6 @@ func extractAndSaveCookies(ctx context.Context) error {
return nil
}
- // Write Cloudflare cookies to a file the fetcher can read
cfPath := filepath.Join(config.ConfigDir(), "cf-cookies")
return os.WriteFile(cfPath, []byte(strings.Join(parts, "\n")+"\n"), 0o600)
}