Skip to content

Commit 55a2b93

Browse files
authored
feat: add kernel browsers curl command (#146)
## Summary Adds a curl-like `kernel browsers curl` command for making HTTP requests through a browser session's Chrome network stack. Requests inherit the browser's TLS fingerprint, cookies, proxy configuration, and browser headers, and responses are streamed directly through the SDK browser HTTP client wrapper around `/curl/raw`. `/curl/raw` follows redirects automatically in Chromium, so this command documents that behavior rather than exposing `-L`. ## Usage ```bash # Simple GET kernel browsers curl $ID https://example.com # POST JSON through the browser session kernel browsers curl $ID https://api.example.com \ -H "Content-Type: application/json" \ -d '{"key":"val"}' # Include response headers and write headers + body to a file kernel browsers curl $ID -i -o page.html https://example.com # Dump headers separately from the response body kernel browsers curl $ID -D headers.txt -o body.html https://example.com # Fetch headers only kernel browsers curl $ID -I https://example.com # Fail on HTTP errors without printing the response body kernel browsers curl $ID -f https://example.com/missing # Print curl-style metrics after completion kernel browsers curl $ID -o body.html -w 'status=%{http_code} bytes=%{size_download}\n' https://example.com ``` ## Flags | Flag | Description | |------|-------------| | `-X, --request` | HTTP method (default: GET; defaults to POST when `--data` is set) | | `-H, --header` | HTTP header, repeatable (`"Key: Value"` format) | | `-d, --data` | Request body | | `--data-file` | Read request body from file | | `--max-time` | Maximum time allowed for the request in seconds (default: 30) | | `-o, --output` | Write response body to file | | `-I, --head` | Fetch headers only | | `-i, --include` | Include response headers in output | | `-D, --dump-header` | Write received headers to file (use `-` for stdout) | | `-w, --write-out` | Output text after completion; supports `%{http_code}`, `%{response_code}`, `%{time_total}`, `%{size_download}` | | `-f, --fail` | Fail with no body output on HTTP errors | | `-s, --silent` | Suppress progress output | ## Implementation notes - Uses `github.com/kernel/kernel-go-sdk v0.51.0` and `Browsers.HTTPClient(sessionID)` to route requests through `/curl/raw`. - Always streams the proxied response directly; there is no JSON envelope mode. - Redirects are followed automatically by Chromium and currently cannot be disabled. - Root command error handling now writes CLI errors to stderr, which keeps `--fail` and timeout output closer to curl behavior. ## Test plan - [x] `make test && make build` - [x] Production smoke: create browser, run `kernel browsers curl <id> https://example.com`, delete browser - [x] Compare actual `curl` vs `kernel browsers curl` for body output, `-i`, `-o`, `-i -o`, and `-d` - [x] Compare `-I`, `-D`, `-w`, `-f`, and `--max-time` - [x] Verify `/curl/raw` follows redirects automatically while plain `curl` does not without `-L` <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Introduces new HTTP execution paths and modifies global error output behavior; while scoped to a new subcommand, it changes stdout/stderr expectations and request/response streaming semantics. > > **Overview** > Adds a new `kernel browsers curl <session-id> <url>` command that makes HTTP requests through the browser session’s SDK-provided `HTTPClient`, supporting curl-like flags for method/headers/body (incl. `--data-file`), timeouts, output-to-file, header inclusion/dumping, `--write-out` metrics, `--fail`, and `--silent`. > > Updates root error handling to support “silent” command errors (suppress diagnostics while still exiting non-zero) and to ensure CLI errors are written to fang’s error stream (stderr), keeping stdout clean for response bodies. Documentation and examples in `README.md` are updated, and new unit tests cover request shaping, output/header behaviors, write-out expansion, fail mode, and silent error wrapping. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit db97766. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent b95f794 commit 55a2b93

4 files changed

Lines changed: 598 additions & 2 deletions

File tree

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,20 @@ Commands with JSON output support:
217217
- `--output json`, `-o json` - Output JSON with liveViewUrl
218218
- `kernel browsers get <id>` - Get detailed browser session info
219219
- `--output json`, `-o json` - Output raw JSON object
220+
- `kernel browsers curl <id> <url>` - Make HTTP requests through a browser session's Chrome network stack
221+
- `-X, --request <method>` - HTTP method (default: GET; defaults to POST when `--data` is set)
222+
- `-H, --header <header>` - HTTP header, repeatable (`"Key: Value"` format)
223+
- `-d, --data <body>` - Request body
224+
- `--data-file <path>` - Read request body from file
225+
- `--max-time <seconds>` - Maximum time allowed for the request (default: 30)
226+
- `-o, --output <path>` - Write response body to file
227+
- `-I, --head` - Fetch headers only
228+
- `-i, --include` - Include response headers in output
229+
- `-D, --dump-header <path>` - Write received headers to file (use `-` for stdout)
230+
- `-w, --write-out <format>` - Output text after completion; supports `%{http_code}`, `%{response_code}`, `%{time_total}`, and `%{size_download}`
231+
- `-f, --fail` - Fail with no body output on HTTP errors
232+
- `-s, --silent` - Suppress progress output
233+
- _Note: redirects are followed automatically by Chromium._
220234

221235
### Browser Pools
222236

@@ -593,6 +607,21 @@ kernel browsers delete browser123
593607
# Get live view URL
594608
kernel browsers view browser123
595609

610+
# Make an HTTP request through the browser session
611+
kernel browsers curl browser123 https://example.com
612+
613+
# Include response headers and save the response to a file
614+
kernel browsers curl browser123 -i -o page.html https://example.com
615+
616+
# Send JSON and print curl-style status metrics
617+
kernel browsers curl browser123 https://api.example.com \
618+
-H "Content-Type: application/json" \
619+
-d '{"key":"value"}' \
620+
-w 'status=%{http_code} bytes=%{size_download}\n'
621+
622+
# Fail on HTTP errors without printing the response body
623+
kernel browsers curl browser123 -f https://example.com/missing
624+
596625
# Stream browser logs
597626
kernel browsers logs stream my-browser --source supervisor --follow --supervisor-process chromium
598627

cmd/browsers.go

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
32593561
func truncateURL(url string, maxLen int) string {
32603562
if !table.IsStdoutTTY() {
32613563
return url

0 commit comments

Comments
 (0)