@@ -14,6 +14,7 @@ import (
1414 "regexp"
1515 "strconv"
1616 "strings"
17+ "time"
1718
1819 "github.com/kernel/cli/pkg/table"
1920 "github.com/kernel/cli/pkg/util"
@@ -36,6 +37,7 @@ type BrowsersService interface {
3637 Update (ctx context.Context , id string , body kernel.BrowserUpdateParams , opts ... option.RequestOption ) (res * kernel.BrowserUpdateResponse , err error )
3738 Delete (ctx context.Context , body kernel.BrowserDeleteParams , opts ... option.RequestOption ) (err error )
3839 DeleteByID (ctx context.Context , id string , opts ... option.RequestOption ) (err error )
40+ HTTPClient (id string , opts ... option.RequestOption ) (* http.Client , error )
3941 LoadExtensions (ctx context.Context , id string , body kernel.BrowserLoadExtensionsParams , opts ... option.RequestOption ) (err error )
4042}
4143
@@ -2519,6 +2521,31 @@ func init() {
25192521 browsersCreateCmd .Flags ().String ("pool-id" , "" , "Browser pool ID to acquire from (mutually exclusive with --pool-name)" )
25202522 browsersCreateCmd .Flags ().String ("pool-name" , "" , "Browser pool name to acquire from (mutually exclusive with --pool-id)" )
25212523
2524+ // curl
2525+ curlCmd := & cobra.Command {
2526+ Use : "curl <session-id> <url>" ,
2527+ Short : "Make HTTP requests through a browser session" ,
2528+ Long : `Execute HTTP requests through Chrome's network stack, inheriting the
2529+ browser's TLS fingerprint, cookies, proxy configuration, and headers.
2530+ Works like curl but requests go through the browser session. Redirects are
2531+ followed automatically by Chromium.` ,
2532+ Args : cobra .ExactArgs (2 ),
2533+ RunE : runBrowsersCurl ,
2534+ }
2535+ curlCmd .Flags ().StringP ("request" , "X" , "" , "HTTP method (default: GET)" )
2536+ curlCmd .Flags ().StringArrayP ("header" , "H" , nil , "HTTP header (repeatable, \" Key: Value\" format)" )
2537+ curlCmd .Flags ().StringP ("data" , "d" , "" , "Request body" )
2538+ curlCmd .Flags ().String ("data-file" , "" , "Read request body from file" )
2539+ curlCmd .Flags ().Float64 ("max-time" , 30 , "Maximum time allowed for the request in seconds" )
2540+ curlCmd .Flags ().StringP ("output" , "o" , "" , "Write response body to file" )
2541+ curlCmd .Flags ().BoolP ("head" , "I" , false , "Fetch headers only" )
2542+ curlCmd .Flags ().BoolP ("include" , "i" , false , "Include response headers in output" )
2543+ curlCmd .Flags ().StringP ("dump-header" , "D" , "" , "Write received headers to file (use - for stdout)" )
2544+ curlCmd .Flags ().StringP ("write-out" , "w" , "" , "Output text after completion; supports %{http_code}, %{response_code}, %{time_total}, %{size_download}" )
2545+ curlCmd .Flags ().BoolP ("fail" , "f" , false , "Fail with no body output on HTTP errors" )
2546+ curlCmd .Flags ().BoolP ("silent" , "s" , false , "Suppress progress output" )
2547+ browsersCmd .AddCommand (curlCmd )
2548+
25222549 // no flags for view; it takes a single positional argument
25232550}
25242551
@@ -3256,6 +3283,281 @@ func runBrowsersComputerWriteClipboard(cmd *cobra.Command, args []string) error
32563283 return b .ComputerWriteClipboard (cmd .Context (), BrowsersComputerWriteClipboardInput {Identifier : args [0 ], Text : text })
32573284}
32583285
3286+ // Curl
3287+
3288+ type BrowsersCurlInput struct {
3289+ Identifier string
3290+ URL string
3291+ Method string
3292+ Headers []string
3293+ Data string
3294+ DataFile string
3295+ MaxTime time.Duration
3296+ OutputFile string
3297+ Head bool
3298+ Include bool
3299+ DumpHeader string
3300+ WriteOut string
3301+ Fail bool
3302+ Silent bool
3303+ }
3304+
3305+ type silentCurlError struct {
3306+ err error
3307+ }
3308+
3309+ func (e silentCurlError ) Error () string {
3310+ return e .err .Error ()
3311+ }
3312+
3313+ func (e silentCurlError ) Unwrap () error {
3314+ return e .err
3315+ }
3316+
3317+ func (e silentCurlError ) Silent () bool {
3318+ return true
3319+ }
3320+
3321+ func curlError (in BrowsersCurlInput , err error ) error {
3322+ if err == nil {
3323+ return nil
3324+ }
3325+ if in .Silent {
3326+ return silentCurlError {err : err }
3327+ }
3328+ return err
3329+ }
3330+
3331+ func parseCurlHeaders (raw []string ) http.Header {
3332+ if len (raw ) == 0 {
3333+ return nil
3334+ }
3335+ headers := make (http.Header )
3336+ for _ , h := range raw {
3337+ k , v , ok := strings .Cut (h , ":" )
3338+ if ! ok {
3339+ continue
3340+ }
3341+ headers .Add (strings .TrimSpace (k ), strings .TrimSpace (v ))
3342+ }
3343+ return headers
3344+ }
3345+
3346+ func readCurlBody (in BrowsersCurlInput ) (string , error ) {
3347+ if in .DataFile == "" {
3348+ return in .Data , nil
3349+ }
3350+
3351+ data , err := os .ReadFile (in .DataFile )
3352+ if err != nil {
3353+ return "" , fmt .Errorf ("reading data file: %w" , err )
3354+ }
3355+ return string (data ), nil
3356+ }
3357+
3358+ func hasCurlHeader (headers http.Header , name string ) bool {
3359+ for key := range headers {
3360+ if strings .EqualFold (key , name ) {
3361+ return true
3362+ }
3363+ }
3364+ return false
3365+ }
3366+
3367+ type curlWriteOutStats struct {
3368+ statusCode int
3369+ timeTotal time.Duration
3370+ sizeDownload int64
3371+ }
3372+
3373+ func expandCurlWriteOut (format string , stats curlWriteOutStats ) string {
3374+ replacer := strings .NewReplacer (
3375+ `\\` , "\\ " ,
3376+ `\n` , "\n " ,
3377+ `\r` , "\r " ,
3378+ `\t` , "\t " ,
3379+ `%%` , "%" ,
3380+ "%{http_code}" , fmt .Sprintf ("%03d" , stats .statusCode ),
3381+ "%{response_code}" , fmt .Sprintf ("%03d" , stats .statusCode ),
3382+ "%{time_total}" , fmt .Sprintf ("%.6f" , stats .timeTotal .Seconds ()),
3383+ "%{size_download}" , fmt .Sprintf ("%d" , stats .sizeDownload ),
3384+ )
3385+ return replacer .Replace (format )
3386+ }
3387+
3388+ func openCurlOutputFile (path string ) (io.Writer , func () error , error ) {
3389+ if path == "" {
3390+ return nil , nil , nil
3391+ }
3392+ if path == "-" {
3393+ return os .Stdout , func () error { return nil }, nil
3394+ }
3395+ f , err := os .Create (path )
3396+ if err != nil {
3397+ return nil , nil , err
3398+ }
3399+ return f , f .Close , nil
3400+ }
3401+
3402+ func (b BrowsersCmd ) Curl (ctx context.Context , in BrowsersCurlInput ) error {
3403+ body , err := readCurlBody (in )
3404+ if err != nil {
3405+ return curlError (in , err )
3406+ }
3407+
3408+ method := in .Method
3409+ if method == "" {
3410+ method = "GET"
3411+ if body != "" {
3412+ method = "POST"
3413+ }
3414+ if in .Head {
3415+ method = "HEAD"
3416+ }
3417+ }
3418+ include := in .Include || in .Head
3419+
3420+ var bodyReader io.Reader
3421+ if body != "" {
3422+ bodyReader = strings .NewReader (body )
3423+ }
3424+
3425+ // Seed the SDK's browser route cache before constructing the raw curl client.
3426+ if _ , err := b .browsers .Get (ctx , in .Identifier , kernel.BrowserGetParams {}); err != nil {
3427+ return curlError (in , util.CleanedUpSdkError {Err : err })
3428+ }
3429+
3430+ httpClient , err := b .browsers .HTTPClient (in .Identifier )
3431+ if err != nil {
3432+ return curlError (in , util.CleanedUpSdkError {Err : err })
3433+ }
3434+ if in .MaxTime > 0 {
3435+ httpClient .Timeout = in .MaxTime
3436+ }
3437+
3438+ req , err := http .NewRequestWithContext (ctx , method , in .URL , bodyReader )
3439+ if err != nil {
3440+ return curlError (in , fmt .Errorf ("creating request: %w" , err ))
3441+ }
3442+ headers := parseCurlHeaders (in .Headers )
3443+ for key , values := range headers {
3444+ for _ , value := range values {
3445+ req .Header .Add (key , value )
3446+ }
3447+ }
3448+ if body != "" && ! hasCurlHeader (headers , "Content-Type" ) {
3449+ req .Header .Set ("Content-Type" , "application/x-www-form-urlencoded" )
3450+ }
3451+
3452+ start := time .Now ()
3453+ resp , err := httpClient .Do (req )
3454+ if err != nil {
3455+ return curlError (in , fmt .Errorf ("request failed: %w" , err ))
3456+ }
3457+ defer resp .Body .Close ()
3458+
3459+ var writer io.Writer = os .Stdout
3460+ var outputFile * os.File
3461+ if in .OutputFile != "" {
3462+ f , err := os .Create (in .OutputFile )
3463+ if err != nil {
3464+ return curlError (in , fmt .Errorf ("creating output file: %w" , err ))
3465+ }
3466+ outputFile = f
3467+ defer outputFile .Close ()
3468+ writer = outputFile
3469+ }
3470+
3471+ if in .DumpHeader != "" {
3472+ headerWriter , closeHeaderWriter , err := openCurlOutputFile (in .DumpHeader )
3473+ if err != nil {
3474+ return curlError (in , fmt .Errorf ("creating dump header file: %w" , err ))
3475+ }
3476+ defer closeHeaderWriter ()
3477+ writeCurlResponseHeaders (headerWriter , resp )
3478+ }
3479+
3480+ if in .Fail && resp .StatusCode >= 400 {
3481+ if in .WriteOut != "" {
3482+ fmt .Fprint (os .Stdout , expandCurlWriteOut (in .WriteOut , curlWriteOutStats {
3483+ statusCode : resp .StatusCode ,
3484+ timeTotal : time .Since (start ),
3485+ }))
3486+ }
3487+ return curlError (in , fmt .Errorf ("HTTP error: %s" , resp .Status ))
3488+ }
3489+
3490+ if include {
3491+ writeCurlResponseHeaders (writer , resp )
3492+ }
3493+
3494+ var sizeDownload int64
3495+ if ! in .Head {
3496+ sizeDownload , err = io .Copy (writer , resp .Body )
3497+ if err != nil {
3498+ if in .OutputFile != "" {
3499+ return curlError (in , fmt .Errorf ("writing output file: %w" , err ))
3500+ }
3501+ return curlError (in , err )
3502+ }
3503+ }
3504+
3505+ if in .WriteOut != "" {
3506+ fmt .Fprint (os .Stdout , expandCurlWriteOut (in .WriteOut , curlWriteOutStats {
3507+ statusCode : resp .StatusCode ,
3508+ timeTotal : time .Since (start ),
3509+ sizeDownload : sizeDownload ,
3510+ }))
3511+ }
3512+ return nil
3513+ }
3514+
3515+ func writeCurlResponseHeaders (w io.Writer , resp * http.Response ) {
3516+ fmt .Fprintf (w , "%s %s\r \n " , resp .Proto , resp .Status )
3517+ for key , vals := range resp .Header {
3518+ for _ , value := range vals {
3519+ fmt .Fprintf (w , "%s: %s\r \n " , key , value )
3520+ }
3521+ }
3522+ fmt .Fprint (w , "\r \n " )
3523+ }
3524+
3525+ func runBrowsersCurl (cmd * cobra.Command , args []string ) error {
3526+ client := getKernelClient (cmd )
3527+ svc := client .Browsers
3528+
3529+ method , _ := cmd .Flags ().GetString ("request" )
3530+ headers , _ := cmd .Flags ().GetStringArray ("header" )
3531+ data , _ := cmd .Flags ().GetString ("data" )
3532+ dataFile , _ := cmd .Flags ().GetString ("data-file" )
3533+ maxTime , _ := cmd .Flags ().GetFloat64 ("max-time" )
3534+ outputFile , _ := cmd .Flags ().GetString ("output" )
3535+ head , _ := cmd .Flags ().GetBool ("head" )
3536+ include , _ := cmd .Flags ().GetBool ("include" )
3537+ dumpHeader , _ := cmd .Flags ().GetString ("dump-header" )
3538+ writeOut , _ := cmd .Flags ().GetString ("write-out" )
3539+ fail , _ := cmd .Flags ().GetBool ("fail" )
3540+ silent , _ := cmd .Flags ().GetBool ("silent" )
3541+
3542+ b := BrowsersCmd {browsers : & svc }
3543+ return b .Curl (cmd .Context (), BrowsersCurlInput {
3544+ Identifier : args [0 ],
3545+ URL : args [1 ],
3546+ Method : method ,
3547+ Headers : headers ,
3548+ Data : data ,
3549+ DataFile : dataFile ,
3550+ MaxTime : time .Duration (maxTime * float64 (time .Second )),
3551+ OutputFile : outputFile ,
3552+ Head : head ,
3553+ Include : include ,
3554+ DumpHeader : dumpHeader ,
3555+ WriteOut : writeOut ,
3556+ Fail : fail ,
3557+ Silent : silent ,
3558+ })
3559+ }
3560+
32593561func truncateURL (url string , maxLen int ) string {
32603562 if ! table .IsStdoutTTY () {
32613563 return url
0 commit comments