Skip to content
Merged
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
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 nil, err
}
Comment thread
cursor[bot] marked this conversation as resolved.

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

import (
"os"
"strings"

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

const browserRoutingSubresourcesEnv = "KERNEL_BROWSER_ROUTING_SUBRESOURCES"

type browserRoutingOption struct {
cache *browserrouting.RouteCache
subresources []string
}

type browserRouteCacheOption struct {
cache *browserrouting.RouteCache
}

func withBrowserRoutingSubresources(cache *browserrouting.RouteCache, subresources []string) option.RequestOption {
return &browserRoutingOption{cache: cache, subresources: subresources}
}

func (o *browserRoutingOption) Apply(r *requestconfig.RequestConfig) error {
r.Middlewares = append(r.Middlewares, browserrouting.DirectVMRoutingMiddleware(o.cache, o.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
}

func browserRoutingSubresourcesFromEnv() []string {
raw, ok := os.LookupEnv(browserRoutingSubresourcesEnv)
if !ok {
return []string{"curl"}
}
if strings.TrimSpace(raw) == "" {
return []string{}
}

subresources := make([]string, 0)
for _, part := range strings.Split(raw, ",") {
if trimmed := strings.TrimSpace(part); trimmed != "" {
subresources = append(subresources, trimmed)
}
}
return subresources
}
150 changes: 150 additions & 0 deletions browser_routing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
package kernel

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

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

func TestBrowserRoutingWarmsCacheAndRoutesAllowlistedSubresources(t *testing.T) {
t.Setenv(browserRoutingSubresourcesEnv, "process")

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()),
)

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) {
t.Setenv(browserRoutingSubresourcesEnv, "computer")

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()),
)

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)
}
}

func TestBrowserRoutingSubresourcesFromEnvDefaultsToCurl(t *testing.T) {
original, ok := os.LookupEnv(browserRoutingSubresourcesEnv)
if err := os.Unsetenv(browserRoutingSubresourcesEnv); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if !ok {
_ = os.Unsetenv(browserRoutingSubresourcesEnv)
return
}
_ = os.Setenv(browserRoutingSubresourcesEnv, original)
})
if got := browserRoutingSubresourcesFromEnv(); len(got) != 1 || got[0] != "curl" {
t.Fatalf("expected default subresources [curl], got %#v", got)
}

t.Setenv(browserRoutingSubresourcesEnv, "")
if got := browserRoutingSubresourcesFromEnv(); len(got) != 0 {
t.Fatalf("expected empty env to disable routing, got %#v", got)
}

t.Setenv(browserRoutingSubresourcesEnv, "curl, process")
got := browserRoutingSubresourcesFromEnv()
want := []string{"curl", "process"}
if len(got) != len(want) {
t.Fatalf("expected %v, got %#v", want, got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("expected %v, got %#v", want, got)
}
}
}
103 changes: 103 additions & 0 deletions browser_session_httpclient_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package kernel

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

"github.com/kernel/kernel-go-sdk/internal/requestconfig"
"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")
}
}

func TestBrowserHTTPClientPropagatesRequestConfigError(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",
})

wantErr := errors.New("request config failed")
hc, err := c.Browsers.HTTPClient("sid", requestconfig.RequestOptionFunc(func(*requestconfig.RequestConfig) error {
return wantErr
}))
if !errors.Is(err, wantErr) {
t.Fatalf("expected error %q, got %v", wantErr, err)
}
if hc != nil {
t.Fatal("expected nil client when request config fails")
}
}
Loading
Loading