@@ -9,12 +9,14 @@ import (
99 "io"
1010 "math/big"
1111 "net/http"
12+ neturl "net/url"
1213 "os"
1314 "path/filepath"
1415 "regexp"
1516 "strconv"
1617 "strings"
1718
19+ "github.com/kernel/cli/pkg/auth"
1820 "github.com/kernel/cli/pkg/util"
1921 "github.com/kernel/kernel-go-sdk"
2022 "github.com/kernel/kernel-go-sdk/option"
@@ -225,6 +227,7 @@ type BrowsersCmd struct {
225227 logs BrowserLogService
226228 computer BrowserComputerService
227229 playwright BrowserPlaywrightService
230+ client * kernel.Client
228231}
229232
230233type BrowsersListInput struct {
@@ -2518,6 +2521,29 @@ func init() {
25182521 browsersCreateCmd .Flags ().String ("pool-id" , "" , "Browser pool ID to acquire from (mutually exclusive with --pool-name)" )
25192522 browsersCreateCmd .Flags ().String ("pool-name" , "" , "Browser pool name to acquire from (mutually exclusive with --pool-id)" )
25202523
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.` ,
2531+ Args : cobra .ExactArgs (2 ),
2532+ RunE : runBrowsersCurl ,
2533+ }
2534+ curlCmd .Flags ().StringP ("request" , "X" , "" , "HTTP method (default: GET)" )
2535+ curlCmd .Flags ().StringArrayP ("header" , "H" , nil , "HTTP header (repeatable, \" Key: Value\" format)" )
2536+ curlCmd .Flags ().StringP ("data" , "d" , "" , "Request body" )
2537+ curlCmd .Flags ().String ("data-file" , "" , "Read request body from file" )
2538+ curlCmd .Flags ().Int ("timeout" , 30000 , "Request timeout in milliseconds" )
2539+ curlCmd .Flags ().String ("encoding" , "" , "Response encoding: utf8 or base64" )
2540+ curlCmd .Flags ().StringP ("output" , "o" , "" , "Write response body to file (uses streaming mode)" )
2541+ curlCmd .Flags ().Bool ("raw" , false , "Use streaming mode (no JSON wrapper)" )
2542+ curlCmd .Flags ().BoolP ("include" , "i" , false , "Include response headers in output" )
2543+ curlCmd .Flags ().BoolP ("silent" , "s" , false , "Suppress progress output" )
2544+ curlCmd .Flags ().Bool ("json" , false , "Output full JSON response" )
2545+ browsersCmd .AddCommand (curlCmd )
2546+
25212547 // no flags for view; it takes a single positional argument
25222548}
25232549
@@ -3255,6 +3281,266 @@ func runBrowsersComputerWriteClipboard(cmd *cobra.Command, args []string) error
32553281 return b .ComputerWriteClipboard (cmd .Context (), BrowsersComputerWriteClipboardInput {Identifier : args [0 ], Text : text })
32563282}
32573283
3284+ // Curl
3285+
3286+ type BrowsersCurlInput struct {
3287+ Identifier string
3288+ URL string
3289+ Method string
3290+ Headers []string
3291+ Data string
3292+ DataFile string
3293+ TimeoutMs int
3294+ Encoding string
3295+ OutputFile string
3296+ Raw bool
3297+ Include bool
3298+ Silent bool
3299+ JSON bool
3300+ }
3301+
3302+ // browserCurlRequest is the JSON body for POST /browsers/{id}/curl.
3303+ type browserCurlRequest struct {
3304+ URL string `json:"url"`
3305+ Method string `json:"method,omitempty"`
3306+ Headers map [string ][]string `json:"headers,omitempty"`
3307+ Body string `json:"body,omitempty"`
3308+ TimeoutMs int `json:"timeout_ms,omitempty"`
3309+ ResponseEncoding string `json:"response_encoding,omitempty"`
3310+ }
3311+
3312+ // browserCurlResponse is the JSON response from POST /browsers/{id}/curl.
3313+ type browserCurlResponse struct {
3314+ Status int `json:"status"`
3315+ Headers map [string ][]string `json:"headers"`
3316+ Body string `json:"body"`
3317+ DurationMs float64 `json:"duration_ms"`
3318+ }
3319+
3320+ func parseCurlHeaders (raw []string ) map [string ][]string {
3321+ if len (raw ) == 0 {
3322+ return nil
3323+ }
3324+ headers := make (map [string ][]string )
3325+ for _ , h := range raw {
3326+ k , v , ok := strings .Cut (h , ":" )
3327+ if ! ok {
3328+ continue
3329+ }
3330+ key := strings .TrimSpace (k )
3331+ val := strings .TrimSpace (v )
3332+ headers [key ] = append (headers [key ], val )
3333+ }
3334+ return headers
3335+ }
3336+
3337+ func (b BrowsersCmd ) Curl (ctx context.Context , in BrowsersCurlInput ) error {
3338+ if in .Raw || in .OutputFile != "" {
3339+ return b .curlRaw (ctx , in )
3340+ }
3341+
3342+ // Read body from file if specified
3343+ body := in .Data
3344+ if in .DataFile != "" {
3345+ data , err := os .ReadFile (in .DataFile )
3346+ if err != nil {
3347+ return fmt .Errorf ("reading data file: %w" , err )
3348+ }
3349+ body = string (data )
3350+ }
3351+
3352+ reqBody := browserCurlRequest {
3353+ URL : in .URL ,
3354+ }
3355+ if in .Method != "" {
3356+ reqBody .Method = in .Method
3357+ }
3358+ if in .TimeoutMs != 0 {
3359+ reqBody .TimeoutMs = in .TimeoutMs
3360+ }
3361+ if in .Encoding != "" {
3362+ reqBody .ResponseEncoding = in .Encoding
3363+ }
3364+ if body != "" {
3365+ reqBody .Body = body
3366+ }
3367+ reqBody .Headers = parseCurlHeaders (in .Headers )
3368+
3369+ client := b .client
3370+ path := fmt .Sprintf ("/browsers/%s/curl" , in .Identifier )
3371+
3372+ var result browserCurlResponse
3373+ err := client .Post (ctx , path , reqBody , & result )
3374+ if err != nil {
3375+ return util.CleanedUpSdkError {Err : err }
3376+ }
3377+
3378+ if in .JSON {
3379+ enc := json .NewEncoder (os .Stdout )
3380+ enc .SetIndent ("" , " " )
3381+ return enc .Encode (result )
3382+ }
3383+
3384+ if in .Include {
3385+ fmt .Fprintf (os .Stdout , "HTTP %d\n " , result .Status )
3386+ for k , vals := range result .Headers {
3387+ for _ , v := range vals {
3388+ fmt .Fprintf (os .Stdout , "%s: %s\n " , k , v )
3389+ }
3390+ }
3391+ fmt .Fprintln (os .Stdout )
3392+ }
3393+
3394+ fmt .Fprint (os .Stdout , result .Body )
3395+ return nil
3396+ }
3397+
3398+ func (b BrowsersCmd ) curlRaw (ctx context.Context , in BrowsersCurlInput ) error {
3399+ // Build the full URL for /curl/raw
3400+ baseURL := util .GetBaseURL ()
3401+ method := in .Method
3402+ if method == "" {
3403+ method = "GET"
3404+ }
3405+
3406+ params := neturl.Values {}
3407+ params .Set ("url" , in .URL )
3408+ params .Set ("method" , method )
3409+ params .Set ("timeout_ms" , fmt .Sprintf ("%d" , in .TimeoutMs ))
3410+
3411+ // Add custom headers as query params
3412+ for _ , h := range in .Headers {
3413+ k , v , ok := strings .Cut (h , ":" )
3414+ if ! ok {
3415+ continue
3416+ }
3417+ params .Add ("header" , strings .TrimSpace (k )+ ": " + strings .TrimSpace (v ))
3418+ }
3419+
3420+ rawURL := fmt .Sprintf ("%s/browsers/%s/curl/raw?%s" ,
3421+ strings .TrimRight (baseURL , "/" ),
3422+ in .Identifier ,
3423+ params .Encode (),
3424+ )
3425+
3426+ // Read body from file if specified
3427+ body := in .Data
3428+ if in .DataFile != "" {
3429+ data , err := os .ReadFile (in .DataFile )
3430+ if err != nil {
3431+ return fmt .Errorf ("reading data file: %w" , err )
3432+ }
3433+ body = string (data )
3434+ }
3435+
3436+ var bodyReader io.Reader
3437+ if body != "" {
3438+ bodyReader = strings .NewReader (body )
3439+ }
3440+
3441+ req , err := http .NewRequestWithContext (ctx , method , rawURL , bodyReader )
3442+ if err != nil {
3443+ return fmt .Errorf ("creating request: %w" , err )
3444+ }
3445+
3446+ // Get auth token from the SDK client's options
3447+ token := b .getAuthToken ()
3448+ if token != "" {
3449+ req .Header .Set ("Authorization" , "Bearer " + token )
3450+ }
3451+
3452+ resp , err := http .DefaultClient .Do (req )
3453+ if err != nil {
3454+ return fmt .Errorf ("request failed: %w" , err )
3455+ }
3456+ defer resp .Body .Close ()
3457+
3458+ // Check for API-level errors (auth failures, session not found, etc.)
3459+ if resp .StatusCode == http .StatusUnauthorized || resp .StatusCode == http .StatusForbidden {
3460+ respBody , _ := io .ReadAll (resp .Body )
3461+ return fmt .Errorf ("authentication error (%d): %s" , resp .StatusCode , strings .TrimSpace (string (respBody )))
3462+ }
3463+ if resp .StatusCode == http .StatusNotFound {
3464+ respBody , _ := io .ReadAll (resp .Body )
3465+ return fmt .Errorf ("not found (%d): %s" , resp .StatusCode , strings .TrimSpace (string (respBody )))
3466+ }
3467+
3468+ if in .OutputFile != "" {
3469+ f , err := os .Create (in .OutputFile )
3470+ if err != nil {
3471+ return fmt .Errorf ("creating output file: %w" , err )
3472+ }
3473+ defer f .Close ()
3474+ _ , err = io .Copy (f , resp .Body )
3475+ if err != nil {
3476+ return fmt .Errorf ("writing output file: %w" , err )
3477+ }
3478+ if ! in .Silent {
3479+ pterm .Success .Printf ("Saved response to %s\n " , in .OutputFile )
3480+ }
3481+ return nil
3482+ }
3483+
3484+ if in .Include {
3485+ fmt .Fprintf (os .Stdout , "HTTP %d\n " , resp .StatusCode )
3486+ for k , vals := range resp .Header {
3487+ for _ , v := range vals {
3488+ fmt .Fprintf (os .Stdout , "%s: %s\n " , k , v )
3489+ }
3490+ }
3491+ fmt .Fprintln (os .Stdout )
3492+ }
3493+
3494+ _ , err = io .Copy (os .Stdout , resp .Body )
3495+ return err
3496+ }
3497+
3498+ // getAuthToken retrieves the bearer token for raw HTTP requests.
3499+ func (b BrowsersCmd ) getAuthToken () string {
3500+ if apiKey := os .Getenv ("KERNEL_API_KEY" ); apiKey != "" {
3501+ return apiKey
3502+ }
3503+ tokens , err := auth .LoadTokens ()
3504+ if err != nil {
3505+ return ""
3506+ }
3507+ return tokens .AccessToken
3508+ }
3509+
3510+ func runBrowsersCurl (cmd * cobra.Command , args []string ) error {
3511+ client := getKernelClient (cmd )
3512+ svc := client .Browsers
3513+
3514+ method , _ := cmd .Flags ().GetString ("request" )
3515+ headers , _ := cmd .Flags ().GetStringArray ("header" )
3516+ data , _ := cmd .Flags ().GetString ("data" )
3517+ dataFile , _ := cmd .Flags ().GetString ("data-file" )
3518+ timeout , _ := cmd .Flags ().GetInt ("timeout" )
3519+ encoding , _ := cmd .Flags ().GetString ("encoding" )
3520+ outputFile , _ := cmd .Flags ().GetString ("output" )
3521+ raw , _ := cmd .Flags ().GetBool ("raw" )
3522+ include , _ := cmd .Flags ().GetBool ("include" )
3523+ silent , _ := cmd .Flags ().GetBool ("silent" )
3524+ jsonOutput , _ := cmd .Flags ().GetBool ("json" )
3525+
3526+ b := BrowsersCmd {browsers : & svc , client : & client }
3527+ return b .Curl (cmd .Context (), BrowsersCurlInput {
3528+ Identifier : args [0 ],
3529+ URL : args [1 ],
3530+ Method : method ,
3531+ Headers : headers ,
3532+ Data : data ,
3533+ DataFile : dataFile ,
3534+ TimeoutMs : timeout ,
3535+ Encoding : encoding ,
3536+ OutputFile : outputFile ,
3537+ Raw : raw ,
3538+ Include : include ,
3539+ Silent : silent ,
3540+ JSON : jsonOutput ,
3541+ })
3542+ }
3543+
32583544func truncateURL (url string , maxLen int ) string {
32593545 if len (url ) <= maxLen {
32603546 return url
0 commit comments