Skip to content
18 changes: 18 additions & 0 deletions browser.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/kernel/kernel-go-sdk/internal/apijson"
"github.com/kernel/kernel-go-sdk/internal/apiquery"
"github.com/kernel/kernel-go-sdk/internal/requestconfig"
"github.com/kernel/kernel-go-sdk/lib/browserrouting"
"github.com/kernel/kernel-go-sdk/option"
"github.com/kernel/kernel-go-sdk/packages/pagination"
"github.com/kernel/kernel-go-sdk/packages/param"
Expand Down Expand Up @@ -69,6 +70,9 @@ func (r *BrowserService) New(ctx context.Context, body BrowserNewParams, opts ..
opts = slices.Concat(r.Options, opts)
path := "browsers"
err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...)
if err == nil && res != nil {
storeBrowserRouteCache(opts, browserrouting.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL})
}
return res, err
}

Expand All @@ -81,6 +85,9 @@ func (r *BrowserService) Get(ctx context.Context, id string, query BrowserGetPar
}
path := fmt.Sprintf("browsers/%s", id)
err = requestconfig.ExecuteNewRequest(ctx, http.MethodGet, path, query, &res, opts...)
if err == nil && res != nil {
storeBrowserRouteCache(opts, browserrouting.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL})
}
return res, err
}

Expand All @@ -93,6 +100,9 @@ func (r *BrowserService) Update(ctx context.Context, id string, body BrowserUpda
}
path := fmt.Sprintf("browsers/%s", id)
err = requestconfig.ExecuteNewRequest(ctx, http.MethodPatch, path, body, &res, opts...)
if err == nil && res != nil {
storeBrowserRouteCache(opts, browserrouting.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL})
}
return res, err
}

Expand All @@ -112,6 +122,9 @@ func (r *BrowserService) List(ctx context.Context, query BrowserListParams, opts
return nil, err
}
res.SetPageConfig(cfg, raw)
for _, item := range res.Items {
storeBrowserRouteCache(opts, browserrouting.Ref{SessionID: item.SessionID, BaseURL: item.BaseURL, CdpWsURL: item.CdpWsURL})
}
return res, nil
}

Expand Down Expand Up @@ -157,6 +170,11 @@ func (r *BrowserService) DeleteByID(ctx context.Context, id string, opts ...opti
}
path := fmt.Sprintf("browsers/%s", id)
err = requestconfig.ExecuteNewRequest(ctx, http.MethodDelete, path, nil, nil, opts...)
if err == nil {
if cache := browserRouteCacheFromOptions(opts); cache != nil {
cache.Delete(id)
}
}
return err
}

Expand Down
34 changes: 34 additions & 0 deletions browser_http_client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package kernel

import (
"context"
"fmt"
"net/http"
"slices"

"github.com/kernel/kernel-go-sdk/internal/requestconfig"
"github.com/kernel/kernel-go-sdk/lib/browserrouting"
"github.com/kernel/kernel-go-sdk/option"
)

// HTTPClient returns an [http.Client] that performs HTTP requests through the
// browser VM's internal /curl/raw path using cached browser route data.
func (r *BrowserService) HTTPClient(id string, opts ...option.RequestOption) (*http.Client, error) {
opts = slices.Concat(r.Options, opts)
cache := browserRouteCacheFromOptions(opts)
if cache == nil {
return nil, fmt.Errorf("kernel: browser route cache is not configured")
}

route, ok := cache.Load(id)
if !ok {
return nil, fmt.Errorf("kernel: browser route cache does not contain session %s", id)
}

cfg, err := requestconfig.NewRequestConfig(context.Background(), http.MethodGet, "https://example.com", nil, nil, opts...)
if err != nil {
return browserrouting.NewHTTPClient(route.BaseURL, route.JWT, nil), nil
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Config error silently swallowed, dropping custom HTTP client

Medium Severity

When requestconfig.NewRequestConfig returns an error, the code silently returns a fallback HTTP client built with a nil underlying (which defaults to http.DefaultClient) and a nil error. This swallows the configuration error and silently drops any custom HTTP client the user configured via option.WithHTTPClient. Every other call site of NewRequestConfig in the codebase propagates the error. The user's custom transport, TLS, proxy, and timeout settings would be silently lost with no indication of failure.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d594f39. Configure here.


return browserrouting.NewHTTPClient(route.BaseURL, route.JWT, cfg.HTTPClient), nil
}
84 changes: 84 additions & 0 deletions browser_routing.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package kernel

import (
"github.com/kernel/kernel-go-sdk/internal/requestconfig"
"github.com/kernel/kernel-go-sdk/lib/browserrouting"
"github.com/kernel/kernel-go-sdk/option"
)

// BrowserRoutingConfig controls which browser subresources route directly to the browser VM.
type BrowserRoutingConfig struct {
Enabled bool
Subresources []string
}

type browserRoutingOption struct {
cache *browserrouting.RouteCache
config BrowserRoutingConfig
}

type browserRouteCacheOption struct {
cache *browserrouting.RouteCache
}

// WithBrowserRouting enables direct-to-VM routing for the configured browser subresources.
func WithBrowserRouting(config BrowserRoutingConfig) option.RequestOption {
return &browserRoutingOption{config: config}
}

func (o *browserRoutingOption) Apply(r *requestconfig.RequestConfig) error {
if !o.config.Enabled {
return nil
}
r.Middlewares = append(r.Middlewares, browserrouting.DirectVMRoutingMiddleware(o.cache, o.config.Subresources))
return nil
}

func (o *browserRoutingOption) browserRouteCache() *browserrouting.RouteCache {
return o.cache
}

func (o *browserRouteCacheOption) Apply(*requestconfig.RequestConfig) error {
return nil
}

func (o *browserRouteCacheOption) browserRouteCache() *browserrouting.RouteCache {
return o.cache
}

func withBrowserRouteCache(cache *browserrouting.RouteCache) option.RequestOption {
return &browserRouteCacheOption{cache: cache}
}

func browserRouteCacheFromOptions(opts []option.RequestOption) *browserrouting.RouteCache {
for _, opt := range opts {
if carrier, ok := opt.(interface{ browserRouteCache() *browserrouting.RouteCache }); ok {
if cache := carrier.browserRouteCache(); cache != nil {
return cache
}
}
}
return nil
}

func storeBrowserRouteCache(opts []option.RequestOption, refs ...browserrouting.Ref) {
cache := browserRouteCacheFromOptions(opts)
for _, ref := range refs {
route, ok := browserRouteFromRef(ref)
if cache != nil && ok {
cache.Store(route)
}
}
}

func browserRouteFromRef(ref browserrouting.Ref) (browserrouting.Route, bool) {
norm, err := ref.Normalize()
if err != nil {
return browserrouting.Route{}, false
}
return browserrouting.Route{
SessionID: norm.SessionID,
BaseURL: norm.BaseURL,
JWT: norm.JWT,
}, true
}
113 changes: 113 additions & 0 deletions browser_routing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package kernel

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/kernel/kernel-go-sdk/option"
)

func TestBrowserRoutingWarmsCacheAndRoutesAllowlistedSubresources(t *testing.T) {
var calls []struct {
Path string
Auth string
}
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls = append(calls, struct {
Path string
Auth string
}{Path: r.URL.Path + "?" + r.URL.RawQuery, Auth: r.Header.Get("Authorization")})

w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/browsers":
_ = json.NewEncoder(w).Encode(map[string]any{
"session_id": "sess-1",
"base_url": srv.URL + "/browser/kernel",
"cdp_ws_url": "wss://browser-session.test/browser/cdp?jwt=token-abc",
})
default:
_ = json.NewEncoder(w).Encode(map[string]any{
"duration_ms": 1,
"exit_code": 0,
"stderr_b64": "",
"stdout_b64": "",
})
}
}))
defer srv.Close()

client := NewClient(
option.WithBaseURL(srv.URL),
option.WithAPIKey("sk_test"),
option.WithHTTPClient(srv.Client()),
WithBrowserRouting(BrowserRoutingConfig{Enabled: true, Subresources: []string{"process"}}),
)

if _, err := client.Browsers.New(context.Background(), BrowserNewParams{}); err != nil {
t.Fatal(err)
}
if _, err := client.Browsers.Process.Exec(context.Background(), "sess-1", BrowserProcessExecParams{Command: "echo"}); err != nil {
t.Fatal(err)
}

if route, ok := client.BrowserRouteCache.Load("sess-1"); !ok || route.JWT != "token-abc" {
t.Fatalf("expected warmed browser route cache, got %#v ok=%v", route, ok)
}
if len(calls) != 2 {
t.Fatalf("expected 2 calls, got %d", len(calls))
}
if calls[1].Path != "/browser/kernel/process/exec?jwt=token-abc" {
t.Fatalf("expected direct VM path, got %q", calls[1].Path)
}
if calls[1].Auth != "" {
t.Fatalf("expected authorization header removed, got %q", calls[1].Auth)
}
}

func TestBrowserRoutingSkipsSubresourcesOutsideConfiguredAllowlist(t *testing.T) {
var paths []string
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/browsers":
_ = json.NewEncoder(w).Encode(map[string]any{
"session_id": "sess-1",
"base_url": srv.URL + "/browser/kernel",
"cdp_ws_url": "wss://browser-session.test/browser/cdp?jwt=token-abc",
})
default:
_ = json.NewEncoder(w).Encode(map[string]any{
"duration_ms": 1,
"exit_code": 0,
"stderr_b64": "",
"stdout_b64": "",
})
}
}))
defer srv.Close()

client := NewClient(
option.WithBaseURL(srv.URL),
option.WithAPIKey("sk_test"),
option.WithHTTPClient(srv.Client()),
WithBrowserRouting(BrowserRoutingConfig{Enabled: true, Subresources: []string{"computer"}}),
)

if _, err := client.Browsers.New(context.Background(), BrowserNewParams{}); err != nil {
t.Fatal(err)
}
if _, err := client.Browsers.Process.Exec(context.Background(), "sess-1", BrowserProcessExecParams{Command: "echo"}); err != nil {
t.Fatal(err)
}

if got := paths[len(paths)-1]; got != "/browsers/sess-1/process/exec" {
t.Fatalf("expected control-plane path, got %q", got)
}
}
77 changes: 77 additions & 0 deletions browser_session_httpclient_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package kernel

import (
"io"
"net/http"
"net/http/httptest"
"testing"

"github.com/kernel/kernel-go-sdk/lib/browserrouting"
"github.com/kernel/kernel-go-sdk/option"
)

func TestBrowserSessionHTTPClientRawCurl(t *testing.T) {
var sawRaw string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/browser/kernel/curl/raw" {
http.NotFound(w, r)
return
}
sawRaw = r.URL.RawQuery
w.Header().Set("Content-Type", "text/plain")
_, _ = w.Write([]byte("proxied"))
}))
defer srv.Close()

c := NewClient(
option.WithBaseURL("https://api.example/"),
option.WithAPIKey("sk"),
option.WithHTTPClient(srv.Client()),
)

storeBrowserRouteCache(c.Options, browserrouting.Ref{
SessionID: "sid",
BaseURL: srv.URL + "/browser/kernel",
CdpWsURL: "wss://x/browser/cdp?jwt=j1",
})

hc, err := c.Browsers.HTTPClient("sid")
if err != nil {
t.Fatal(err)
}
req, err := http.NewRequest(http.MethodGet, "https://httpbin.org/get", nil)
if err != nil {
t.Fatal(err)
}
res, err := hc.Do(req)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
if string(body) != "proxied" {
t.Fatalf("body %q", body)
}
if sawRaw == "" {
t.Fatal("expected raw query on curl/raw")
}
}

func TestBrowserSessionHTTPClientRequiresCachedRoute(t *testing.T) {
c := NewClient(
option.WithBaseURL("https://api.example/"),
option.WithAPIKey("sk"),
)

storeBrowserRouteCache(c.Options, browserrouting.Ref{
SessionID: "sid",
BaseURL: "https://browser-session.test/browser/kernel",
CdpWsURL: "wss://x/browser/cdp?jwt=j1",
})
c.BrowserRouteCache.Delete("sid")

_, err := c.Browsers.HTTPClient("sid")
if err == nil {
t.Fatal("expected cached route lookup failure")
}
}
Loading
Loading