-
Notifications
You must be signed in to change notification settings - Fork 8
feat: add kernel browsers curl command
#146
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
54ba9dd
466ed67
7f8cece
d112191
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
|
||
| "github.com/pterm/pterm" | ||
| "github.com/spf13/cobra" | ||
| "github.com/spf13/pflag" | ||
|
|
@@ -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) | ||
| } | ||
|
|
||
|
|
@@ -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 | ||
| } | ||
|
|
||
|
|
@@ -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))) | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Raw mode error checks intercept proxied target responsesMedium Severity In 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 | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Raw mode uses expired OAuth tokens without refreshingHigh Severity
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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Development SDK replace directive left in go.modHigh Severity The 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 | ||
|
|
||


Uh oh!
There was an error while loading. Please reload this page.