Skip to content

Commit 54ba9dd

Browse files
rgarciaclaude
andcommitted
feat: add kernel browsers curl command
Adds a CLI command that makes HTTP requests through a browser session's Chrome network stack, inheriting TLS fingerprint, cookies, proxy config, and headers. Mirrors real curl UX with -X, -H, -d flags. Two modes: - Structured (default): POST /browsers/{id}/curl with JSON response - Raw (--raw or -o): GET /browsers/{id}/curl/raw for streaming Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6d54518 commit 54ba9dd

1 file changed

Lines changed: 286 additions & 0 deletions

File tree

cmd/browsers.go

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

230233
type 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+
32583544
func truncateURL(url string, maxLen int) string {
32593545
if len(url) <= maxLen {
32603546
return url

0 commit comments

Comments
 (0)