Skip to content

Commit 86d4bb2

Browse files
committed
feat(transport): drive a real headless Chrome via chromedp (XActions-style)
User pulled the v0.3+utls binary, ran it, and the Cloudflare HTML challenge body was still in the error. utls only fakes the TLS handshake — Cloudflare also inspects HTTP/2 frame ordering, JS challenges, navigator fingerprinting, etc. Faking a piece of Chrome was never going to be enough against modern bot management. Stop fighting it. Be Chrome. This commit ports XActions' Puppeteer approach to Go via chromedp. Real headless Chrome instance, real TLS, real HTTP/2, real cookies, real everything. Cloudflare can't tell us apart from the user's daily driver because we ARE Chrome. internal/chromebrowser/browser.go (new): Browser type wraps a chromedp ExecAllocator + browser context. Lazy-started on the first Fetch; Close() releases the Chrome process. Stealth flags applied at launch: --headless=new (Chrome 109+ headless v2) --disable-blink-features=AutomationControlled --disable-features=AutomationControlled,IsolateOrigins,site-per-process --no-default-browser-check --no-first-run --user-agent=<Chrome 120 macOS> --window-size=1280,800 Fetch(ctx, method, url, headers, cookies) sets cookies via Network.SetCookies (CDP), navigates to https://x.com/robots.txt to establish an x.com origin, then runs `fetch()` inside the page context with credentials:'include'. Returns the HTTP status and raw body bytes. Cloudflare clearance cookies are issued by the navigation as a side effect, so the subsequent fetch inherits them automatically. internal/chromebrowser/transport.go (new): Transport implements http.RoundTripper around Browser. Drop into http.Client.Transport and the rest of x-cli's HTTP code (retries, throttle, Set-Cookie merging, error parsing) keeps working unchanged. Cookie header is split out and pushed to the browser via CDP; everything else flows through fetch options. api/client.go: New Options.UseBrowser flag. When true and no HTTPClient was provided, the default client is wired with chromebrowser.NewTransport() and a 60s timeout (browser startup is ~1-2s on first call). When false, falls back to the existing tlsprint http+utls path — preserved so users without Chrome can still use x-cli with reduced reliability. cmd/root.go: --http persistent flag forces the http+utls path. Default is the browser path. newClient() propagates UseBrowser: !useHTTP into api.Options. cmd/auth.go: runAuthImport now defaults to the browser path. The "verifying session" log line tells the user which transport is in use so they can debug. 60s context (vs the old 20s) accounts for Chrome startup. The error message points at "Chrome could not start" as one possible failure mode. .github/workflows/ci.yml: Go bumped 1.24 → 1.26 (chromedp's go.mod requires it). go.mod: + github.com/chromedp/chromedp v0.15.1 + github.com/chromedp/cdproto (latest) Plus pure-Go indirect deps (chromedp/sysutil, gobwas/ws, go-json-experiment). How it works end to end: 1. `x auth import` reads cookies from the user's Chrome via kooky (our existing browsercookies path, unchanged). 2. The Client constructs a chromebrowser.Transport. First call spawns headless Chrome (~1-2s). 3. VerifyCredentials calls UserByRestId via the transport. The RoundTrip pushes cookies into the headless Chrome via Network.SetCookies, navigates to x.com/robots.txt to get Cloudflare clearance, then runs fetch() in the x.com origin context. The response comes back as JSON. 4. x-cli parses the JSON same as before — the api/ projection code is transport-agnostic. Trade-offs: + Cloudflare doesn't see us as a bot because we're not. Same reason XActions' Puppeteer path works. + No more chasing fingerprints: TLS, HTTP/2, JS challenges, all handled by Chrome itself. + The existing http+utls path stays available via --http for users without Chrome (Linux servers, CI runners). - Requires Chrome installed on the user's machine. macOS and Windows: typical. Linux desktop: typical. Headless Linux: user has to install google-chrome-stable or chromium. - First call pays ~1-2s Chrome startup. Subsequent calls in the same process are ~200-500ms. - Adds ~10MB of pure-Go deps (chromedp + cdproto). Binary goes from ~11MB to ~14MB. If `--http` is needed (no Chrome installed), x-cli falls back to the utls path with all the caveats it has. The user can decide which trade-off matters more for their environment.
1 parent e88b25b commit 86d4bb2

8 files changed

Lines changed: 347 additions & 29 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ jobs:
2626

2727
- uses: actions/setup-go@v5
2828
with:
29-
go-version: '1.24'
29+
go-version: '1.26'
3030
cache: true
3131

3232
- name: Module tidy check
@@ -71,7 +71,7 @@ jobs:
7171

7272
- uses: actions/setup-go@v5
7373
with:
74-
go-version: '1.24'
74+
go-version: '1.26'
7575
cache: true
7676

7777
- name: Build ${{ matrix.goos }}/${{ matrix.goarch }}

api/client.go

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"sync"
1818
"time"
1919

20+
"github.com/thevibeworks/x-cli/internal/chromebrowser"
2021
"github.com/thevibeworks/x-cli/internal/tlsprint"
2122
)
2223

@@ -55,25 +56,36 @@ type Options struct {
5556
Session Session
5657
UserAgent string
5758
Verbose bool
59+
60+
// UseBrowser routes requests through a headless Chrome via
61+
// chromedp instead of the http+utls transport. Use this for
62+
// endpoints behind Cloudflare Bot Management (the entire
63+
// /i/api/graphql/* path on x.com today). Ignored when
64+
// HTTPClient is set explicitly.
65+
UseBrowser bool
5866
}
5967

6068
func New(opts Options) *Client {
6169
if opts.HTTPClient == nil {
62-
// The default HTTP client is wired with a uTLS Chrome 120
63-
// ClientHelloID round-tripper. x.com's /i/api/graphql/* path
64-
// is behind Cloudflare Bot Management which JA3-fingerprints
65-
// clients; Go's stdlib TLS gets flagged as non-browser and
66-
// served a challenge page. Impersonating Chrome at the TLS
67-
// handshake defeats that. See internal/tlsprint/ for detail.
68-
//
69-
// 15s per request is generous for a single GraphQL call. With
70-
// the (lowered) default retry count of 1, total worst-case
71-
// wall-clock for one client.GraphQL is ~30s — short enough
72-
// that an interactive user does not assume the program is
73-
// hung.
74-
opts.HTTPClient = &http.Client{
75-
Transport: tlsprint.NewChromeTransport(),
76-
Timeout: 15 * time.Second,
70+
switch {
71+
case opts.UseBrowser:
72+
// Route every request through a real headless Chrome.
73+
// Slower (1-2s startup, then 200-500ms per call) but
74+
// passes Cloudflare Bot Management because it IS Chrome.
75+
// See internal/chromebrowser for the full rationale.
76+
opts.HTTPClient = &http.Client{
77+
Transport: chromebrowser.NewTransport(),
78+
Timeout: 60 * time.Second,
79+
}
80+
default:
81+
// HTTP path with uTLS Chrome 120 ClientHelloID. Lighter
82+
// weight, no Chrome dependency, but only fakes the TLS
83+
// handshake — Cloudflare's deeper layers (HTTP/2 frame
84+
// ordering, JS challenges) can still detect us.
85+
opts.HTTPClient = &http.Client{
86+
Transport: tlsprint.NewChromeTransport(),
87+
Timeout: 15 * time.Second,
88+
}
7789
}
7890
}
7991
if opts.UserAgent == "" {

cmd/auth.go

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -154,21 +154,28 @@ func runAuthImport(cmd *cobra.Command, _ []string) error {
154154
return err
155155
}
156156
client := api.New(api.Options{
157-
Endpoints: eps,
158-
Throttle: api.NewThrottle(api.Defaults{}),
159-
Session: api.Session{Cookies: cookies},
160-
Verbose: verbose,
157+
Endpoints: eps,
158+
Throttle: api.NewThrottle(api.Defaults{}),
159+
Session: api.Session{Cookies: cookies},
160+
Verbose: verbose,
161+
UseBrowser: !useHTTP,
161162
})
162163

163-
// Tight timeout for the import-time liveness check. We want
164-
// snappy failure on a stuck connection, not 90 seconds of silence.
165-
ctx, cancel := context.WithTimeout(cmd.Context(), 20*time.Second)
164+
// Generous timeout for the verify call. The browser path adds
165+
// a one-time ~1-2s Chrome startup cost on first run; the http
166+
// path is much faster but we still want to give X a few seconds
167+
// to respond. 60s is comfortable for both.
168+
ctx, cancel := context.WithTimeout(cmd.Context(), 60*time.Second)
166169
defer cancel()
167170

168-
cmdutil.Info("verifying session against X (UserByRestId via twid)...")
171+
if !useHTTP {
172+
cmdutil.Info("verifying session via headless Chrome (--http to use the http+utls path)...")
173+
} else {
174+
cmdutil.Info("verifying session via http+utls (UserByRestId)...")
175+
}
169176
user, err := client.VerifyCredentials(ctx)
170177
if err != nil {
171-
return fmt.Errorf("verify session: %w (your cookies may be stale or X is unreachable)", err)
178+
return fmt.Errorf("verify session: %w (your cookies may be stale or Chrome could not start)", err)
172179
}
173180

174181
path, err := sessionFilePath()

cmd/root.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ var (
2020
endpointsFile string
2121
jsonOut bool
2222
verbose bool
23+
useHTTP bool
2324
)
2425

2526
var rootCmd = &cobra.Command{
@@ -53,6 +54,7 @@ func init() {
5354
rootCmd.PersistentFlags().StringVar(&endpointsFile, "endpoints", "", "endpoints.yaml (default $HOME/.config/x-cli/endpoints.yaml, then ./endpoints.yaml)")
5455
rootCmd.PersistentFlags().BoolVar(&jsonOut, "json", false, "emit machine-readable JSON on stdout")
5556
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose logging to stderr")
57+
rootCmd.PersistentFlags().BoolVar(&useHTTP, "http", false, "force the http+utls transport (skip the headless Chrome path; needed when Chrome is not installed)")
5658
}
5759

5860
func initConfig() {
@@ -165,7 +167,8 @@ func newClient(ctx context.Context) (*api.Client, error) {
165167
Name: sess.Name,
166168
},
167169
},
168-
Verbose: verbose,
170+
Verbose: verbose,
171+
UseBrowser: !useHTTP,
169172
})
170173
return client, nil
171174
}

go.mod

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/thevibeworks/x-cli
22

3-
go 1.24.0
3+
go 1.26
44

55
require (
66
github.com/browserutils/kooky v0.2.9
@@ -15,10 +15,17 @@ require (
1515
require (
1616
github.com/andybalholm/brotli v1.0.6 // indirect
1717
github.com/browserutils/ese v0.0.0-20260314233042-37b6a03a93ce // indirect
18+
github.com/chromedp/cdproto v0.0.0-20260405000525-47a8ff65b46a // indirect
19+
github.com/chromedp/chromedp v0.15.1 // indirect
20+
github.com/chromedp/sysutil v1.1.0 // indirect
1821
github.com/danieljoos/wincred v1.2.3 // indirect
1922
github.com/fsnotify/fsnotify v1.8.0 // indirect
23+
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
2024
github.com/go-sqlite/sqlite3 v0.0.0-20180313105335-53dd8e640ee7 // indirect
2125
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
26+
github.com/gobwas/httphead v0.1.0 // indirect
27+
github.com/gobwas/pool v0.2.1 // indirect
28+
github.com/gobwas/ws v1.4.0 // indirect
2229
github.com/godbus/dbus/v5 v5.2.2 // indirect
2330
github.com/gonuts/binary v0.2.0 // indirect
2431
github.com/inconshreveable/mousetrap v1.1.0 // indirect
@@ -36,7 +43,7 @@ require (
3643
go.uber.org/multierr v1.9.0 // indirect
3744
golang.org/x/crypto v0.48.0 // indirect
3845
golang.org/x/net v0.50.0 // indirect
39-
golang.org/x/sys v0.41.0 // indirect
46+
golang.org/x/sys v0.42.0 // indirect
4047
golang.org/x/text v0.34.0 // indirect
4148
gopkg.in/ini.v1 v1.67.1 // indirect
4249
)

go.sum

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ github.com/browserutils/ese v0.0.0-20260314233042-37b6a03a93ce h1:xb/LXUukZgVLMR
44
github.com/browserutils/ese v0.0.0-20260314233042-37b6a03a93ce/go.mod h1:Rj9TJxm7cExxJmdec83sr8cjvyF3raBsszTFviSo/6U=
55
github.com/browserutils/kooky v0.2.9 h1:EY1evaA/nbrFXt6F5PFUXDxoxzQNxC4IZdsVEf+wtiQ=
66
github.com/browserutils/kooky v0.2.9/go.mod h1:AUQgZqn9D8gj42rtsca99WMNSs250TsE4yt1LQKActA=
7+
github.com/chromedp/cdproto v0.0.0-20260405000525-47a8ff65b46a h1:Kk4P1W58eAf+OUGtx51cM7CcJokJuBEmOxxwPdHFH4Q=
8+
github.com/chromedp/cdproto v0.0.0-20260405000525-47a8ff65b46a/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
9+
github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ=
10+
github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4=
11+
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
12+
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
713
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
814
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
915
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
@@ -14,10 +20,18 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
1420
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
1521
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
1622
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
23+
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
24+
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
1725
github.com/go-sqlite/sqlite3 v0.0.0-20180313105335-53dd8e640ee7 h1:ow5vK9Q/DSKkxbEIJHBST6g+buBDwdaDIyk1dGGwpQo=
1826
github.com/go-sqlite/sqlite3 v0.0.0-20180313105335-53dd8e640ee7/go.mod h1:JxSQ+SvsjFb+p8Y+bn+GhTkiMfKVGBD0fq43ms2xw04=
1927
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
2028
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
29+
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
30+
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
31+
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
32+
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
33+
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
34+
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
2135
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
2236
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
2337
github.com/gonuts/binary v0.2.0 h1:caITwMWAoQWlL0RNvv2lTU/AHqAJlVuu6nZmNgfbKW4=
@@ -84,8 +98,11 @@ golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
8498
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
8599
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
86100
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
101+
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
87102
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
88103
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
104+
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
105+
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
89106
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
90107
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
91108
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=

0 commit comments

Comments
 (0)