Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
265 changes: 265 additions & 0 deletions cmd/browsers.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,20 @@
"io"
"math/big"
"net/http"
neturl "net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"

"github.com/kernel/cli/pkg/auth"
"github.com/kernel/cli/pkg/util"
"github.com/kernel/kernel-go-sdk"
"github.com/kernel/kernel-go-sdk/option"
"github.com/kernel/kernel-go-sdk/packages/pagination"
"github.com/kernel/kernel-go-sdk/packages/ssestream"
"github.com/kernel/kernel-go-sdk/shared"

Check failure on line 25 in cmd/browsers.go

View workflow job for this annotation

GitHub Actions / test

github.com/stainless-sdks/kernel-go@v0.0.0-20260410014529-98c58b154bb9: invalid version: git ls-remote -q https://github.com/stainless-sdks/kernel-go in /home/runner/go/pkg/mod/cache/vcs/3563256f7b3335aeb62510446cbd6f99af044800b0672efa9e83fb3f98fc0986: exit status 128:
"github.com/pterm/pterm"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
Expand All @@ -35,6 +37,7 @@
Update(ctx context.Context, id string, body kernel.BrowserUpdateParams, opts ...option.RequestOption) (res *kernel.BrowserUpdateResponse, err error)
Delete(ctx context.Context, body kernel.BrowserDeleteParams, opts ...option.RequestOption) (err error)
DeleteByID(ctx context.Context, id string, opts ...option.RequestOption) (err error)
Curl(ctx context.Context, id string, body kernel.BrowserCurlParams, opts ...option.RequestOption) (res *kernel.BrowserCurlResponse, err error)
LoadExtensions(ctx context.Context, id string, body kernel.BrowserLoadExtensionsParams, opts ...option.RequestOption) (err error)
}

Expand Down Expand Up @@ -2518,6 +2521,29 @@
browsersCreateCmd.Flags().String("pool-id", "", "Browser pool ID to acquire from (mutually exclusive with --pool-name)")
browsersCreateCmd.Flags().String("pool-name", "", "Browser pool name to acquire from (mutually exclusive with --pool-id)")

// curl
curlCmd := &cobra.Command{
Use: "curl <session-id> <url>",
Short: "Make HTTP requests through a browser session",
Long: `Execute HTTP requests through Chrome's network stack, inheriting the
browser's TLS fingerprint, cookies, proxy configuration, and headers.
Works like curl but requests go through the browser session.`,
Args: cobra.ExactArgs(2),
RunE: runBrowsersCurl,
}
curlCmd.Flags().StringP("request", "X", "", "HTTP method (default: GET)")
curlCmd.Flags().StringArrayP("header", "H", nil, "HTTP header (repeatable, \"Key: Value\" format)")
curlCmd.Flags().StringP("data", "d", "", "Request body")
curlCmd.Flags().String("data-file", "", "Read request body from file")
curlCmd.Flags().Int("timeout", 30000, "Request timeout in milliseconds")
curlCmd.Flags().String("encoding", "", "Response encoding: utf8 or base64")
curlCmd.Flags().StringP("output", "o", "", "Write response body to file (uses streaming mode)")
curlCmd.Flags().Bool("raw", false, "Use streaming mode (no JSON wrapper)")
curlCmd.Flags().BoolP("include", "i", false, "Include response headers in output")
curlCmd.Flags().BoolP("silent", "s", false, "Suppress progress output")
curlCmd.Flags().Bool("json", false, "Output full JSON response")
browsersCmd.AddCommand(curlCmd)

// no flags for view; it takes a single positional argument
}

Expand Down Expand Up @@ -3255,6 +3281,245 @@
return b.ComputerWriteClipboard(cmd.Context(), BrowsersComputerWriteClipboardInput{Identifier: args[0], Text: text})
}

// Curl

type BrowsersCurlInput struct {
Identifier string
URL string
Method string
Headers []string
Data string
DataFile string
TimeoutMs int
Encoding string
OutputFile string
Raw bool
Include bool
Silent bool
JSON bool
}

func parseCurlHeaders(raw []string) map[string]string {
if len(raw) == 0 {
return nil
}
headers := make(map[string]string)
for _, h := range raw {
k, v, ok := strings.Cut(h, ":")
if !ok {
continue
}
headers[strings.TrimSpace(k)] = strings.TrimSpace(v)
}
return headers
}

func (b BrowsersCmd) Curl(ctx context.Context, in BrowsersCurlInput) error {
if in.Raw || in.OutputFile != "" {
return b.curlRaw(ctx, in)
}

// Read body from file if specified
body := in.Data
if in.DataFile != "" {
data, err := os.ReadFile(in.DataFile)
if err != nil {
return fmt.Errorf("reading data file: %w", err)
}
body = string(data)
}

params := kernel.BrowserCurlParams{
URL: in.URL,
Headers: parseCurlHeaders(in.Headers),
}
if in.Method != "" {
params.Method = kernel.BrowserCurlParamsMethod(in.Method)
}
if in.TimeoutMs != 0 {
params.TimeoutMs = kernel.Opt(int64(in.TimeoutMs))
}
if in.Encoding != "" {
params.ResponseEncoding = kernel.BrowserCurlParamsResponseEncoding(in.Encoding)
}
if body != "" {
params.Body = kernel.Opt(body)
}

result, err := b.browsers.Curl(ctx, in.Identifier, params)
if err != nil {
return util.CleanedUpSdkError{Err: err}
}

if in.JSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(result)
}

if in.Include {
fmt.Fprintf(os.Stdout, "HTTP %d\n", result.Status)
for k, vals := range result.Headers {
for _, v := range vals {
fmt.Fprintf(os.Stdout, "%s: %s\n", k, v)
}
}
fmt.Fprintln(os.Stdout)
}

fmt.Fprint(os.Stdout, result.Body)
return nil
}

func (b BrowsersCmd) curlRaw(ctx context.Context, in BrowsersCurlInput) error {
// Build the full URL for /curl/raw
baseURL := util.GetBaseURL()
method := in.Method
if method == "" {
method = "GET"
}

params := neturl.Values{}
params.Set("url", in.URL)
params.Set("method", method)
params.Set("timeout_ms", fmt.Sprintf("%d", in.TimeoutMs))
if in.Encoding != "" {
params.Set("response_encoding", in.Encoding)
}

// Add custom headers as query params
for _, h := range in.Headers {
k, v, ok := strings.Cut(h, ":")
if !ok {
continue
}
params.Add("header", strings.TrimSpace(k)+": "+strings.TrimSpace(v))
}

rawURL := fmt.Sprintf("%s/browsers/%s/curl/raw?%s",
strings.TrimRight(baseURL, "/"),
in.Identifier,
params.Encode(),
)

// Read body from file if specified
body := in.Data
if in.DataFile != "" {
data, err := os.ReadFile(in.DataFile)
if err != nil {
return fmt.Errorf("reading data file: %w", err)
}
body = string(data)
}

var bodyReader io.Reader
if body != "" {
bodyReader = strings.NewReader(body)
}

req, err := http.NewRequestWithContext(ctx, method, rawURL, bodyReader)
if err != nil {
return fmt.Errorf("creating request: %w", err)
}

// Get auth token from the SDK client's options
token := b.getAuthToken()
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}

resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()

// Check for API-level errors (auth failures, session not found, etc.)
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("authentication error (%d): %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
if resp.StatusCode == http.StatusNotFound {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("not found (%d): %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Raw mode error checks intercept proxied target responses

Medium Severity

In curlRaw, the status code checks for 401/403/404 cannot distinguish between API-level errors (e.g., invalid session) and legitimate proxied responses from the target URL. If the target URL itself returns a 401, 403, or 404, the CLI will incorrectly report it as an authentication or "not found" error instead of streaming the proxied response body to the user. This breaks the core "raw mode" contract of transparently piping the proxied response.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d112191. Configure here.


if in.Include {
fmt.Fprintf(os.Stdout, "HTTP %d\n", resp.StatusCode)
for k, vals := range resp.Header {
for _, v := range vals {
fmt.Fprintf(os.Stdout, "%s: %s\n", k, v)
}
}
fmt.Fprintln(os.Stdout)
}

if in.OutputFile != "" {
f, err := os.Create(in.OutputFile)
if err != nil {
return fmt.Errorf("creating output file: %w", err)
}
defer f.Close()
_, err = io.Copy(f, resp.Body)
if err != nil {
return fmt.Errorf("writing output file: %w", err)
}
if !in.Silent {
pterm.Success.Printf("Saved response to %s\n", in.OutputFile)
}
return nil
}

_, err = io.Copy(os.Stdout, resp.Body)
return err
}

// getAuthToken retrieves the bearer token for raw HTTP requests.
func (b BrowsersCmd) getAuthToken() string {
if apiKey := os.Getenv("KERNEL_API_KEY"); apiKey != "" {
return apiKey
}
tokens, err := auth.LoadTokens()
if err != nil {
return ""
}
return tokens.AccessToken
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Raw mode uses expired OAuth tokens without refreshing

High Severity

getAuthToken loads OAuth tokens via auth.LoadTokens() and returns tokens.AccessToken without checking whether the token is expired or attempting a refresh. The main auth path in auth.GetAuthenticatedClient checks tokens.IsExpired() and calls RefreshTokens when needed. In raw/streaming mode, users with expired (but refreshable) OAuth tokens will get authentication errors even though they're properly logged in, while JSON mode works fine via the SDK client.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d112191. Configure here.


func runBrowsersCurl(cmd *cobra.Command, args []string) error {
client := getKernelClient(cmd)
svc := client.Browsers

method, _ := cmd.Flags().GetString("request")
headers, _ := cmd.Flags().GetStringArray("header")
data, _ := cmd.Flags().GetString("data")
dataFile, _ := cmd.Flags().GetString("data-file")
timeout, _ := cmd.Flags().GetInt("timeout")
encoding, _ := cmd.Flags().GetString("encoding")
outputFile, _ := cmd.Flags().GetString("output")
raw, _ := cmd.Flags().GetBool("raw")
include, _ := cmd.Flags().GetBool("include")
silent, _ := cmd.Flags().GetBool("silent")
jsonOutput, _ := cmd.Flags().GetBool("json")

b := BrowsersCmd{browsers: &svc}
return b.Curl(cmd.Context(), BrowsersCurlInput{
Identifier: args[0],
URL: args[1],
Method: method,
Headers: headers,
Data: data,
DataFile: dataFile,
TimeoutMs: timeout,
Encoding: encoding,
OutputFile: outputFile,
Raw: raw,
Include: include,
Silent: silent,
JSON: jsonOutput,
})
}

func truncateURL(url string, maxLen int) string {
if len(url) <= maxLen {
return url
Expand Down
8 changes: 8 additions & 0 deletions cmd/browsers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ type FakeBrowsersService struct {
UpdateFunc func(ctx context.Context, id string, body kernel.BrowserUpdateParams, opts ...option.RequestOption) (*kernel.BrowserUpdateResponse, error)
DeleteFunc func(ctx context.Context, body kernel.BrowserDeleteParams, opts ...option.RequestOption) error
DeleteByIDFunc func(ctx context.Context, id string, opts ...option.RequestOption) error
CurlFunc func(ctx context.Context, id string, body kernel.BrowserCurlParams, opts ...option.RequestOption) (*kernel.BrowserCurlResponse, error)
LoadExtensionsFunc func(ctx context.Context, id string, body kernel.BrowserLoadExtensionsParams, opts ...option.RequestOption) error
}

Expand Down Expand Up @@ -105,6 +106,13 @@ func (f *FakeBrowsersService) DeleteByID(ctx context.Context, id string, opts ..
return nil
}

func (f *FakeBrowsersService) Curl(ctx context.Context, id string, body kernel.BrowserCurlParams, opts ...option.RequestOption) (*kernel.BrowserCurlResponse, error) {
if f.CurlFunc != nil {
return f.CurlFunc(ctx, id, body, opts...)
}
return &kernel.BrowserCurlResponse{}, nil
}

func (f *FakeBrowsersService) LoadExtensions(ctx context.Context, id string, body kernel.BrowserLoadExtensionsParams, opts ...option.RequestOption) error {
if f.LoadExtensionsFunc != nil {
return f.LoadExtensionsFunc(ctx, id, body, opts...)
Expand Down
2 changes: 1 addition & 1 deletion cmd/proxies/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func (p ProxyCmd) Check(ctx context.Context, in ProxyCheckInput) error {
pterm.Info.Printf("Running health check on proxy %s...\n", in.ID)
}

proxy, err := p.proxies.Check(ctx, in.ID)
proxy, err := p.proxies.Check(ctx, in.ID, kernel.ProxyCheckParams{})
if err != nil {
return util.CleanedUpSdkError{Err: err}
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/proxies/check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ func TestProxyCheck_ShowsBypassHosts(t *testing.T) {
buf := captureOutput(t)

fake := &FakeProxyService{
CheckFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ProxyCheckResponse, error) {
CheckFunc: func(ctx context.Context, id string, body kernel.ProxyCheckParams, opts ...option.RequestOption) (*kernel.ProxyCheckResponse, error) {
return &kernel.ProxyCheckResponse{
ID: id,
Name: "Proxy 1",
Expand Down
6 changes: 3 additions & 3 deletions cmd/proxies/common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ type FakeProxyService struct {
GetFunc func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ProxyGetResponse, error)
NewFunc func(ctx context.Context, body kernel.ProxyNewParams, opts ...option.RequestOption) (*kernel.ProxyNewResponse, error)
DeleteFunc func(ctx context.Context, id string, opts ...option.RequestOption) error
CheckFunc func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ProxyCheckResponse, error)
CheckFunc func(ctx context.Context, id string, body kernel.ProxyCheckParams, opts ...option.RequestOption) (*kernel.ProxyCheckResponse, error)
}

func (f *FakeProxyService) List(ctx context.Context, opts ...option.RequestOption) (*[]kernel.ProxyListResponse, error) {
Expand Down Expand Up @@ -73,9 +73,9 @@ func (f *FakeProxyService) Delete(ctx context.Context, id string, opts ...option
return nil
}

func (f *FakeProxyService) Check(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ProxyCheckResponse, error) {
func (f *FakeProxyService) Check(ctx context.Context, id string, body kernel.ProxyCheckParams, opts ...option.RequestOption) (*kernel.ProxyCheckResponse, error) {
if f.CheckFunc != nil {
return f.CheckFunc(ctx, id, opts...)
return f.CheckFunc(ctx, id, body, opts...)
}
return &kernel.ProxyCheckResponse{ID: id, Type: kernel.ProxyCheckResponseTypeDatacenter}, nil
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/proxies/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ type ProxyService interface {
Get(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.ProxyGetResponse, err error)
New(ctx context.Context, body kernel.ProxyNewParams, opts ...option.RequestOption) (res *kernel.ProxyNewResponse, err error)
Delete(ctx context.Context, id string, opts ...option.RequestOption) (err error)
Check(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.ProxyCheckResponse, err error)
Check(ctx context.Context, id string, body kernel.ProxyCheckParams, opts ...option.RequestOption) (res *kernel.ProxyCheckResponse, err error)
}

// ProxyCmd handles proxy operations independent of cobra.
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ require (
golang.org/x/sync v0.19.0
)

replace github.com/kernel/kernel-go-sdk => github.com/stainless-sdks/kernel-go v0.0.0-20260410014529-98c58b154bb9
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Development SDK replace directive left in go.mod

High Severity

The replace directive redirects github.com/kernel/kernel-go-sdk to a pre-release commit from the private github.com/stainless-sdks/kernel-go repository. This is development scaffolding generated by scripts/go-mod-replace-kernel.sh (as documented in DEVELOPMENT.md) and needs to be removed before merging. Shipping with this replace ties the build to an unreleased private SDK commit, breaking builds for anyone without access to that private repo.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7f8cece. Configure here.


require (
al.essio.dev/pkg/shellescape v1.5.1 // indirect
atomicgo.dev/cursor v0.2.0 // indirect
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,6 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kernel/kernel-go-sdk v0.44.1-0.20260323174449-5e56fc5d99a6 h1:RBlGCN3IagI0b+XrWsb5FOUV/18tniuL6oHFAb7MMHE=
github.com/kernel/kernel-go-sdk v0.44.1-0.20260323174449-5e56fc5d99a6/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
Expand Down Expand Up @@ -118,6 +116,8 @@ github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stainless-sdks/kernel-go v0.0.0-20260410014529-98c58b154bb9 h1:6swlSdr5UYmQbuM3HWM9+1FDMjVHeBqE+ZPUvkDr73I=
github.com/stainless-sdks/kernel-go v0.0.0-20260410014529-98c58b154bb9/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
Expand Down
Loading