diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 26b1ce2..2b2b4fa 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.50.0" + ".": "0.51.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index bed1034..fec95fa 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 112 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fkernel-686a9addd4f9356ca26ff3ff04e1a11466d77a412859829075566394922b715d.yml -openapi_spec_hash: 7a9e9c2023400d44bcbfb87b7ec07708 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel%2Fkernel-e14974fd90680e5745b35d8718a1ccce2181f6d17a6e0a1fd35fc5bca88795ae.yml +openapi_spec_hash: 1b3aa75f0ab48b122d514047f9c82873 config_hash: 08d55086449943a8fec212b870061a3f diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e2054a..4ffb4fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ # Changelog +## 0.51.0 (2026-04-24) + +Full Changelog: [v0.50.0...v0.51.0](https://github.com/kernel/kernel-go-sdk/compare/v0.50.0...v0.51.0) + +### Features + +* add browser-scoped session client ([ef994b4](https://github.com/kernel/kernel-go-sdk/commit/ef994b4d6189ee0cdb3ad6e002816e0d77c7db48)) +* Expose browser_session_id on managed auth connection ([e23defc](https://github.com/kernel/kernel-go-sdk/commit/e23defc4abad6e49ae854ead1d1a015a2ecad097)) +* generate browser-scoped service bindings ([b6a77bc](https://github.com/kernel/kernel-go-sdk/commit/b6a77bc656e6480e1bee0932fcc3b5beb40bcb10)) + + +### Bug Fixes + +* align browser-scoped routing with base_url ([64f7f81](https://github.com/kernel/kernel-go-sdk/commit/64f7f811a2f3d7c657ad56a1f34a8bea0206e585)) +* clean up go browser routing follow-ups ([77bda33](https://github.com/kernel/kernel-go-sdk/commit/77bda33323c48a60ab652bf42a39cc2bc1f909a0)) +* keep browser pool routing in sync with cache ([4f754d1](https://github.com/kernel/kernel-go-sdk/commit/4f754d13f38e5b545de8d84cb26de28f6f2f892a)) +* make browser route deletion win over sniffing ([0e44ff3](https://github.com/kernel/kernel-go-sdk/commit/0e44ff363c53b1225d48313be6376b2cda7b057f)) +* propagate browser HTTP client config errors ([1ec1358](https://github.com/kernel/kernel-go-sdk/commit/1ec1358150d20982fc582c634e68f42322d1fa4c)) +* remove old go browser scope package ([d594f39](https://github.com/kernel/kernel-go-sdk/commit/d594f39a352e2ad5639807f222bb16535cce72c6)) +* tighten browser route metadata parsing ([b293866](https://github.com/kernel/kernel-go-sdk/commit/b293866537bddb183ad7e8d014c0e2c298d715a9)) +* tighten browser-scoped helper surface ([3e3e33f](https://github.com/kernel/kernel-go-sdk/commit/3e3e33f031d8460662da8a368467a3dcc6a0ec3f)) + + +### Chores + +* **internal:** more robust bootstrap script ([c0430e3](https://github.com/kernel/kernel-go-sdk/commit/c0430e3e2952f10648ed0181edf85a69230e9279)) + + +### Documentation + +* add browser-scoped raw http example ([92dc96e](https://github.com/kernel/kernel-go-sdk/commit/92dc96e99e14eeb9eb6881115f330369bb3c7542)) + + +### Refactors + +* move browser route cache sync into middleware ([62078d3](https://github.com/kernel/kernel-go-sdk/commit/62078d3213dbe4ede9600968b48d5c8e7aa118e4)) +* move go browser routing rollout to env ([681e57f](https://github.com/kernel/kernel-go-sdk/commit/681e57f34b3199a4d720fb517da08b3bbd6bc0fb)) +* narrow browser-scoped helper exports ([0ac61ef](https://github.com/kernel/kernel-go-sdk/commit/0ac61ef0532e6682eed740765975775767e28a10)) +* remove browser session wrapper layer ([3452e53](https://github.com/kernel/kernel-go-sdk/commit/3452e537737576fdb645a4e5de0d9147e50cb33b)) +* rename browser routing subresources config ([909c377](https://github.com/kernel/kernel-go-sdk/commit/909c377e21adddbaede526ba2a356352652e947b)) +* simplify direct-to-vm route caching ([6bdf25f](https://github.com/kernel/kernel-go-sdk/commit/6bdf25f058d2e0bb552bd47ba4478222d97aa254)) + ## 0.50.0 (2026-04-20) Full Changelog: [v0.49.0...v0.50.0](https://github.com/kernel/kernel-go-sdk/compare/v0.49.0...v0.50.0) diff --git a/README.md b/README.md index 25c70d8..66d9e25 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Or to pin the version: ```sh -go get -u 'github.com/kernel/kernel-go-sdk@v0.50.0' +go get -u 'github.com/kernel/kernel-go-sdk@v0.51.0' ``` diff --git a/authconnection.go b/authconnection.go index 3b791fd..ab68ee8 100644 --- a/authconnection.go +++ b/authconnection.go @@ -246,6 +246,10 @@ type ManagedAuth struct { // - OneLogin: \*.onelogin.com // - Ping Identity: _.pingone.com, _.pingidentity.com AllowedDomains []string `json:"allowed_domains"` + // ID of the underlying browser session driving the current flow (present when flow + // in progress). Use this to inspect or terminate the browser session via the + // `/browsers` API. + BrowserSessionID string `json:"browser_session_id" api:"nullable"` // Whether automatic re-authentication is possible (has credential, selectors, and // login_url) CanReauth bool `json:"can_reauth"` @@ -320,6 +324,7 @@ type ManagedAuth struct { SaveCredentials respjson.Field Status respjson.Field AllowedDomains respjson.Field + BrowserSessionID respjson.Field CanReauth respjson.Field CanReauthReason respjson.Field Credential respjson.Field diff --git a/browser_http_client.go b/browser_http_client.go new file mode 100644 index 0000000..f2a19eb --- /dev/null +++ b/browser_http_client.go @@ -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 + } + + return browserrouting.NewHTTPClient(route.BaseURL, route.JWT, cfg.HTTPClient), nil +} diff --git a/browser_routing.go b/browser_routing.go new file mode 100644 index 0000000..5d49567 --- /dev/null +++ b/browser_routing.go @@ -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 +} diff --git a/browser_routing_test.go b/browser_routing_test.go new file mode 100644 index 0000000..e1dd48e --- /dev/null +++ b/browser_routing_test.go @@ -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) + } + } +} diff --git a/browser_session_httpclient_test.go b/browser_session_httpclient_test.go new file mode 100644 index 0000000..c12f204 --- /dev/null +++ b/browser_session_httpclient_test.go @@ -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") + } +} diff --git a/client.go b/client.go index 880a657..cf6e7d7 100644 --- a/client.go +++ b/client.go @@ -9,6 +9,7 @@ import ( "slices" "github.com/kernel/kernel-go-sdk/internal/requestconfig" + "github.com/kernel/kernel-go-sdk/lib/browserrouting" "github.com/kernel/kernel-go-sdk/option" ) @@ -17,6 +18,8 @@ import ( // directly, and instead use the [NewClient] method instead. type Client struct { Options []option.RequestOption + // BrowserRouteCache stores cached base_url and jwt data for direct-to-VM routing. + BrowserRouteCache *browserrouting.RouteCache // Create and manage app deployments and stream deployment events. Deployments DeploymentService // List applications and versions. @@ -61,8 +64,13 @@ func DefaultClientOptions() []option.RequestOption { // the services and requests that this client makes. func NewClient(opts ...option.RequestOption) (r Client) { opts = append(DefaultClientOptions(), opts...) + cache := browserrouting.NewRouteCache() + opts = append(opts, + withBrowserRouteCache(cache), + withBrowserRoutingSubresources(cache, browserRoutingSubresourcesFromEnv()), + ) - r = Client{Options: opts} + r = Client{Options: opts, BrowserRouteCache: cache} r.Deployments = NewDeploymentService(opts...) r.Apps = NewAppService(opts...) diff --git a/examples/browser-routing/main.go b/examples/browser-routing/main.go new file mode 100644 index 0000000..f7e0976 --- /dev/null +++ b/examples/browser-routing/main.go @@ -0,0 +1,42 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "os" + + kernel "github.com/kernel/kernel-go-sdk" +) + +func main() { + ctx := context.Background() + client := kernel.NewClient() + + browser, err := client.Browsers.New(ctx, kernel.BrowserNewParams{ + Headless: kernel.Bool(true), + }) + if err != nil { + panic(err) + } + defer func() { + _ = client.Browsers.DeleteByID(context.Background(), browser.SessionID) + }() + + httpClient, err := client.Browsers.HTTPClient(browser.SessionID) + if err != nil { + panic(err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.com", nil) + if err != nil { + panic(err) + } + + resp, err := httpClient.Do(req) + if err != nil { + panic(err) + } + defer resp.Body.Close() + + fmt.Fprintln(os.Stdout, "status", resp.StatusCode) +} diff --git a/internal/version.go b/internal/version.go index 9fc0bbd..5d9eddd 100644 --- a/internal/version.go +++ b/internal/version.go @@ -2,4 +2,4 @@ package internal -const PackageVersion = "0.50.0" // x-release-please-version +const PackageVersion = "0.51.0" // x-release-please-version diff --git a/lib/browserrouting/integration_test.go b/lib/browserrouting/integration_test.go new file mode 100644 index 0000000..a3a736f --- /dev/null +++ b/lib/browserrouting/integration_test.go @@ -0,0 +1,84 @@ +package browserrouting_test + +import ( + "context" + "io" + "net/http" + "os" + "strings" + "testing" + "time" + + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" +) + +func TestIntegrationBrowserRouting(t *testing.T) { + apiKey := strings.TrimSpace(os.Getenv("KERNEL_API_KEY")) + baseURL := strings.TrimSpace(os.Getenv("KERNEL_BASE_URL")) + if apiKey == "" || baseURL == "" { + t.Skip("set KERNEL_API_KEY and KERNEL_BASE_URL to run integration test") + } + t.Setenv("KERNEL_BROWSER_ROUTING_SUBRESOURCES", "process") + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + client := kernel.NewClient( + option.WithAPIKey(apiKey), + option.WithBaseURL(baseURL), + ) + + browser, err := client.Browsers.New(ctx, kernel.BrowserNewParams{ + Headless: kernel.Bool(true), + TimeoutSeconds: kernel.Int(60), + }) + if err != nil { + t.Fatalf("create browser: %v", err) + } + t.Cleanup(func() { + _ = client.Browsers.DeleteByID(context.Background(), browser.SessionID) + }) + + if browser.BaseURL == "" { + t.Fatal("expected browser base_url to be set") + } + + execRes, err := client.Browsers.Process.Exec(ctx, browser.SessionID, kernel.BrowserProcessExecParams{ + Command: "echo", + Args: []string{"hello"}, + }) + if err != nil { + t.Fatalf("process exec: %v", err) + } + if execRes.ExitCode != 0 { + t.Fatalf("expected process exit code 0, got %d", execRes.ExitCode) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.com", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + httpClient, err := client.Browsers.HTTPClient(browser.SessionID) + if err != nil { + t.Fatalf("browser http client: %v", err) + } + resp, err := httpClient.Do(req) + if err != nil { + t.Fatalf("browser http client: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 400 { + t.Fatalf("expected successful browser http response, got %d", resp.StatusCode) + } + if got := resp.Header.Get("Content-Type"); got == "" { + t.Fatal("expected raw browser response to include content type") + } + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read raw browser response: %v", err) + } + if len(body) == 0 { + t.Fatal("expected raw browser response body to be non-empty") + } +} diff --git a/lib/browserrouting/jwt.go b/lib/browserrouting/jwt.go new file mode 100644 index 0000000..98f676e --- /dev/null +++ b/lib/browserrouting/jwt.go @@ -0,0 +1,25 @@ +package browserrouting + +import ( + "fmt" + "net/url" + "strings" +) + +// jwtFromWebSocketURL extracts the session jwt query parameter from a browser +// websocket URL (for example cdp_ws_url or webdriver_ws_url). +func jwtFromWebSocketURL(wsURL string) (string, error) { + wsURL = strings.TrimSpace(wsURL) + if wsURL == "" { + return "", fmt.Errorf("browserrouting: empty websocket url") + } + u, err := url.Parse(wsURL) + if err != nil { + return "", fmt.Errorf("browserrouting: parse websocket url: %w", err) + } + jwt := u.Query().Get("jwt") + if jwt == "" { + return "", fmt.Errorf("browserrouting: missing jwt query parameter") + } + return jwt, nil +} diff --git a/lib/browserrouting/jwt_test.go b/lib/browserrouting/jwt_test.go new file mode 100644 index 0000000..c206784 --- /dev/null +++ b/lib/browserrouting/jwt_test.go @@ -0,0 +1,21 @@ +package browserrouting + +import "testing" + +func TestJWTFromWebSocketURL(t *testing.T) { + const u = "wss://browser.example/browser/cdp?jwt=abc123&foo=bar" + j, err := jwtFromWebSocketURL(u) + if err != nil { + t.Fatal(err) + } + if j != "abc123" { + t.Fatalf("jwt: got %q want abc123", j) + } +} + +func TestJWTFromWebSocketURLMissing(t *testing.T) { + _, err := jwtFromWebSocketURL("wss://browser.example/browser/cdp") + if err == nil { + t.Fatal("expected error") + } +} diff --git a/lib/browserrouting/rawcurl.go b/lib/browserrouting/rawcurl.go new file mode 100644 index 0000000..ce4d6eb --- /dev/null +++ b/lib/browserrouting/rawcurl.go @@ -0,0 +1,81 @@ +package browserrouting + +import ( + "fmt" + "net/http" + "net/url" + "strings" +) + +// RawCURLRoundTripper implements browser-egress HTTP by tunneling through the +// browser session base_url /curl/raw endpoint. +type RawCURLRoundTripper struct { + browserBaseURL string + jwt string + underlying http.RoundTripper +} + +var _ http.RoundTripper = (*RawCURLRoundTripper)(nil) + +// NewHTTPClient returns an [http.Client] that performs browser egress HTTP via +// the browser session base_url and internal /curl/raw path. +func NewHTTPClient(browserBaseURL, jwt string, underlying *http.Client) *http.Client { + if underlying == nil { + underlying = http.DefaultClient + } + rt := underlying.Transport + if rt == nil { + rt = http.DefaultTransport + } + return &http.Client{ + Transport: newRawCURLRoundTripper(browserBaseURL, jwt, rt), + Timeout: underlying.Timeout, + } +} + +// newRawCURLRoundTripper returns an [http.RoundTripper] that maps each request +// to {base_url}/curl/raw?jwt=...&url=, preserving method, +// headers, and body. The caller's request URL must be an absolute http(s) URL. +func newRawCURLRoundTripper(browserBaseURL, jwt string, underlying http.RoundTripper) *RawCURLRoundTripper { + if underlying == nil { + underlying = http.DefaultTransport + } + return &RawCURLRoundTripper{ + browserBaseURL: strings.TrimRight(strings.TrimSpace(browserBaseURL), "/"), + jwt: strings.TrimSpace(jwt), + underlying: underlying, + } +} + +// RoundTrip implements [http.RoundTripper]. +func (t *RawCURLRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if req.URL == nil || !req.URL.IsAbs() { + return nil, fmt.Errorf("browserrouting: raw curl requires an absolute request URL (got %q)", req.URL) + } + if req.URL.Scheme != "http" && req.URL.Scheme != "https" { + return nil, fmt.Errorf("browserrouting: raw curl requires http or https scheme") + } + if t.browserBaseURL == "" { + return nil, fmt.Errorf("browserrouting: browser base_url is required") + } + if t.jwt == "" { + return nil, fmt.Errorf("browserrouting: jwt is required for raw curl") + } + + target := req.URL.String() + proxyURL, err := url.Parse(t.browserBaseURL + "/curl/raw") + if err != nil { + return nil, err + } + q := url.Values{} + q.Set("jwt", t.jwt) + q.Set("url", target) + proxyURL.RawQuery = q.Encode() + + out := req.Clone(req.Context()) + out.URL = proxyURL + out.Host = proxyURL.Host + out.RequestURI = "" + + return t.underlying.RoundTrip(out) +} diff --git a/lib/browserrouting/rawcurl_test.go b/lib/browserrouting/rawcurl_test.go new file mode 100644 index 0000000..57982b2 --- /dev/null +++ b/lib/browserrouting/rawcurl_test.go @@ -0,0 +1,54 @@ +package browserrouting + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestRawCURLRoundTripper(t *testing.T) { + var sawPath, sawRaw string + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawPath = r.URL.Path + sawRaw = r.URL.RawQuery + w.WriteHeader(http.StatusTeapot) + _, _ = w.Write([]byte("ok")) + })) + defer up.Close() + + rt := newRawCURLRoundTripper(up.URL+"/browser/kernel", "jwt1", http.DefaultTransport) + + client := &http.Client{Transport: rt} + req, err := http.NewRequest(http.MethodGet, "https://example.org/foo?bar=1", nil) + if err != nil { + t.Fatal(err) + } + res, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusTeapot { + t.Fatalf("status: %d", res.StatusCode) + } + b, _ := io.ReadAll(res.Body) + if string(b) != "ok" { + t.Fatalf("body: %q", b) + } + if sawPath != "/browser/kernel/curl/raw" { + t.Fatalf("path: %s", sawPath) + } + if sawRaw == "" { + t.Fatal("expected query") + } +} + +func TestRawCURLRoundTripperRelativeURL(t *testing.T) { + rt := newRawCURLRoundTripper("https://x/browser/kernel", "j", http.DefaultTransport) + req, _ := http.NewRequest(http.MethodGet, "/relative", nil) + _, err := rt.RoundTrip(req) + if err == nil { + t.Fatal("expected error for relative url") + } +} diff --git a/lib/browserrouting/ref.go b/lib/browserrouting/ref.go new file mode 100644 index 0000000..d98e497 --- /dev/null +++ b/lib/browserrouting/ref.go @@ -0,0 +1,43 @@ +package browserrouting + +import ( + "fmt" + "strings" +) + +// Ref identifies a browser session for direct-to-VM HTTP calls. SessionID is +// reserved for future client-side routing; allowlisted requests rewrite the +// /browsers/{SessionID}/ path segment against the returned base_url. +type Ref struct { + SessionID string + BaseURL string + JWT string + CdpWsURL string +} + +// Normalize validates fields and fills JWT from CdpWsURL when JWT is empty. +func (r Ref) Normalize() (Ref, error) { + if strings.TrimSpace(r.BaseURL) == "" { + return Ref{}, fmt.Errorf("browserrouting: base_url is required") + } + if strings.TrimSpace(r.SessionID) == "" { + return Ref{}, fmt.Errorf("browserrouting: session_id is required") + } + out := r + out.BaseURL = strings.TrimSpace(r.BaseURL) + out.SessionID = strings.TrimSpace(r.SessionID) + out.JWT = strings.TrimSpace(r.JWT) + out.CdpWsURL = strings.TrimSpace(r.CdpWsURL) + if out.JWT == "" { + src := out.CdpWsURL + if src == "" { + return Ref{}, fmt.Errorf("browserrouting: jwt or cdp_ws_url is required") + } + jwt, err := jwtFromWebSocketURL(src) + if err != nil { + return Ref{}, err + } + out.JWT = jwt + } + return out, nil +} diff --git a/lib/browserrouting/route_cache.go b/lib/browserrouting/route_cache.go new file mode 100644 index 0000000..3631bd1 --- /dev/null +++ b/lib/browserrouting/route_cache.go @@ -0,0 +1,326 @@ +package browserrouting + +import ( + "bytes" + "encoding/json" + "io" + "mime" + "net/http" + "net/url" + "strings" + "sync" + + "github.com/kernel/kernel-go-sdk/option" +) + +// Route identifies a cached direct-to-VM transport for one browser session. +type Route struct { + SessionID string + BaseURL string + JWT string +} + +// RouteCache stores browser session transport details keyed by session_id. +type RouteCache struct { + mu sync.RWMutex + routes map[string]Route +} + +type cacheLifecycle struct { + sniffResponse bool + evictSessionID string +} + +// NewRouteCache returns an empty browser route cache. +func NewRouteCache() *RouteCache { + return &RouteCache{routes: map[string]Route{}} +} + +// Load returns the cached route for the given session id. +func (c *RouteCache) Load(sessionID string) (Route, bool) { + if c == nil { + return Route{}, false + } + c.mu.RLock() + defer c.mu.RUnlock() + route, ok := c.routes[sessionID] + return route, ok +} + +// Store normalizes and caches the given route. +func (c *RouteCache) Store(route Route) { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.routes[strings.TrimSpace(route.SessionID)] = Route{ + SessionID: strings.TrimSpace(route.SessionID), + BaseURL: strings.TrimSpace(route.BaseURL), + JWT: strings.TrimSpace(route.JWT), + } +} + +// Delete removes a cached route. +func (c *RouteCache) Delete(sessionID string) { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + delete(c.routes, sessionID) +} + +// DirectVMRoutingMiddleware rewrites allowlisted browser subresource requests to +// the browser VM using cached base_url and jwt data. +func DirectVMRoutingMiddleware(cache *RouteCache, subresources []string) option.Middleware { + allowed := map[string]struct{}{} + for _, subresource := range subresources { + if trimmed := strings.TrimSpace(subresource); trimmed != "" { + allowed[trimmed] = struct{}{} + } + } + + return func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { + lifecycle, err := parseCacheLifecycle(req) + if err != nil { + return nil, err + } + sessionID, subresource, suffix, ok := parseDirectVMPath(req.URL.Path) + if ok { + if _, ok := allowed[subresource]; ok { + route, ok := cache.Load(sessionID) + if ok { + base, err := url.Parse(route.BaseURL) + if err != nil { + return nil, err + } + req.Header.Del("Authorization") + if route.JWT != "" { + q := req.URL.Query() + if q.Get("jwt") == "" { + q.Set("jwt", route.JWT) + req.URL.RawQuery = q.Encode() + } + } + + req.URL.Scheme = base.Scheme + req.URL.Host = base.Host + req.Host = base.Host + req.URL.Path = joinURLPath(base.Path, subresource, suffix) + req.URL.RawPath = "" + } + } + } + + res, err := next(req) + if err != nil { + return res, err + } + return finalizeResponse(res, cache, lifecycle) + } +} + +func parseCacheLifecycle(req *http.Request) (cacheLifecycle, error) { + if req == nil || req.URL == nil { + return cacheLifecycle{}, nil + } + + parts := strings.Split(strings.Trim(req.URL.Path, "/"), "/") + for i := 0; i < len(parts); i++ { + switch parts[i] { + case "browsers": + return parseBrowserCacheLifecycle(req.Method, parts, i), nil + case "browser_pools": + return parseBrowserPoolCacheLifecycle(req, parts, i) + } + } + return cacheLifecycle{}, nil +} + +func parseBrowserCacheLifecycle(method string, parts []string, index int) cacheLifecycle { + switch len(parts) - index { + case 1: + return cacheLifecycle{sniffResponse: true} + case 2: + if parts[index+1] == "" { + return cacheLifecycle{} + } + lifecycle := cacheLifecycle{sniffResponse: true} + if method == http.MethodDelete { + lifecycle.evictSessionID = parts[index+1] + } + return lifecycle + default: + return cacheLifecycle{} + } +} + +func parseBrowserPoolCacheLifecycle(req *http.Request, parts []string, index int) (cacheLifecycle, error) { + switch len(parts) - index { + case 3: + if parts[index+1] == "" || parts[index+2] == "" { + return cacheLifecycle{}, nil + } + switch parts[index+2] { + case "acquire": + if req.Method != http.MethodPost { + return cacheLifecycle{}, nil + } + return cacheLifecycle{sniffResponse: true}, nil + case "release": + if req.Method != http.MethodPost { + return cacheLifecycle{}, nil + } + sessionID, err := parseBrowserPoolReleaseSessionID(req) + if err != nil { + return cacheLifecycle{}, err + } + return cacheLifecycle{evictSessionID: sessionID}, nil + default: + return cacheLifecycle{}, nil + } + default: + return cacheLifecycle{}, nil + } +} + +func parseBrowserPoolReleaseSessionID(req *http.Request) (string, error) { + if req == nil || req.Body == nil { + return "", nil + } + + body, err := io.ReadAll(req.Body) + if err != nil { + return "", err + } + _ = req.Body.Close() + req.Body = io.NopCloser(bytes.NewReader(body)) + req.ContentLength = int64(len(body)) + + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return "", nil + } + sessionID, _ := payload["session_id"].(string) + return strings.TrimSpace(sessionID), nil +} + +func finalizeResponse(res *http.Response, cache *RouteCache, lifecycle cacheLifecycle) (*http.Response, error) { + if lifecycle.sniffResponse { + if err := sniffAndPopulateCache(res, cache); err != nil { + return nil, err + } + } + if lifecycle.evictSessionID != "" && isSuccessfulResponse(res) { + cache.Delete(lifecycle.evictSessionID) + } + return res, nil +} + +func sniffAndPopulateCache(res *http.Response, cache *RouteCache) error { + if res == nil || res.Body == nil || cache == nil || !isSuccessfulResponse(res) || !isJSONResponse(res.Header) { + return nil + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return err + } + _ = res.Body.Close() + res.Body = io.NopCloser(bytes.NewReader(body)) + res.ContentLength = int64(len(body)) + + var value any + if err := json.Unmarshal(body, &value); err != nil { + return nil + } + populateCache(value, cache) + return nil +} + +func isSuccessfulResponse(res *http.Response) bool { + return res != nil && res.StatusCode >= 200 && res.StatusCode < 300 +} + +func isJSONResponse(header http.Header) bool { + mediaType, _, _ := mime.ParseMediaType(header.Get("Content-Type")) + return strings.Contains(mediaType, "application/json") || strings.HasSuffix(mediaType, "+json") +} + +func populateCache(value any, cache *RouteCache) { + if route, ok := routeFromValue(value); ok { + cache.Store(route) + } + + switch value := value.(type) { + case []any: + for _, item := range value { + populateCache(item, cache) + } + case map[string]any: + for _, child := range value { + if child != nil { + populateCache(child, cache) + } + } + } +} + +func routeFromValue(value any) (Route, bool) { + record, ok := value.(map[string]any) + if !ok { + return Route{}, false + } + + sessionID, _ := record["session_id"].(string) + baseURL, _ := record["base_url"].(string) + jwt, _ := record["jwt"].(string) + cdpWsURL, _ := record["cdp_ws_url"].(string) + ref, err := (Ref{ + SessionID: sessionID, + BaseURL: baseURL, + JWT: jwt, + CdpWsURL: cdpWsURL, + }).Normalize() + if err != nil { + return Route{}, false + } + + return Route{ + SessionID: ref.SessionID, + BaseURL: ref.BaseURL, + JWT: ref.JWT, + }, true +} + +func parseDirectVMPath(path string) (sessionID, subresource, suffix string, ok bool) { + parts := strings.Split(strings.Trim(path, "/"), "/") + for i := 0; i+2 < len(parts); i++ { + if parts[i] != "browsers" { + continue + } + sessionID = parts[i+1] + subresource = parts[i+2] + if sessionID == "" || subresource == "" { + return "", "", "", false + } + if i+3 < len(parts) { + suffix = "/" + strings.Join(parts[i+3:], "/") + } + return sessionID, subresource, suffix, true + } + return "", "", "", false +} + +func joinURLPath(basePath, subresource, suffix string) string { + base := "/" + strings.Trim(strings.TrimSpace(basePath), "/") + if base == "/" { + base = "" + } + out := base + "/" + strings.TrimPrefix(subresource, "/") + if suffix != "" { + out += "/" + strings.TrimPrefix(suffix, "/") + } + return out +} diff --git a/lib/browserrouting/route_cache_test.go b/lib/browserrouting/route_cache_test.go new file mode 100644 index 0000000..b489941 --- /dev/null +++ b/lib/browserrouting/route_cache_test.go @@ -0,0 +1,342 @@ +package browserrouting + +import ( + "io" + "net/http" + "net/url" + "strings" + "testing" +) + +func TestDirectVMRoutingMiddlewareClearsStaleRawPath(t *testing.T) { + cache := NewRouteCache() + cache.Store(Route{ + SessionID: "sess-1", + BaseURL: "https://browser.example/browser/kernel", + JWT: "jwt-123", + }) + + middleware := DirectVMRoutingMiddleware(cache, []string{"process"}) + reqURL, err := url.Parse("https://api.example/browsers/sess-1/process/exec") + if err != nil { + t.Fatal(err) + } + reqURL.RawPath = "/browsers/sess-1/process/%65xec" + + req := &http.Request{ + URL: reqURL, + Header: http.Header{"Authorization": []string{"Bearer sk_test"}}, + } + + var got *http.Request + _, err = middleware(req, func(next *http.Request) (*http.Response, error) { + got = next + return nil, nil + }) + if err != nil { + t.Fatal(err) + } + if got == nil { + t.Fatal("expected middleware to invoke next handler") + } + if got.URL.Path != "/browser/kernel/process/exec" { + t.Fatalf("expected rewritten path, got %q", got.URL.Path) + } + if got.URL.RawPath != "" { + t.Fatalf("expected stale raw path to be cleared, got %q", got.URL.RawPath) + } + if got.URL.Query().Get("jwt") != "jwt-123" { + t.Fatalf("expected jwt query param, got %q", got.URL.Query().Get("jwt")) + } + if got.Header.Get("Authorization") != "" { + t.Fatalf("expected authorization to be stripped, got %q", got.Header.Get("Authorization")) + } +} + +func TestParseCacheLifecycleRejectsBrowserSubresourcePaths(t *testing.T) { + cases := []string{ + "/browsers/sess-1/process/exec", + "/browsers/sess-1/browsers", + } + + for _, path := range cases { + reqURL, err := url.Parse("https://api.example" + path) + if err != nil { + t.Fatal(err) + } + lifecycle, err := parseCacheLifecycle(&http.Request{ + Method: http.MethodGet, + URL: reqURL, + Header: http.Header{}, + }) + if err != nil { + t.Fatal(err) + } + if lifecycle.sniffResponse || lifecycle.evictSessionID != "" { + t.Fatalf("expected subresource path %q to be ignored, got %#v", path, lifecycle) + } + } +} + +func TestDirectVMRoutingMiddlewarePopulatesCacheFromJSONResponse(t *testing.T) { + cache := NewRouteCache() + middleware := DirectVMRoutingMiddleware(cache, nil) + body := `{"session_id":"sess-1","base_url":"https://browser.example/browser/kernel","cdp_ws_url":"wss://browser.example/browser/cdp?jwt=jwt-123"}` + + reqURL, err := url.Parse("https://api.example/browsers") + if err != nil { + t.Fatal(err) + } + req := &http.Request{ + Method: http.MethodPost, + URL: reqURL, + Header: http.Header{}, + } + + res, err := middleware(req, func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json; charset=utf-8"}}, + Body: io.NopCloser(strings.NewReader(body)), + }, nil + }) + if err != nil { + t.Fatal(err) + } + + route, ok := cache.Load("sess-1") + if !ok { + t.Fatal("expected browser route cache to be warmed") + } + if route.BaseURL != "https://browser.example/browser/kernel" || route.JWT != "jwt-123" { + t.Fatalf("unexpected cached route %#v", route) + } + + gotBody, err := io.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if string(gotBody) != body { + t.Fatalf("expected response body to be preserved, got %q", gotBody) + } +} + +func TestDirectVMRoutingMiddlewarePopulatesCacheFromNestedJSONResponse(t *testing.T) { + cache := NewRouteCache() + middleware := DirectVMRoutingMiddleware(cache, nil) + body := `{"items":[{"session_id":"sess-2","base_url":"https://browser.example/browser/kernel","cdp_ws_url":"wss://browser.example/browser/cdp?jwt=jwt-234"}]}` + + reqURL, err := url.Parse("https://api.example/v1/browsers") + if err != nil { + t.Fatal(err) + } + req := &http.Request{ + Method: http.MethodGet, + URL: reqURL, + Header: http.Header{}, + } + + _, err = middleware(req, func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + }, nil + }) + if err != nil { + t.Fatal(err) + } + + route, ok := cache.Load("sess-2") + if !ok { + t.Fatal("expected nested browser metadata to warm the cache") + } + if route.JWT != "jwt-234" { + t.Fatalf("expected nested browser metadata jwt to be cached, got %q", route.JWT) + } +} + +func TestDirectVMRoutingMiddlewarePopulatesCacheFromBrowserPoolAcquireResponse(t *testing.T) { + cache := NewRouteCache() + middleware := DirectVMRoutingMiddleware(cache, nil) + body := `{"session_id":"sess-3","base_url":"https://browser.example/browser/kernel","cdp_ws_url":"wss://browser.example/browser/cdp?jwt=jwt-345"}` + + reqURL, err := url.Parse("https://api.example/browser_pools/pool-1/acquire") + if err != nil { + t.Fatal(err) + } + req := &http.Request{ + Method: http.MethodPost, + URL: reqURL, + Header: http.Header{}, + } + + _, err = middleware(req, func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + }, nil + }) + if err != nil { + t.Fatal(err) + } + + route, ok := cache.Load("sess-3") + if !ok { + t.Fatal("expected browser pool acquire response to warm the cache") + } + if route.JWT != "jwt-345" { + t.Fatalf("expected browser pool acquire jwt to be cached, got %q", route.JWT) + } +} + +func TestDirectVMRoutingMiddlewareSkipsCacheSniffingForNonBrowserMetadataPaths(t *testing.T) { + cache := NewRouteCache() + middleware := DirectVMRoutingMiddleware(cache, nil) + body := `{"session_id":"sess-1","base_url":"https://browser.example/browser/kernel","cdp_ws_url":"wss://browser.example/browser/cdp?jwt=jwt-123"}` + + reqURL, err := url.Parse("https://api.example/projects") + if err != nil { + t.Fatal(err) + } + req := &http.Request{ + Method: http.MethodGet, + URL: reqURL, + Header: http.Header{}, + } + + _, err = middleware(req, func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + }, nil + }) + if err != nil { + t.Fatal(err) + } + + if _, ok := cache.Load("sess-1"); ok { + t.Fatal("expected non-browser metadata response to skip cache warm-up") + } +} + +func TestDirectVMRoutingMiddlewareEvictsCacheOnSuccessfulBrowserDelete(t *testing.T) { + cache := NewRouteCache() + cache.Store(Route{ + SessionID: "sess-1", + BaseURL: "https://browser.example/browser/kernel", + JWT: "jwt-123", + }) + middleware := DirectVMRoutingMiddleware(cache, nil) + + reqURL, err := url.Parse("https://api.example/browsers/sess-1") + if err != nil { + t.Fatal(err) + } + req := &http.Request{ + Method: http.MethodDelete, + URL: reqURL, + Header: http.Header{}, + } + + _, err = middleware(req, func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusNoContent, + Header: http.Header{}, + Body: io.NopCloser(strings.NewReader("")), + }, nil + }) + if err != nil { + t.Fatal(err) + } + + if _, ok := cache.Load("sess-1"); ok { + t.Fatal("expected successful browser delete to evict cached route") + } +} + +func TestDirectVMRoutingMiddlewareEvictsCacheOnSuccessfulBrowserPoolRelease(t *testing.T) { + cache := NewRouteCache() + cache.Store(Route{ + SessionID: "sess-1", + BaseURL: "https://browser.example/browser/kernel", + JWT: "jwt-123", + }) + middleware := DirectVMRoutingMiddleware(cache, nil) + releaseBody := `{"session_id":"sess-1","reuse":false}` + + reqURL, err := url.Parse("https://api.example/browser_pools/pool-1/release") + if err != nil { + t.Fatal(err) + } + req := &http.Request{ + Method: http.MethodPost, + URL: reqURL, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(releaseBody)), + ContentLength: int64(len(releaseBody)), + } + + var gotRequestBody string + _, err = middleware(req, func(next *http.Request) (*http.Response, error) { + body, err := io.ReadAll(next.Body) + if err != nil { + return nil, err + } + gotRequestBody = string(body) + return &http.Response{ + StatusCode: http.StatusNoContent, + Header: http.Header{}, + Body: io.NopCloser(strings.NewReader("")), + }, nil + }) + if err != nil { + t.Fatal(err) + } + + if gotRequestBody != releaseBody { + t.Fatalf("expected release request body to be preserved, got %q", gotRequestBody) + } + if _, ok := cache.Load("sess-1"); ok { + t.Fatal("expected successful browser pool release to evict cached route") + } +} + +func TestDirectVMRoutingMiddlewareDeleteWinsOverJSONCacheSniff(t *testing.T) { + cache := NewRouteCache() + cache.Store(Route{ + SessionID: "sess-1", + BaseURL: "https://browser.example/browser/kernel", + JWT: "jwt-123", + }) + middleware := DirectVMRoutingMiddleware(cache, nil) + + reqURL, err := url.Parse("https://api.example/browsers/sess-1") + if err != nil { + t.Fatal(err) + } + req := &http.Request{ + Method: http.MethodDelete, + URL: reqURL, + Header: http.Header{}, + } + + _, err = middleware(req, func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader( + `{"session_id":"sess-1","base_url":"https://browser.example/browser/kernel","cdp_ws_url":"wss://browser.example/browser/cdp?jwt=jwt-123"}`, + )), + }, nil + }) + if err != nil { + t.Fatal(err) + } + + if _, ok := cache.Load("sess-1"); ok { + t.Fatal("expected delete response to leave cached route evicted") + } +} diff --git a/scripts/bootstrap b/scripts/bootstrap index 5ab3066..46547f1 100755 --- a/scripts/bootstrap +++ b/scripts/bootstrap @@ -4,7 +4,7 @@ set -e cd "$(dirname "$0")/.." -if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "$SKIP_BREW" != "1" ] && [ -t 0 ]; then +if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "${SKIP_BREW:-}" != "1" ] && [ -t 0 ]; then brew bundle check >/dev/null 2>&1 || { echo -n "==> Install Homebrew dependencies? (y/N): " read -r response