From ef994b4d6189ee0cdb3ad6e002816e0d77c7db48 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 13 Apr 2026 11:51:38 -0400 Subject: [PATCH 01/20] feat: add browser-scoped session client Bind browser subresource calls to a browser session's base_url and expose raw HTTP through a standard http.Client so metro-routed access feels like normal Go networking. Made-with: Cursor --- browser_session.go | 126 ++++++++++++++++ browser_session_httpclient_test.go | 57 +++++++ browser_session_services.go | 223 ++++++++++++++++++++++++++++ browser_session_test.go | 80 ++++++++++ lib/browserscope/jwt.go | 25 ++++ lib/browserscope/jwt_test.go | 21 +++ lib/browserscope/middleware.go | 41 +++++ lib/browserscope/middleware_test.go | 57 +++++++ lib/browserscope/rawcurl.go | 63 ++++++++ lib/browserscope/rawcurl_test.go | 54 +++++++ lib/browserscope/ref.go | 43 ++++++ 11 files changed, 790 insertions(+) create mode 100644 browser_session.go create mode 100644 browser_session_httpclient_test.go create mode 100644 browser_session_services.go create mode 100644 browser_session_test.go create mode 100644 lib/browserscope/jwt.go create mode 100644 lib/browserscope/jwt_test.go create mode 100644 lib/browserscope/middleware.go create mode 100644 lib/browserscope/middleware_test.go create mode 100644 lib/browserscope/rawcurl.go create mode 100644 lib/browserscope/rawcurl_test.go create mode 100644 lib/browserscope/ref.go diff --git a/browser_session.go b/browser_session.go new file mode 100644 index 0000000..5ec2409 --- /dev/null +++ b/browser_session.go @@ -0,0 +1,126 @@ +package kernel + +import ( + "fmt" + "net/http" + "slices" + + "github.com/kernel/kernel-go-sdk/internal/requestconfig" + "github.com/kernel/kernel-go-sdk/lib/browserscope" + "github.com/kernel/kernel-go-sdk/option" +) + +// BrowserSessionClient is a metro-scoped view of a browser session. Subresources +// use the session base_url and do not repeat the browser id in method +// signatures. SessionID is exposed for future routing extensions. +type BrowserSessionClient struct { + sessionID string + opts []option.RequestOption + kernelBase string + jwt string + + Replays BrowserSessionReplayService + Fs BrowserSessionFService + Process BrowserSessionProcessService + Logs BrowserSessionLogService + Computer BrowserSessionComputerService + Playwright BrowserSessionPlaywrightService +} + +// SessionID returns the control-plane browser session id. +func (b *BrowserSessionClient) SessionID() string { return b.sessionID } + +// HTTPClient returns an [http.Client] that performs egress HTTP through the +// browser's Chrome network stack via the metro kernel /curl/raw proxy. Each +// request must use an absolute http(s) URL; it is not rewritten to expose +// /curl/raw in the public API. +func (b *BrowserSessionClient) HTTPClient() *http.Client { + cfg, err := requestconfig.PreRequestOptions(b.opts...) + if err != nil { + return &http.Client{ + Transport: browserscope.NewRawCURLRoundTripper(b.kernelBase, b.jwt, nil), + } + } + underlying := cfg.HTTPClient + if underlying == nil { + underlying = http.DefaultClient + } + rt := underlying.Transport + if rt == nil { + rt = http.DefaultTransport + } + return &http.Client{ + Transport: browserscope.NewRawCURLRoundTripper(b.kernelBase, b.jwt, rt), + Timeout: underlying.Timeout, + } +} + +// ForBrowser returns a [BrowserSessionClient] for the given browser value. +// Supported types are [browserscope.Ref], [*BrowserNewResponse], [*BrowserGetResponse], +// [*BrowserListResponse], and [*BrowserUpdateResponse]. +func (c *Client) ForBrowser(v any, opts ...option.RequestOption) (*BrowserSessionClient, error) { + ref, err := browserSessionRefFrom(v) + if err != nil { + return nil, err + } + norm, err := ref.Normalize() + if err != nil { + return nil, err + } + + scoped := slices.Concat( + c.Options, + []option.RequestOption{ + option.WithBaseURL(norm.BaseURL), + option.WithMiddleware(browserscope.MetroKernelMiddleware(norm.SessionID, norm.JWT)), + }, + opts, + ) + + innerFs := NewBrowserFService(scoped...) + return &BrowserSessionClient{ + sessionID: norm.SessionID, + opts: scoped, + kernelBase: norm.BaseURL, + jwt: norm.JWT, + Replays: BrowserSessionReplayService{inner: NewBrowserReplayService(scoped...), id: norm.SessionID}, + Fs: BrowserSessionFService{ + inner: innerFs, + id: norm.SessionID, + Watch: BrowserSessionFWatchService{inner: innerFs.Watch, id: norm.SessionID}, + }, + Process: BrowserSessionProcessService{inner: NewBrowserProcessService(scoped...), id: norm.SessionID}, + Logs: BrowserSessionLogService{inner: NewBrowserLogService(scoped...), id: norm.SessionID}, + Computer: BrowserSessionComputerService{inner: NewBrowserComputerService(scoped...), id: norm.SessionID}, + Playwright: BrowserSessionPlaywrightService{inner: NewBrowserPlaywrightService(scoped...), id: norm.SessionID}, + }, nil +} + +func browserSessionRefFrom(v any) (browserscope.Ref, error) { + switch t := v.(type) { + case browserscope.Ref: + return t, nil + case *BrowserNewResponse: + if t == nil { + return browserscope.Ref{}, fmt.Errorf("kernel: ForBrowser: nil *BrowserNewResponse") + } + return browserscope.Ref{SessionID: t.SessionID, BaseURL: t.BaseURL, CdpWsURL: t.CdpWsURL}, nil + case *BrowserGetResponse: + if t == nil { + return browserscope.Ref{}, fmt.Errorf("kernel: ForBrowser: nil *BrowserGetResponse") + } + return browserscope.Ref{SessionID: t.SessionID, BaseURL: t.BaseURL, CdpWsURL: t.CdpWsURL}, nil + case *BrowserListResponse: + if t == nil { + return browserscope.Ref{}, fmt.Errorf("kernel: ForBrowser: nil *BrowserListResponse") + } + return browserscope.Ref{SessionID: t.SessionID, BaseURL: t.BaseURL, CdpWsURL: t.CdpWsURL}, nil + case *BrowserUpdateResponse: + if t == nil { + return browserscope.Ref{}, fmt.Errorf("kernel: ForBrowser: nil *BrowserUpdateResponse") + } + return browserscope.Ref{SessionID: t.SessionID, BaseURL: t.BaseURL, CdpWsURL: t.CdpWsURL}, nil + default: + return browserscope.Ref{}, fmt.Errorf("kernel: ForBrowser: unsupported type %T", v) + } +} diff --git a/browser_session_httpclient_test.go b/browser_session_httpclient_test.go new file mode 100644 index 0000000..99fb422 --- /dev/null +++ b/browser_session_httpclient_test.go @@ -0,0 +1,57 @@ +package kernel + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "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()), + ) + + sess, err := c.ForBrowser(&BrowserGetResponse{ + SessionID: "sid", + BaseURL: srv.URL + "/browser/kernel", + CdpWsURL: "wss://x/browser/cdp?jwt=j1", + }) + if err != nil { + t.Fatal(err) + } + + hc := sess.HTTPClient() + 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") + } +} diff --git a/browser_session_services.go b/browser_session_services.go new file mode 100644 index 0000000..814a2b5 --- /dev/null +++ b/browser_session_services.go @@ -0,0 +1,223 @@ +package kernel + +import ( + "context" + "io" + "net/http" + + "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/ssestream" + "github.com/kernel/kernel-go-sdk/shared" +) + +// BrowserSessionProcessService exposes process APIs without passing browser id. +type BrowserSessionProcessService struct { + inner BrowserProcessService + id string +} + +func (s BrowserSessionProcessService) Exec(ctx context.Context, body BrowserProcessExecParams, opts ...option.RequestOption) (*BrowserProcessExecResponse, error) { + return s.inner.Exec(ctx, s.id, body, opts...) +} + +func (s BrowserSessionProcessService) Kill(ctx context.Context, processID string, params BrowserProcessKillParams, opts ...option.RequestOption) (*BrowserProcessKillResponse, error) { + params.ID = s.id + return s.inner.Kill(ctx, processID, params, opts...) +} + +func (s BrowserSessionProcessService) Resize(ctx context.Context, processID string, params BrowserProcessResizeParams, opts ...option.RequestOption) (*BrowserProcessResizeResponse, error) { + params.ID = s.id + return s.inner.Resize(ctx, processID, params, opts...) +} + +func (s BrowserSessionProcessService) Spawn(ctx context.Context, body BrowserProcessSpawnParams, opts ...option.RequestOption) (*BrowserProcessSpawnResponse, error) { + return s.inner.Spawn(ctx, s.id, body, opts...) +} + +func (s BrowserSessionProcessService) Status(ctx context.Context, processID string, query BrowserProcessStatusParams, opts ...option.RequestOption) (*BrowserProcessStatusResponse, error) { + query.ID = s.id + return s.inner.Status(ctx, processID, query, opts...) +} + +func (s BrowserSessionProcessService) Stdin(ctx context.Context, processID string, params BrowserProcessStdinParams, opts ...option.RequestOption) (*BrowserProcessStdinResponse, error) { + params.ID = s.id + return s.inner.Stdin(ctx, processID, params, opts...) +} + +func (s BrowserSessionProcessService) StdoutStreamStreaming(ctx context.Context, processID string, query BrowserProcessStdoutStreamParams, opts ...option.RequestOption) *ssestream.Stream[BrowserProcessStdoutStreamResponse] { + query.ID = s.id + return s.inner.StdoutStreamStreaming(ctx, processID, query, opts...) +} + +// BrowserSessionComputerService exposes computer APIs without passing browser id. +type BrowserSessionComputerService struct { + inner BrowserComputerService + id string +} + +func (s BrowserSessionComputerService) Batch(ctx context.Context, body BrowserComputerBatchParams, opts ...option.RequestOption) error { + return s.inner.Batch(ctx, s.id, body, opts...) +} + +func (s BrowserSessionComputerService) CaptureScreenshot(ctx context.Context, body BrowserComputerCaptureScreenshotParams, opts ...option.RequestOption) (*http.Response, error) { + return s.inner.CaptureScreenshot(ctx, s.id, body, opts...) +} + +func (s BrowserSessionComputerService) ClickMouse(ctx context.Context, body BrowserComputerClickMouseParams, opts ...option.RequestOption) error { + return s.inner.ClickMouse(ctx, s.id, body, opts...) +} + +func (s BrowserSessionComputerService) DragMouse(ctx context.Context, body BrowserComputerDragMouseParams, opts ...option.RequestOption) error { + return s.inner.DragMouse(ctx, s.id, body, opts...) +} + +func (s BrowserSessionComputerService) GetMousePosition(ctx context.Context, opts ...option.RequestOption) (*BrowserComputerGetMousePositionResponse, error) { + return s.inner.GetMousePosition(ctx, s.id, opts...) +} + +func (s BrowserSessionComputerService) MoveMouse(ctx context.Context, body BrowserComputerMoveMouseParams, opts ...option.RequestOption) error { + return s.inner.MoveMouse(ctx, s.id, body, opts...) +} + +func (s BrowserSessionComputerService) PressKey(ctx context.Context, body BrowserComputerPressKeyParams, opts ...option.RequestOption) error { + return s.inner.PressKey(ctx, s.id, body, opts...) +} + +func (s BrowserSessionComputerService) ReadClipboard(ctx context.Context, opts ...option.RequestOption) (*BrowserComputerReadClipboardResponse, error) { + return s.inner.ReadClipboard(ctx, s.id, opts...) +} + +func (s BrowserSessionComputerService) Scroll(ctx context.Context, body BrowserComputerScrollParams, opts ...option.RequestOption) error { + return s.inner.Scroll(ctx, s.id, body, opts...) +} + +func (s BrowserSessionComputerService) SetCursorVisibility(ctx context.Context, body BrowserComputerSetCursorVisibilityParams, opts ...option.RequestOption) (*BrowserComputerSetCursorVisibilityResponse, error) { + return s.inner.SetCursorVisibility(ctx, s.id, body, opts...) +} + +func (s BrowserSessionComputerService) TypeText(ctx context.Context, body BrowserComputerTypeTextParams, opts ...option.RequestOption) error { + return s.inner.TypeText(ctx, s.id, body, opts...) +} + +func (s BrowserSessionComputerService) WriteClipboard(ctx context.Context, body BrowserComputerWriteClipboardParams, opts ...option.RequestOption) error { + return s.inner.WriteClipboard(ctx, s.id, body, opts...) +} + +// BrowserSessionFService exposes filesystem APIs without passing browser id. +type BrowserSessionFService struct { + inner BrowserFService + id string + Watch BrowserSessionFWatchService +} + +func (s BrowserSessionFService) NewDirectory(ctx context.Context, body BrowserFNewDirectoryParams, opts ...option.RequestOption) error { + return s.inner.NewDirectory(ctx, s.id, body, opts...) +} + +func (s BrowserSessionFService) DeleteDirectory(ctx context.Context, body BrowserFDeleteDirectoryParams, opts ...option.RequestOption) error { + return s.inner.DeleteDirectory(ctx, s.id, body, opts...) +} + +func (s BrowserSessionFService) DeleteFile(ctx context.Context, body BrowserFDeleteFileParams, opts ...option.RequestOption) error { + return s.inner.DeleteFile(ctx, s.id, body, opts...) +} + +func (s BrowserSessionFService) DownloadDirZip(ctx context.Context, query BrowserFDownloadDirZipParams, opts ...option.RequestOption) (*http.Response, error) { + return s.inner.DownloadDirZip(ctx, s.id, query, opts...) +} + +func (s BrowserSessionFService) FileInfo(ctx context.Context, query BrowserFFileInfoParams, opts ...option.RequestOption) (*BrowserFFileInfoResponse, error) { + return s.inner.FileInfo(ctx, s.id, query, opts...) +} + +func (s BrowserSessionFService) ListFiles(ctx context.Context, query BrowserFListFilesParams, opts ...option.RequestOption) (*[]BrowserFListFilesResponse, error) { + return s.inner.ListFiles(ctx, s.id, query, opts...) +} + +func (s BrowserSessionFService) Move(ctx context.Context, body BrowserFMoveParams, opts ...option.RequestOption) error { + return s.inner.Move(ctx, s.id, body, opts...) +} + +func (s BrowserSessionFService) ReadFile(ctx context.Context, query BrowserFReadFileParams, opts ...option.RequestOption) (*http.Response, error) { + return s.inner.ReadFile(ctx, s.id, query, opts...) +} + +func (s BrowserSessionFService) SetFilePermissions(ctx context.Context, body BrowserFSetFilePermissionsParams, opts ...option.RequestOption) error { + return s.inner.SetFilePermissions(ctx, s.id, body, opts...) +} + +func (s BrowserSessionFService) Upload(ctx context.Context, body BrowserFUploadParams, opts ...option.RequestOption) error { + return s.inner.Upload(ctx, s.id, body, opts...) +} + +func (s BrowserSessionFService) UploadZip(ctx context.Context, body BrowserFUploadZipParams, opts ...option.RequestOption) error { + return s.inner.UploadZip(ctx, s.id, body, opts...) +} + +func (s BrowserSessionFService) WriteFile(ctx context.Context, contents io.Reader, params BrowserFWriteFileParams, opts ...option.RequestOption) error { + return s.inner.WriteFile(ctx, s.id, contents, params, opts...) +} + +// BrowserSessionFWatchService exposes fs watch APIs without passing browser id. +type BrowserSessionFWatchService struct { + inner BrowserFWatchService + id string +} + +func (s BrowserSessionFWatchService) EventsStreaming(ctx context.Context, watchID string, query BrowserFWatchEventsParams, opts ...option.RequestOption) *ssestream.Stream[BrowserFWatchEventsResponse] { + query.ID = s.id + return s.inner.EventsStreaming(ctx, watchID, query, opts...) +} + +func (s BrowserSessionFWatchService) Start(ctx context.Context, body BrowserFWatchStartParams, opts ...option.RequestOption) (*BrowserFWatchStartResponse, error) { + return s.inner.Start(ctx, s.id, body, opts...) +} + +func (s BrowserSessionFWatchService) Stop(ctx context.Context, watchID string, body BrowserFWatchStopParams, opts ...option.RequestOption) error { + body.ID = s.id + return s.inner.Stop(ctx, watchID, body, opts...) +} + +// BrowserSessionReplayService exposes replay APIs without passing browser id. +type BrowserSessionReplayService struct { + inner BrowserReplayService + id string +} + +func (s BrowserSessionReplayService) List(ctx context.Context, opts ...option.RequestOption) (*[]BrowserReplayListResponse, error) { + return s.inner.List(ctx, s.id, opts...) +} + +func (s BrowserSessionReplayService) Download(ctx context.Context, replayID string, query BrowserReplayDownloadParams, opts ...option.RequestOption) (*http.Response, error) { + query.ID = s.id + return s.inner.Download(ctx, replayID, query, opts...) +} + +func (s BrowserSessionReplayService) Start(ctx context.Context, body BrowserReplayStartParams, opts ...option.RequestOption) (*BrowserReplayStartResponse, error) { + return s.inner.Start(ctx, s.id, body, opts...) +} + +func (s BrowserSessionReplayService) Stop(ctx context.Context, replayID string, body BrowserReplayStopParams, opts ...option.RequestOption) error { + body.ID = s.id + return s.inner.Stop(ctx, replayID, body, opts...) +} + +// BrowserSessionLogService exposes log streaming without passing browser id. +type BrowserSessionLogService struct { + inner BrowserLogService + id string +} + +func (s BrowserSessionLogService) StreamStreaming(ctx context.Context, query BrowserLogStreamParams, opts ...option.RequestOption) *ssestream.Stream[shared.LogEvent] { + return s.inner.StreamStreaming(ctx, s.id, query, opts...) +} + +// BrowserSessionPlaywrightService exposes Playwright execution without passing browser id. +type BrowserSessionPlaywrightService struct { + inner BrowserPlaywrightService + id string +} + +func (s BrowserSessionPlaywrightService) Execute(ctx context.Context, body BrowserPlaywrightExecuteParams, opts ...option.RequestOption) (*BrowserPlaywrightExecuteResponse, error) { + return s.inner.Execute(ctx, s.id, body, opts...) +} diff --git a/browser_session_test.go b/browser_session_test.go new file mode 100644 index 0000000..0678017 --- /dev/null +++ b/browser_session_test.go @@ -0,0 +1,80 @@ +package kernel + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/kernel/kernel-go-sdk/lib/browserscope" + "github.com/kernel/kernel-go-sdk/option" +) + +func TestForBrowserRewritesToKernelPaths(t *testing.T) { + var gotPath string + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + if r.URL.Query().Get("jwt") == "" { + http.Error(w, "jwt", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "duration_ms": 1, + "exit_code": 0, + "stderr_b64": "", + "stdout_b64": "", + }) + })) + defer srv.Close() + + c := NewClient( + option.WithBaseURL("https://api.example/"), + option.WithAPIKey("sk_test"), + option.WithHTTPClient(srv.Client()), + ) + + b := &BrowserGetResponse{ + SessionID: "sid-1", + BaseURL: srv.URL + "/browser/kernel", + CdpWsURL: "wss://x/browser/cdp?jwt=metro-jwt", + } + + sess, err := c.ForBrowser(b) + if err != nil { + t.Fatal(err) + } + if sess.SessionID() != "sid-1" { + t.Fatalf("session id: %s", sess.SessionID()) + } + + _, err = sess.Process.Exec(context.Background(), BrowserProcessExecParams{Command: "true"}) + if err != nil { + t.Fatal(err) + } + + if gotPath != "/browser/kernel/process/exec" { + t.Fatalf("path: got %q", gotPath) + } + if gotAuth != "" { + t.Fatalf("authorization should be empty, got %q", gotAuth) + } +} + +func TestForBrowserRefNormalize(t *testing.T) { + ref := browserscope.Ref{ + SessionID: "s", + BaseURL: "https://x/browser/kernel", + JWT: "direct", + } + norm, err := ref.Normalize() + if err != nil { + t.Fatal(err) + } + if norm.JWT != "direct" { + t.Fatalf("jwt: %q", norm.JWT) + } +} diff --git a/lib/browserscope/jwt.go b/lib/browserscope/jwt.go new file mode 100644 index 0000000..6600231 --- /dev/null +++ b/lib/browserscope/jwt.go @@ -0,0 +1,25 @@ +package browserscope + +import ( + "fmt" + "net/url" + "strings" +) + +// JWTFromWebSocketURL extracts the session jwt query parameter from a Kernel +// metro 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("browserscope: empty websocket url") + } + u, err := url.Parse(wsURL) + if err != nil { + return "", fmt.Errorf("browserscope: parse websocket url: %w", err) + } + jwt := u.Query().Get("jwt") + if jwt == "" { + return "", fmt.Errorf("browserscope: missing jwt query parameter") + } + return jwt, nil +} diff --git a/lib/browserscope/jwt_test.go b/lib/browserscope/jwt_test.go new file mode 100644 index 0000000..2364373 --- /dev/null +++ b/lib/browserscope/jwt_test.go @@ -0,0 +1,21 @@ +package browserscope + +import "testing" + +func TestJWTFromWebSocketURL(t *testing.T) { + const u = "wss://metro.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://metro.example/browser/cdp") + if err == nil { + t.Fatal("expected error") + } +} diff --git a/lib/browserscope/middleware.go b/lib/browserscope/middleware.go new file mode 100644 index 0000000..cca3232 --- /dev/null +++ b/lib/browserscope/middleware.go @@ -0,0 +1,41 @@ +package browserscope + +import ( + "net/http" + "strings" + + "github.com/kernel/kernel-go-sdk/option" +) + +const kernelHTTPPrefix = "/browser/kernel" + +// MetroKernelMiddleware prepares requests for metro-api's /browser/kernel proxy: +// it strips the control-plane browsers/{session_id} path prefix, attaches jwt, +// and removes Authorization so the Kernel API key is not forwarded to the VM. +func MetroKernelMiddleware(sessionID, jwt string) option.Middleware { + prefix := kernelHTTPPrefix + "/browsers/" + sessionID + "/" + return func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { + req.Header.Del("Authorization") + + if jwt != "" { + q := req.URL.Query() + if q.Get("jwt") == "" { + q.Set("jwt", jwt) + req.URL.RawQuery = q.Encode() + } + } + + if sessionID != "" && strings.HasPrefix(req.URL.Path, prefix) { + rest := strings.TrimPrefix(req.URL.Path, prefix) + if rest == "" { + rest = "/" + } + if !strings.HasPrefix(rest, "/") { + rest = "/" + rest + } + req.URL.Path = kernelHTTPPrefix + rest + } + + return next(req) + } +} diff --git a/lib/browserscope/middleware_test.go b/lib/browserscope/middleware_test.go new file mode 100644 index 0000000..a18a2f6 --- /dev/null +++ b/lib/browserscope/middleware_test.go @@ -0,0 +1,57 @@ +package browserscope + +import ( + "net/http" + "net/url" + "testing" + + "github.com/kernel/kernel-go-sdk/option" +) + +func TestMetroKernelMiddleware(t *testing.T) { + mw := MetroKernelMiddleware("sess1", "tok") + var final *http.Request + next := func(req *http.Request) (*http.Response, error) { + final = req + return nil, nil + } + + u, err := url.Parse("https://host/browser/kernel/browsers/sess1/process/exec?x=1") + if err != nil { + t.Fatal(err) + } + u.Path = "/browser/kernel/browsers/sess1/process/exec" + req := &http.Request{URL: u, Header: http.Header{"Authorization": {"Bearer sk"}}} + _, _ = mw(req, next) + + if final.Header.Get("Authorization") != "" { + t.Fatal("authorization should be stripped") + } + if final.URL.Query().Get("jwt") != "tok" { + t.Fatalf("jwt query: got %q", final.URL.Query().Get("jwt")) + } + if final.URL.Path != "/browser/kernel/process/exec" { + t.Fatalf("path rewrite: got %s", final.URL.Path) + } +} + +func TestMetroKernelMiddlewarePreservesExistingJWT(t *testing.T) { + mw := MetroKernelMiddleware("sess1", "tok") + var final *http.Request + next := func(req *http.Request) (*http.Response, error) { + final = req + return nil, nil + } + + u, _ := url.Parse("https://host/browser/kernel/browsers/sess1/fs/list_files?jwt=already") + u.Path = "/browser/kernel/browsers/sess1/fs/list_files" + req := &http.Request{URL: u} + _, _ = mw(req, next) + if final.URL.Query().Get("jwt") != "already" { + t.Fatalf("jwt: got %q want already", final.URL.Query().Get("jwt")) + } +} + +func TestMetroKernelMiddlewareType(t *testing.T) { + var _ option.Middleware = MetroKernelMiddleware("a", "b") +} diff --git a/lib/browserscope/rawcurl.go b/lib/browserscope/rawcurl.go new file mode 100644 index 0000000..32b9a28 --- /dev/null +++ b/lib/browserscope/rawcurl.go @@ -0,0 +1,63 @@ +package browserscope + +import ( + "fmt" + "net/http" + "net/url" + "strings" +) + +// RawCURLRoundTripper implements browser-egress HTTP by tunneling through the +// kernel-images /curl/raw endpoint on the metro kernel base URL. +type RawCURLRoundTripper struct { + kernelBaseURL string + jwt string + underlying http.RoundTripper +} + +// NewRawCURLRoundTripper returns an [http.RoundTripper] that maps each request +// to {kernelBase}/curl/raw?jwt=...&url=, preserving method, +// headers, and body. The caller's request URL must be an absolute http(s) URL. +func NewRawCURLRoundTripper(kernelBaseURL, jwt string, underlying http.RoundTripper) *RawCURLRoundTripper { + if underlying == nil { + underlying = http.DefaultTransport + } + return &RawCURLRoundTripper{ + kernelBaseURL: strings.TrimRight(strings.TrimSpace(kernelBaseURL), "/"), + 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("browserscope: raw curl requires an absolute request URL (got %q)", req.URL) + } + if req.URL.Scheme != "http" && req.URL.Scheme != "https" { + return nil, fmt.Errorf("browserscope: raw curl requires http or https scheme") + } + if t.kernelBaseURL == "" { + return nil, fmt.Errorf("browserscope: kernel base url is required") + } + if t.jwt == "" { + return nil, fmt.Errorf("browserscope: jwt is required for raw curl") + } + + target := req.URL.String() + proxyURL, err := url.Parse(t.kernelBaseURL + "/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/browserscope/rawcurl_test.go b/lib/browserscope/rawcurl_test.go new file mode 100644 index 0000000..42e66cd --- /dev/null +++ b/lib/browserscope/rawcurl_test.go @@ -0,0 +1,54 @@ +package browserscope + +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/browserscope/ref.go b/lib/browserscope/ref.go new file mode 100644 index 0000000..3a41027 --- /dev/null +++ b/lib/browserscope/ref.go @@ -0,0 +1,43 @@ +package browserscope + +import ( + "fmt" + "strings" +) + +// Ref identifies a browser session for metro-scoped HTTP calls. SessionID is +// reserved for future client-side routing; metro requests rewrite +// /browser/kernel/browsers/{SessionID}/ to /browser/kernel/. +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("browserscope: base_url is required") + } + if strings.TrimSpace(r.SessionID) == "" { + return Ref{}, fmt.Errorf("browserscope: 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("browserscope: jwt or cdp_ws_url is required") + } + jwt, err := JWTFromWebSocketURL(src) + if err != nil { + return Ref{}, err + } + out.JWT = jwt + } + return out, nil +} From 64f7f811a2f3d7c657ad56a1f34a8bea0206e585 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 13 Apr 2026 14:09:21 -0400 Subject: [PATCH 02/20] fix: align browser-scoped routing with base_url Use the browser session base_url directly for path rewriting, preserve custom HTTP clients in HTTPClient(), and add an env-gated integration test for browser-scoped routing. Made-with: Cursor --- browser_session.go | 6 +-- browser_session_test.go | 2 +- lib/browserscope/integration_test.go | 76 ++++++++++++++++++++++++++++ lib/browserscope/jwt.go | 4 +- lib/browserscope/jwt_test.go | 4 +- lib/browserscope/middleware.go | 28 +++++----- lib/browserscope/middleware_test.go | 12 ++--- lib/browserscope/rawcurl.go | 24 ++++----- lib/browserscope/ref.go | 6 +-- option/requestoption.go | 2 +- 10 files changed, 120 insertions(+), 44 deletions(-) create mode 100644 lib/browserscope/integration_test.go diff --git a/browser_session.go b/browser_session.go index 5ec2409..d22eb1c 100644 --- a/browser_session.go +++ b/browser_session.go @@ -10,7 +10,7 @@ import ( "github.com/kernel/kernel-go-sdk/option" ) -// BrowserSessionClient is a metro-scoped view of a browser session. Subresources +// BrowserSessionClient is a browser-scoped view of a browser session. Subresources // use the session base_url and do not repeat the browser id in method // signatures. SessionID is exposed for future routing extensions. type BrowserSessionClient struct { @@ -31,7 +31,7 @@ type BrowserSessionClient struct { func (b *BrowserSessionClient) SessionID() string { return b.sessionID } // HTTPClient returns an [http.Client] that performs egress HTTP through the -// browser's Chrome network stack via the metro kernel /curl/raw proxy. Each +// browser's Chrome network stack via the browser session /curl/raw proxy. Each // request must use an absolute http(s) URL; it is not rewritten to expose // /curl/raw in the public API. func (b *BrowserSessionClient) HTTPClient() *http.Client { @@ -72,7 +72,7 @@ func (c *Client) ForBrowser(v any, opts ...option.RequestOption) (*BrowserSessio c.Options, []option.RequestOption{ option.WithBaseURL(norm.BaseURL), - option.WithMiddleware(browserscope.MetroKernelMiddleware(norm.SessionID, norm.JWT)), + option.WithMiddleware(browserscope.BrowserSessionMiddleware(norm.SessionID, norm.JWT)), }, opts, ) diff --git a/browser_session_test.go b/browser_session_test.go index 0678017..5ca48d0 100644 --- a/browser_session_test.go +++ b/browser_session_test.go @@ -40,7 +40,7 @@ func TestForBrowserRewritesToKernelPaths(t *testing.T) { b := &BrowserGetResponse{ SessionID: "sid-1", BaseURL: srv.URL + "/browser/kernel", - CdpWsURL: "wss://x/browser/cdp?jwt=metro-jwt", + CdpWsURL: "wss://x/browser/cdp?jwt=session-jwt", } sess, err := c.ForBrowser(b) diff --git a/lib/browserscope/integration_test.go b/lib/browserscope/integration_test.go new file mode 100644 index 0000000..a1727a6 --- /dev/null +++ b/lib/browserscope/integration_test.go @@ -0,0 +1,76 @@ +package browserscope_test + +import ( + "context" + "net/http" + "os" + "strings" + "testing" + "time" + + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" +) + +func TestIntegrationBrowserSessionClient(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") + } + + 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") + } + if !strings.Contains(browser.BaseURL, "/browser/kernel") { + t.Fatalf("expected browser base_url to include /browser/kernel, got %q", browser.BaseURL) + } + + scoped, err := client.ForBrowser(browser) + if err != nil { + t.Fatalf("for browser: %v", err) + } + + execRes, err := scoped.Process.Exec(ctx, 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) + } + resp, err := scoped.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) + } +} diff --git a/lib/browserscope/jwt.go b/lib/browserscope/jwt.go index 6600231..dd023f8 100644 --- a/lib/browserscope/jwt.go +++ b/lib/browserscope/jwt.go @@ -6,8 +6,8 @@ import ( "strings" ) -// JWTFromWebSocketURL extracts the session jwt query parameter from a Kernel -// metro websocket URL (for example cdp_ws_url or webdriver_ws_url). +// 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 == "" { diff --git a/lib/browserscope/jwt_test.go b/lib/browserscope/jwt_test.go index 2364373..4798071 100644 --- a/lib/browserscope/jwt_test.go +++ b/lib/browserscope/jwt_test.go @@ -3,7 +3,7 @@ package browserscope import "testing" func TestJWTFromWebSocketURL(t *testing.T) { - const u = "wss://metro.example/browser/cdp?jwt=abc123&foo=bar" + const u = "wss://browser.example/browser/cdp?jwt=abc123&foo=bar" j, err := JWTFromWebSocketURL(u) if err != nil { t.Fatal(err) @@ -14,7 +14,7 @@ func TestJWTFromWebSocketURL(t *testing.T) { } func TestJWTFromWebSocketURLMissing(t *testing.T) { - _, err := JWTFromWebSocketURL("wss://metro.example/browser/cdp") + _, err := JWTFromWebSocketURL("wss://browser.example/browser/cdp") if err == nil { t.Fatal("expected error") } diff --git a/lib/browserscope/middleware.go b/lib/browserscope/middleware.go index cca3232..8f9f7bf 100644 --- a/lib/browserscope/middleware.go +++ b/lib/browserscope/middleware.go @@ -7,13 +7,11 @@ import ( "github.com/kernel/kernel-go-sdk/option" ) -const kernelHTTPPrefix = "/browser/kernel" - -// MetroKernelMiddleware prepares requests for metro-api's /browser/kernel proxy: +// BrowserSessionMiddleware prepares requests for a browser session base_url: // it strips the control-plane browsers/{session_id} path prefix, attaches jwt, -// and removes Authorization so the Kernel API key is not forwarded to the VM. -func MetroKernelMiddleware(sessionID, jwt string) option.Middleware { - prefix := kernelHTTPPrefix + "/browsers/" + sessionID + "/" +// and removes Authorization so the Kernel API key is not forwarded to the browser. +func BrowserSessionMiddleware(sessionID, jwt string) option.Middleware { + marker := "/browsers/" + sessionID + "/" return func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { req.Header.Del("Authorization") @@ -25,15 +23,17 @@ func MetroKernelMiddleware(sessionID, jwt string) option.Middleware { } } - if sessionID != "" && strings.HasPrefix(req.URL.Path, prefix) { - rest := strings.TrimPrefix(req.URL.Path, prefix) - if rest == "" { - rest = "/" - } - if !strings.HasPrefix(rest, "/") { - rest = "/" + rest + if sessionID != "" { + if idx := strings.Index(req.URL.Path, marker); idx >= 0 { + prefix := strings.TrimRight(req.URL.Path[:idx], "/") + rest := strings.TrimPrefix(req.URL.Path[idx+len(marker):], "/") + if rest == "" { + rest = "/" + } else { + rest = "/" + rest + } + req.URL.Path = prefix + rest } - req.URL.Path = kernelHTTPPrefix + rest } return next(req) diff --git a/lib/browserscope/middleware_test.go b/lib/browserscope/middleware_test.go index a18a2f6..afd1efe 100644 --- a/lib/browserscope/middleware_test.go +++ b/lib/browserscope/middleware_test.go @@ -8,8 +8,8 @@ import ( "github.com/kernel/kernel-go-sdk/option" ) -func TestMetroKernelMiddleware(t *testing.T) { - mw := MetroKernelMiddleware("sess1", "tok") +func TestBrowserSessionMiddleware(t *testing.T) { + mw := BrowserSessionMiddleware("sess1", "tok") var final *http.Request next := func(req *http.Request) (*http.Response, error) { final = req @@ -35,8 +35,8 @@ func TestMetroKernelMiddleware(t *testing.T) { } } -func TestMetroKernelMiddlewarePreservesExistingJWT(t *testing.T) { - mw := MetroKernelMiddleware("sess1", "tok") +func TestBrowserSessionMiddlewarePreservesExistingJWT(t *testing.T) { + mw := BrowserSessionMiddleware("sess1", "tok") var final *http.Request next := func(req *http.Request) (*http.Response, error) { final = req @@ -52,6 +52,6 @@ func TestMetroKernelMiddlewarePreservesExistingJWT(t *testing.T) { } } -func TestMetroKernelMiddlewareType(t *testing.T) { - var _ option.Middleware = MetroKernelMiddleware("a", "b") +func TestBrowserSessionMiddlewareType(t *testing.T) { + var _ option.Middleware = BrowserSessionMiddleware("a", "b") } diff --git a/lib/browserscope/rawcurl.go b/lib/browserscope/rawcurl.go index 32b9a28..9370638 100644 --- a/lib/browserscope/rawcurl.go +++ b/lib/browserscope/rawcurl.go @@ -8,24 +8,24 @@ import ( ) // RawCURLRoundTripper implements browser-egress HTTP by tunneling through the -// kernel-images /curl/raw endpoint on the metro kernel base URL. +// browser session base_url /curl/raw endpoint. type RawCURLRoundTripper struct { - kernelBaseURL string - jwt string - underlying http.RoundTripper + browserBaseURL string + jwt string + underlying http.RoundTripper } // NewRawCURLRoundTripper returns an [http.RoundTripper] that maps each request -// to {kernelBase}/curl/raw?jwt=...&url=, preserving method, +// 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(kernelBaseURL, jwt string, underlying http.RoundTripper) *RawCURLRoundTripper { +func NewRawCURLRoundTripper(browserBaseURL, jwt string, underlying http.RoundTripper) *RawCURLRoundTripper { if underlying == nil { underlying = http.DefaultTransport } return &RawCURLRoundTripper{ - kernelBaseURL: strings.TrimRight(strings.TrimSpace(kernelBaseURL), "/"), - jwt: strings.TrimSpace(jwt), - underlying: underlying, + browserBaseURL: strings.TrimRight(strings.TrimSpace(browserBaseURL), "/"), + jwt: strings.TrimSpace(jwt), + underlying: underlying, } } @@ -37,15 +37,15 @@ func (t *RawCURLRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro if req.URL.Scheme != "http" && req.URL.Scheme != "https" { return nil, fmt.Errorf("browserscope: raw curl requires http or https scheme") } - if t.kernelBaseURL == "" { - return nil, fmt.Errorf("browserscope: kernel base url is required") + if t.browserBaseURL == "" { + return nil, fmt.Errorf("browserscope: browser base_url is required") } if t.jwt == "" { return nil, fmt.Errorf("browserscope: jwt is required for raw curl") } target := req.URL.String() - proxyURL, err := url.Parse(t.kernelBaseURL + "/curl/raw") + proxyURL, err := url.Parse(t.browserBaseURL + "/curl/raw") if err != nil { return nil, err } diff --git a/lib/browserscope/ref.go b/lib/browserscope/ref.go index 3a41027..60c3586 100644 --- a/lib/browserscope/ref.go +++ b/lib/browserscope/ref.go @@ -5,9 +5,9 @@ import ( "strings" ) -// Ref identifies a browser session for metro-scoped HTTP calls. SessionID is -// reserved for future client-side routing; metro requests rewrite -// /browser/kernel/browsers/{SessionID}/ to /browser/kernel/. +// Ref identifies a browser session for browser-scoped HTTP calls. SessionID is +// reserved for future client-side routing; browser-scoped requests rewrite +// the /browsers/{SessionID}/ path segment against the returned base_url. type Ref struct { SessionID string BaseURL string diff --git a/option/requestoption.go b/option/requestoption.go index fd632db..82b0927 100644 --- a/option/requestoption.go +++ b/option/requestoption.go @@ -56,7 +56,7 @@ type HTTPClient interface { // For custom uses cases, it is recommended to provide an [*http.Client] with a custom // [http.RoundTripper] as its transport, rather than directly implementing [HTTPClient]. func WithHTTPClient(client HTTPClient) RequestOption { - return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error { + return requestconfig.PreRequestOptionFunc(func(r *requestconfig.RequestConfig) error { if client == nil { return fmt.Errorf("requestoption: custom http client cannot be nil") } From 3e3e33f031d8460662da8a368467a3dcc6a0ec3f Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 13 Apr 2026 14:33:33 -0400 Subject: [PATCH 03/20] fix: tighten browser-scoped helper surface Avoid depending on base_url path details in the integration test, keep the JWT helper package-private, and make round-tripper conformance explicit while preserving browser-scoped routing behavior. Made-with: Cursor --- lib/browserscope/integration_test.go | 14 +++++++++++--- lib/browserscope/jwt.go | 4 ++-- lib/browserscope/jwt_test.go | 4 ++-- lib/browserscope/rawcurl.go | 2 ++ lib/browserscope/ref.go | 2 +- 5 files changed, 18 insertions(+), 8 deletions(-) diff --git a/lib/browserscope/integration_test.go b/lib/browserscope/integration_test.go index a1727a6..6feef68 100644 --- a/lib/browserscope/integration_test.go +++ b/lib/browserscope/integration_test.go @@ -2,6 +2,7 @@ package browserscope_test import ( "context" + "io" "net/http" "os" "strings" @@ -41,9 +42,6 @@ func TestIntegrationBrowserSessionClient(t *testing.T) { if browser.BaseURL == "" { t.Fatal("expected browser base_url to be set") } - if !strings.Contains(browser.BaseURL, "/browser/kernel") { - t.Fatalf("expected browser base_url to include /browser/kernel, got %q", browser.BaseURL) - } scoped, err := client.ForBrowser(browser) if err != nil { @@ -73,4 +71,14 @@ func TestIntegrationBrowserSessionClient(t *testing.T) { 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/browserscope/jwt.go b/lib/browserscope/jwt.go index dd023f8..5e1f857 100644 --- a/lib/browserscope/jwt.go +++ b/lib/browserscope/jwt.go @@ -6,9 +6,9 @@ import ( "strings" ) -// JWTFromWebSocketURL extracts the session jwt query parameter from a browser +// 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) { +func jwtFromWebSocketURL(wsURL string) (string, error) { wsURL = strings.TrimSpace(wsURL) if wsURL == "" { return "", fmt.Errorf("browserscope: empty websocket url") diff --git a/lib/browserscope/jwt_test.go b/lib/browserscope/jwt_test.go index 4798071..220ce77 100644 --- a/lib/browserscope/jwt_test.go +++ b/lib/browserscope/jwt_test.go @@ -4,7 +4,7 @@ import "testing" func TestJWTFromWebSocketURL(t *testing.T) { const u = "wss://browser.example/browser/cdp?jwt=abc123&foo=bar" - j, err := JWTFromWebSocketURL(u) + j, err := jwtFromWebSocketURL(u) if err != nil { t.Fatal(err) } @@ -14,7 +14,7 @@ func TestJWTFromWebSocketURL(t *testing.T) { } func TestJWTFromWebSocketURLMissing(t *testing.T) { - _, err := JWTFromWebSocketURL("wss://browser.example/browser/cdp") + _, err := jwtFromWebSocketURL("wss://browser.example/browser/cdp") if err == nil { t.Fatal("expected error") } diff --git a/lib/browserscope/rawcurl.go b/lib/browserscope/rawcurl.go index 9370638..25e37fe 100644 --- a/lib/browserscope/rawcurl.go +++ b/lib/browserscope/rawcurl.go @@ -15,6 +15,8 @@ type RawCURLRoundTripper struct { underlying http.RoundTripper } +var _ http.RoundTripper = (*RawCURLRoundTripper)(nil) + // 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. diff --git a/lib/browserscope/ref.go b/lib/browserscope/ref.go index 60c3586..603a94b 100644 --- a/lib/browserscope/ref.go +++ b/lib/browserscope/ref.go @@ -33,7 +33,7 @@ func (r Ref) Normalize() (Ref, error) { if src == "" { return Ref{}, fmt.Errorf("browserscope: jwt or cdp_ws_url is required") } - jwt, err := JWTFromWebSocketURL(src) + jwt, err := jwtFromWebSocketURL(src) if err != nil { return Ref{}, err } From 0ac61ef0532e6682eed740765975775767e28a10 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 13 Apr 2026 15:58:01 -0400 Subject: [PATCH 04/20] refactor: narrow browser-scoped helper exports Keep the raw round-tripper constructor package-private, remove defensive middleware branches that imply unsupported empty inputs, and retain the browser-scoped integration coverage without baking in base_url path details. Made-with: Cursor --- browser_session.go | 17 ++--------------- lib/browserscope/rawcurl.go | 20 ++++++++++++++++++-- lib/browserscope/rawcurl_test.go | 4 ++-- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/browser_session.go b/browser_session.go index d22eb1c..e8bf476 100644 --- a/browser_session.go +++ b/browser_session.go @@ -37,22 +37,9 @@ func (b *BrowserSessionClient) SessionID() string { return b.sessionID } func (b *BrowserSessionClient) HTTPClient() *http.Client { cfg, err := requestconfig.PreRequestOptions(b.opts...) if err != nil { - return &http.Client{ - Transport: browserscope.NewRawCURLRoundTripper(b.kernelBase, b.jwt, nil), - } - } - underlying := cfg.HTTPClient - if underlying == nil { - underlying = http.DefaultClient - } - rt := underlying.Transport - if rt == nil { - rt = http.DefaultTransport - } - return &http.Client{ - Transport: browserscope.NewRawCURLRoundTripper(b.kernelBase, b.jwt, rt), - Timeout: underlying.Timeout, + return browserscope.HTTPClient(b.kernelBase, b.jwt, nil) } + return browserscope.HTTPClient(b.kernelBase, b.jwt, cfg.HTTPClient) } // ForBrowser returns a [BrowserSessionClient] for the given browser value. diff --git a/lib/browserscope/rawcurl.go b/lib/browserscope/rawcurl.go index 25e37fe..a53fc04 100644 --- a/lib/browserscope/rawcurl.go +++ b/lib/browserscope/rawcurl.go @@ -17,10 +17,26 @@ type RawCURLRoundTripper struct { var _ http.RoundTripper = (*RawCURLRoundTripper)(nil) -// NewRawCURLRoundTripper returns an [http.RoundTripper] that maps each request +// HTTPClient returns an [http.Client] that performs browser egress HTTP via the +// browser session base_url and internal /curl/raw path. +func HTTPClient(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 { +func newRawCURLRoundTripper(browserBaseURL, jwt string, underlying http.RoundTripper) *RawCURLRoundTripper { if underlying == nil { underlying = http.DefaultTransport } diff --git a/lib/browserscope/rawcurl_test.go b/lib/browserscope/rawcurl_test.go index 42e66cd..f39a622 100644 --- a/lib/browserscope/rawcurl_test.go +++ b/lib/browserscope/rawcurl_test.go @@ -17,7 +17,7 @@ func TestRawCURLRoundTripper(t *testing.T) { })) defer up.Close() - rt := NewRawCURLRoundTripper(up.URL+"/browser/kernel", "jwt1", http.DefaultTransport) + 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) @@ -45,7 +45,7 @@ func TestRawCURLRoundTripper(t *testing.T) { } func TestRawCURLRoundTripperRelativeURL(t *testing.T) { - rt := NewRawCURLRoundTripper("https://x/browser/kernel", "j", http.DefaultTransport) + rt := newRawCURLRoundTripper("https://x/browser/kernel", "j", http.DefaultTransport) req, _ := http.NewRequest(http.MethodGet, "/relative", nil) _, err := rt.RoundTrip(req) if err == nil { From b6a77bc656e6480e1bee0932fcc3b5beb40bcb10 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 13 Apr 2026 18:27:02 -0400 Subject: [PATCH 05/20] feat: generate browser-scoped service bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the handwritten browser-scoped Go service façade with deterministic generated bindings derived from the generated browser service graph, and enforce regeneration in lint. Made-with: Cursor --- browser_session.go | 37 +- ...ices.go => browser_session_services_gen.go | 228 ++++--- go.mod | 5 +- go.sum | 8 + .../generate_test.go | 63 ++ internal/genbrowsersessionservices/main.go | 623 ++++++++++++++++++ scripts/generate-browser-session | 7 + scripts/lint | 6 + 8 files changed, 847 insertions(+), 130 deletions(-) rename browser_session_services.go => browser_session_services_gen.go (62%) create mode 100644 internal/genbrowsersessionservices/generate_test.go create mode 100644 internal/genbrowsersessionservices/main.go create mode 100755 scripts/generate-browser-session diff --git a/browser_session.go b/browser_session.go index e8bf476..c541f01 100644 --- a/browser_session.go +++ b/browser_session.go @@ -1,5 +1,7 @@ package kernel +//go:generate go run ./internal/genbrowsersessionservices -output browser_session_services_gen.go + import ( "fmt" "net/http" @@ -10,23 +12,6 @@ import ( "github.com/kernel/kernel-go-sdk/option" ) -// BrowserSessionClient is a browser-scoped view of a browser session. Subresources -// use the session base_url and do not repeat the browser id in method -// signatures. SessionID is exposed for future routing extensions. -type BrowserSessionClient struct { - sessionID string - opts []option.RequestOption - kernelBase string - jwt string - - Replays BrowserSessionReplayService - Fs BrowserSessionFService - Process BrowserSessionProcessService - Logs BrowserSessionLogService - Computer BrowserSessionComputerService - Playwright BrowserSessionPlaywrightService -} - // SessionID returns the control-plane browser session id. func (b *BrowserSessionClient) SessionID() string { return b.sessionID } @@ -64,23 +49,7 @@ func (c *Client) ForBrowser(v any, opts ...option.RequestOption) (*BrowserSessio opts, ) - innerFs := NewBrowserFService(scoped...) - return &BrowserSessionClient{ - sessionID: norm.SessionID, - opts: scoped, - kernelBase: norm.BaseURL, - jwt: norm.JWT, - Replays: BrowserSessionReplayService{inner: NewBrowserReplayService(scoped...), id: norm.SessionID}, - Fs: BrowserSessionFService{ - inner: innerFs, - id: norm.SessionID, - Watch: BrowserSessionFWatchService{inner: innerFs.Watch, id: norm.SessionID}, - }, - Process: BrowserSessionProcessService{inner: NewBrowserProcessService(scoped...), id: norm.SessionID}, - Logs: BrowserSessionLogService{inner: NewBrowserLogService(scoped...), id: norm.SessionID}, - Computer: BrowserSessionComputerService{inner: NewBrowserComputerService(scoped...), id: norm.SessionID}, - Playwright: BrowserSessionPlaywrightService{inner: NewBrowserPlaywrightService(scoped...), id: norm.SessionID}, - }, nil + return newBrowserSessionClient(norm.SessionID, norm.BaseURL, norm.JWT, scoped), nil } func browserSessionRefFrom(v any) (browserscope.Ref, error) { diff --git a/browser_session_services.go b/browser_session_services_gen.go similarity index 62% rename from browser_session_services.go rename to browser_session_services_gen.go index 814a2b5..be7053b 100644 --- a/browser_session_services.go +++ b/browser_session_services_gen.go @@ -1,3 +1,5 @@ +// Code generated by internal/genbrowsersessionservices; DO NOT EDIT. + package kernel import ( @@ -10,214 +12,250 @@ import ( "github.com/kernel/kernel-go-sdk/shared" ) -// BrowserSessionProcessService exposes process APIs without passing browser id. -type BrowserSessionProcessService struct { - inner BrowserProcessService - id string -} - -func (s BrowserSessionProcessService) Exec(ctx context.Context, body BrowserProcessExecParams, opts ...option.RequestOption) (*BrowserProcessExecResponse, error) { - return s.inner.Exec(ctx, s.id, body, opts...) -} - -func (s BrowserSessionProcessService) Kill(ctx context.Context, processID string, params BrowserProcessKillParams, opts ...option.RequestOption) (*BrowserProcessKillResponse, error) { - params.ID = s.id - return s.inner.Kill(ctx, processID, params, opts...) -} - -func (s BrowserSessionProcessService) Resize(ctx context.Context, processID string, params BrowserProcessResizeParams, opts ...option.RequestOption) (*BrowserProcessResizeResponse, error) { - params.ID = s.id - return s.inner.Resize(ctx, processID, params, opts...) -} - -func (s BrowserSessionProcessService) Spawn(ctx context.Context, body BrowserProcessSpawnParams, opts ...option.RequestOption) (*BrowserProcessSpawnResponse, error) { - return s.inner.Spawn(ctx, s.id, body, opts...) -} - -func (s BrowserSessionProcessService) Status(ctx context.Context, processID string, query BrowserProcessStatusParams, opts ...option.RequestOption) (*BrowserProcessStatusResponse, error) { - query.ID = s.id - return s.inner.Status(ctx, processID, query, opts...) -} - -func (s BrowserSessionProcessService) Stdin(ctx context.Context, processID string, params BrowserProcessStdinParams, opts ...option.RequestOption) (*BrowserProcessStdinResponse, error) { - params.ID = s.id - return s.inner.Stdin(ctx, processID, params, opts...) -} - -func (s BrowserSessionProcessService) StdoutStreamStreaming(ctx context.Context, processID string, query BrowserProcessStdoutStreamParams, opts ...option.RequestOption) *ssestream.Stream[BrowserProcessStdoutStreamResponse] { - query.ID = s.id - return s.inner.StdoutStreamStreaming(ctx, processID, query, opts...) -} - -// BrowserSessionComputerService exposes computer APIs without passing browser id. +// BrowserSessionComputerService exposes browser session APIs without passing browser id. type BrowserSessionComputerService struct { inner BrowserComputerService id string } -func (s BrowserSessionComputerService) Batch(ctx context.Context, body BrowserComputerBatchParams, opts ...option.RequestOption) error { +func (s BrowserSessionComputerService) Batch(ctx context.Context, body BrowserComputerBatchParams, opts ...option.RequestOption) (err error) { return s.inner.Batch(ctx, s.id, body, opts...) } -func (s BrowserSessionComputerService) CaptureScreenshot(ctx context.Context, body BrowserComputerCaptureScreenshotParams, opts ...option.RequestOption) (*http.Response, error) { +func (s BrowserSessionComputerService) CaptureScreenshot(ctx context.Context, body BrowserComputerCaptureScreenshotParams, opts ...option.RequestOption) (res *http.Response, err error) { return s.inner.CaptureScreenshot(ctx, s.id, body, opts...) } -func (s BrowserSessionComputerService) ClickMouse(ctx context.Context, body BrowserComputerClickMouseParams, opts ...option.RequestOption) error { +func (s BrowserSessionComputerService) ClickMouse(ctx context.Context, body BrowserComputerClickMouseParams, opts ...option.RequestOption) (err error) { return s.inner.ClickMouse(ctx, s.id, body, opts...) } -func (s BrowserSessionComputerService) DragMouse(ctx context.Context, body BrowserComputerDragMouseParams, opts ...option.RequestOption) error { +func (s BrowserSessionComputerService) DragMouse(ctx context.Context, body BrowserComputerDragMouseParams, opts ...option.RequestOption) (err error) { return s.inner.DragMouse(ctx, s.id, body, opts...) } -func (s BrowserSessionComputerService) GetMousePosition(ctx context.Context, opts ...option.RequestOption) (*BrowserComputerGetMousePositionResponse, error) { +func (s BrowserSessionComputerService) GetMousePosition(ctx context.Context, opts ...option.RequestOption) (res *BrowserComputerGetMousePositionResponse, err error) { return s.inner.GetMousePosition(ctx, s.id, opts...) } -func (s BrowserSessionComputerService) MoveMouse(ctx context.Context, body BrowserComputerMoveMouseParams, opts ...option.RequestOption) error { +func (s BrowserSessionComputerService) MoveMouse(ctx context.Context, body BrowserComputerMoveMouseParams, opts ...option.RequestOption) (err error) { return s.inner.MoveMouse(ctx, s.id, body, opts...) } -func (s BrowserSessionComputerService) PressKey(ctx context.Context, body BrowserComputerPressKeyParams, opts ...option.RequestOption) error { +func (s BrowserSessionComputerService) PressKey(ctx context.Context, body BrowserComputerPressKeyParams, opts ...option.RequestOption) (err error) { return s.inner.PressKey(ctx, s.id, body, opts...) } -func (s BrowserSessionComputerService) ReadClipboard(ctx context.Context, opts ...option.RequestOption) (*BrowserComputerReadClipboardResponse, error) { +func (s BrowserSessionComputerService) ReadClipboard(ctx context.Context, opts ...option.RequestOption) (res *BrowserComputerReadClipboardResponse, err error) { return s.inner.ReadClipboard(ctx, s.id, opts...) } -func (s BrowserSessionComputerService) Scroll(ctx context.Context, body BrowserComputerScrollParams, opts ...option.RequestOption) error { +func (s BrowserSessionComputerService) Scroll(ctx context.Context, body BrowserComputerScrollParams, opts ...option.RequestOption) (err error) { return s.inner.Scroll(ctx, s.id, body, opts...) } -func (s BrowserSessionComputerService) SetCursorVisibility(ctx context.Context, body BrowserComputerSetCursorVisibilityParams, opts ...option.RequestOption) (*BrowserComputerSetCursorVisibilityResponse, error) { +func (s BrowserSessionComputerService) SetCursorVisibility(ctx context.Context, body BrowserComputerSetCursorVisibilityParams, opts ...option.RequestOption) (res *BrowserComputerSetCursorVisibilityResponse, err error) { return s.inner.SetCursorVisibility(ctx, s.id, body, opts...) } -func (s BrowserSessionComputerService) TypeText(ctx context.Context, body BrowserComputerTypeTextParams, opts ...option.RequestOption) error { +func (s BrowserSessionComputerService) TypeText(ctx context.Context, body BrowserComputerTypeTextParams, opts ...option.RequestOption) (err error) { return s.inner.TypeText(ctx, s.id, body, opts...) } -func (s BrowserSessionComputerService) WriteClipboard(ctx context.Context, body BrowserComputerWriteClipboardParams, opts ...option.RequestOption) error { +func (s BrowserSessionComputerService) WriteClipboard(ctx context.Context, body BrowserComputerWriteClipboardParams, opts ...option.RequestOption) (err error) { return s.inner.WriteClipboard(ctx, s.id, body, opts...) } -// BrowserSessionFService exposes filesystem APIs without passing browser id. +// BrowserSessionFService exposes browser session APIs without passing browser id. type BrowserSessionFService struct { inner BrowserFService id string Watch BrowserSessionFWatchService } -func (s BrowserSessionFService) NewDirectory(ctx context.Context, body BrowserFNewDirectoryParams, opts ...option.RequestOption) error { - return s.inner.NewDirectory(ctx, s.id, body, opts...) -} - -func (s BrowserSessionFService) DeleteDirectory(ctx context.Context, body BrowserFDeleteDirectoryParams, opts ...option.RequestOption) error { +func (s BrowserSessionFService) DeleteDirectory(ctx context.Context, body BrowserFDeleteDirectoryParams, opts ...option.RequestOption) (err error) { return s.inner.DeleteDirectory(ctx, s.id, body, opts...) } -func (s BrowserSessionFService) DeleteFile(ctx context.Context, body BrowserFDeleteFileParams, opts ...option.RequestOption) error { +func (s BrowserSessionFService) DeleteFile(ctx context.Context, body BrowserFDeleteFileParams, opts ...option.RequestOption) (err error) { return s.inner.DeleteFile(ctx, s.id, body, opts...) } -func (s BrowserSessionFService) DownloadDirZip(ctx context.Context, query BrowserFDownloadDirZipParams, opts ...option.RequestOption) (*http.Response, error) { +func (s BrowserSessionFService) DownloadDirZip(ctx context.Context, query BrowserFDownloadDirZipParams, opts ...option.RequestOption) (res *http.Response, err error) { return s.inner.DownloadDirZip(ctx, s.id, query, opts...) } -func (s BrowserSessionFService) FileInfo(ctx context.Context, query BrowserFFileInfoParams, opts ...option.RequestOption) (*BrowserFFileInfoResponse, error) { +func (s BrowserSessionFService) FileInfo(ctx context.Context, query BrowserFFileInfoParams, opts ...option.RequestOption) (res *BrowserFFileInfoResponse, err error) { return s.inner.FileInfo(ctx, s.id, query, opts...) } -func (s BrowserSessionFService) ListFiles(ctx context.Context, query BrowserFListFilesParams, opts ...option.RequestOption) (*[]BrowserFListFilesResponse, error) { +func (s BrowserSessionFService) ListFiles(ctx context.Context, query BrowserFListFilesParams, opts ...option.RequestOption) (res *[]BrowserFListFilesResponse, err error) { return s.inner.ListFiles(ctx, s.id, query, opts...) } -func (s BrowserSessionFService) Move(ctx context.Context, body BrowserFMoveParams, opts ...option.RequestOption) error { +func (s BrowserSessionFService) Move(ctx context.Context, body BrowserFMoveParams, opts ...option.RequestOption) (err error) { return s.inner.Move(ctx, s.id, body, opts...) } -func (s BrowserSessionFService) ReadFile(ctx context.Context, query BrowserFReadFileParams, opts ...option.RequestOption) (*http.Response, error) { +func (s BrowserSessionFService) NewDirectory(ctx context.Context, body BrowserFNewDirectoryParams, opts ...option.RequestOption) (err error) { + return s.inner.NewDirectory(ctx, s.id, body, opts...) +} + +func (s BrowserSessionFService) ReadFile(ctx context.Context, query BrowserFReadFileParams, opts ...option.RequestOption) (res *http.Response, err error) { return s.inner.ReadFile(ctx, s.id, query, opts...) } -func (s BrowserSessionFService) SetFilePermissions(ctx context.Context, body BrowserFSetFilePermissionsParams, opts ...option.RequestOption) error { +func (s BrowserSessionFService) SetFilePermissions(ctx context.Context, body BrowserFSetFilePermissionsParams, opts ...option.RequestOption) (err error) { return s.inner.SetFilePermissions(ctx, s.id, body, opts...) } -func (s BrowserSessionFService) Upload(ctx context.Context, body BrowserFUploadParams, opts ...option.RequestOption) error { +func (s BrowserSessionFService) Upload(ctx context.Context, body BrowserFUploadParams, opts ...option.RequestOption) (err error) { return s.inner.Upload(ctx, s.id, body, opts...) } -func (s BrowserSessionFService) UploadZip(ctx context.Context, body BrowserFUploadZipParams, opts ...option.RequestOption) error { +func (s BrowserSessionFService) UploadZip(ctx context.Context, body BrowserFUploadZipParams, opts ...option.RequestOption) (err error) { return s.inner.UploadZip(ctx, s.id, body, opts...) } -func (s BrowserSessionFService) WriteFile(ctx context.Context, contents io.Reader, params BrowserFWriteFileParams, opts ...option.RequestOption) error { +func (s BrowserSessionFService) WriteFile(ctx context.Context, contents io.Reader, params BrowserFWriteFileParams, opts ...option.RequestOption) (err error) { return s.inner.WriteFile(ctx, s.id, contents, params, opts...) } -// BrowserSessionFWatchService exposes fs watch APIs without passing browser id. +// BrowserSessionFWatchService exposes browser session APIs without passing browser id. type BrowserSessionFWatchService struct { inner BrowserFWatchService id string } -func (s BrowserSessionFWatchService) EventsStreaming(ctx context.Context, watchID string, query BrowserFWatchEventsParams, opts ...option.RequestOption) *ssestream.Stream[BrowserFWatchEventsResponse] { +func (s BrowserSessionFWatchService) EventsStreaming(ctx context.Context, watchID string, query BrowserFWatchEventsParams, opts ...option.RequestOption) (stream *ssestream.Stream[BrowserFWatchEventsResponse]) { query.ID = s.id return s.inner.EventsStreaming(ctx, watchID, query, opts...) } -func (s BrowserSessionFWatchService) Start(ctx context.Context, body BrowserFWatchStartParams, opts ...option.RequestOption) (*BrowserFWatchStartResponse, error) { +func (s BrowserSessionFWatchService) Start(ctx context.Context, body BrowserFWatchStartParams, opts ...option.RequestOption) (res *BrowserFWatchStartResponse, err error) { return s.inner.Start(ctx, s.id, body, opts...) } -func (s BrowserSessionFWatchService) Stop(ctx context.Context, watchID string, body BrowserFWatchStopParams, opts ...option.RequestOption) error { +func (s BrowserSessionFWatchService) Stop(ctx context.Context, watchID string, body BrowserFWatchStopParams, opts ...option.RequestOption) (err error) { body.ID = s.id return s.inner.Stop(ctx, watchID, body, opts...) } -// BrowserSessionReplayService exposes replay APIs without passing browser id. -type BrowserSessionReplayService struct { - inner BrowserReplayService +// BrowserSessionLogService exposes browser session APIs without passing browser id. +type BrowserSessionLogService struct { + inner BrowserLogService id string } -func (s BrowserSessionReplayService) List(ctx context.Context, opts ...option.RequestOption) (*[]BrowserReplayListResponse, error) { - return s.inner.List(ctx, s.id, opts...) +func (s BrowserSessionLogService) StreamStreaming(ctx context.Context, query BrowserLogStreamParams, opts ...option.RequestOption) (stream *ssestream.Stream[shared.LogEvent]) { + return s.inner.StreamStreaming(ctx, s.id, query, opts...) } -func (s BrowserSessionReplayService) Download(ctx context.Context, replayID string, query BrowserReplayDownloadParams, opts ...option.RequestOption) (*http.Response, error) { +// BrowserSessionPlaywrightService exposes browser session APIs without passing browser id. +type BrowserSessionPlaywrightService struct { + inner BrowserPlaywrightService + id string +} + +func (s BrowserSessionPlaywrightService) Execute(ctx context.Context, body BrowserPlaywrightExecuteParams, opts ...option.RequestOption) (res *BrowserPlaywrightExecuteResponse, err error) { + return s.inner.Execute(ctx, s.id, body, opts...) +} + +// BrowserSessionProcessService exposes browser session APIs without passing browser id. +type BrowserSessionProcessService struct { + inner BrowserProcessService + id string +} + +func (s BrowserSessionProcessService) Exec(ctx context.Context, body BrowserProcessExecParams, opts ...option.RequestOption) (res *BrowserProcessExecResponse, err error) { + return s.inner.Exec(ctx, s.id, body, opts...) +} + +func (s BrowserSessionProcessService) Kill(ctx context.Context, processID string, params BrowserProcessKillParams, opts ...option.RequestOption) (res *BrowserProcessKillResponse, err error) { + params.ID = s.id + return s.inner.Kill(ctx, processID, params, opts...) +} + +func (s BrowserSessionProcessService) Resize(ctx context.Context, processID string, params BrowserProcessResizeParams, opts ...option.RequestOption) (res *BrowserProcessResizeResponse, err error) { + params.ID = s.id + return s.inner.Resize(ctx, processID, params, opts...) +} + +func (s BrowserSessionProcessService) Spawn(ctx context.Context, body BrowserProcessSpawnParams, opts ...option.RequestOption) (res *BrowserProcessSpawnResponse, err error) { + return s.inner.Spawn(ctx, s.id, body, opts...) +} + +func (s BrowserSessionProcessService) Status(ctx context.Context, processID string, query BrowserProcessStatusParams, opts ...option.RequestOption) (res *BrowserProcessStatusResponse, err error) { query.ID = s.id - return s.inner.Download(ctx, replayID, query, opts...) + return s.inner.Status(ctx, processID, query, opts...) } -func (s BrowserSessionReplayService) Start(ctx context.Context, body BrowserReplayStartParams, opts ...option.RequestOption) (*BrowserReplayStartResponse, error) { - return s.inner.Start(ctx, s.id, body, opts...) +func (s BrowserSessionProcessService) Stdin(ctx context.Context, processID string, params BrowserProcessStdinParams, opts ...option.RequestOption) (res *BrowserProcessStdinResponse, err error) { + params.ID = s.id + return s.inner.Stdin(ctx, processID, params, opts...) } -func (s BrowserSessionReplayService) Stop(ctx context.Context, replayID string, body BrowserReplayStopParams, opts ...option.RequestOption) error { - body.ID = s.id - return s.inner.Stop(ctx, replayID, body, opts...) +func (s BrowserSessionProcessService) StdoutStreamStreaming(ctx context.Context, processID string, query BrowserProcessStdoutStreamParams, opts ...option.RequestOption) (stream *ssestream.Stream[BrowserProcessStdoutStreamResponse]) { + query.ID = s.id + return s.inner.StdoutStreamStreaming(ctx, processID, query, opts...) } -// BrowserSessionLogService exposes log streaming without passing browser id. -type BrowserSessionLogService struct { - inner BrowserLogService +// BrowserSessionReplayService exposes browser session APIs without passing browser id. +type BrowserSessionReplayService struct { + inner BrowserReplayService id string } -func (s BrowserSessionLogService) StreamStreaming(ctx context.Context, query BrowserLogStreamParams, opts ...option.RequestOption) *ssestream.Stream[shared.LogEvent] { - return s.inner.StreamStreaming(ctx, s.id, query, opts...) +func (s BrowserSessionReplayService) Download(ctx context.Context, replayID string, query BrowserReplayDownloadParams, opts ...option.RequestOption) (res *http.Response, err error) { + query.ID = s.id + return s.inner.Download(ctx, replayID, query, opts...) } -// BrowserSessionPlaywrightService exposes Playwright execution without passing browser id. -type BrowserSessionPlaywrightService struct { - inner BrowserPlaywrightService - id string +func (s BrowserSessionReplayService) List(ctx context.Context, opts ...option.RequestOption) (res *[]BrowserReplayListResponse, err error) { + return s.inner.List(ctx, s.id, opts...) } -func (s BrowserSessionPlaywrightService) Execute(ctx context.Context, body BrowserPlaywrightExecuteParams, opts ...option.RequestOption) (*BrowserPlaywrightExecuteResponse, error) { - return s.inner.Execute(ctx, s.id, body, opts...) +func (s BrowserSessionReplayService) Start(ctx context.Context, body BrowserReplayStartParams, opts ...option.RequestOption) (res *BrowserReplayStartResponse, err error) { + return s.inner.Start(ctx, s.id, body, opts...) +} + +func (s BrowserSessionReplayService) Stop(ctx context.Context, replayID string, body BrowserReplayStopParams, opts ...option.RequestOption) (err error) { + body.ID = s.id + return s.inner.Stop(ctx, replayID, body, opts...) +} + +// BrowserSessionClient is a browser-scoped view of a browser session. Subresources +// use the session base_url and do not repeat the browser id in method +// signatures. SessionID is exposed for future routing extensions. +type BrowserSessionClient struct { + sessionID string + opts []option.RequestOption + kernelBase string + jwt string + Replays BrowserSessionReplayService + Fs BrowserSessionFService + Process BrowserSessionProcessService + Logs BrowserSessionLogService + Computer BrowserSessionComputerService + Playwright BrowserSessionPlaywrightService +} + +func newBrowserSessionClient(sessionID, kernelBase, jwt string, scoped []option.RequestOption) *BrowserSessionClient { + innerFs := NewBrowserFService(scoped...) + return &BrowserSessionClient{ + sessionID: sessionID, + opts: scoped, + kernelBase: kernelBase, + jwt: jwt, + Replays: BrowserSessionReplayService{inner: NewBrowserReplayService(scoped...), id: sessionID}, + Fs: BrowserSessionFService{ + inner: innerFs, + id: sessionID, + Watch: BrowserSessionFWatchService{inner: innerFs.Watch, id: sessionID}, + }, + Process: BrowserSessionProcessService{inner: NewBrowserProcessService(scoped...), id: sessionID}, + Logs: BrowserSessionLogService{inner: NewBrowserLogService(scoped...), id: sessionID}, + Computer: BrowserSessionComputerService{inner: NewBrowserComputerService(scoped...), id: sessionID}, + Playwright: BrowserSessionPlaywrightService{inner: NewBrowserPlaywrightService(scoped...), id: sessionID}, + } } diff --git a/go.mod b/go.mod index e5d1e20..cd5b1e5 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,16 @@ module github.com/kernel/kernel-go-sdk -go 1.22 +go 1.23.0 require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 + golang.org/x/tools v0.31.0 ) require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect + golang.org/x/mod v0.24.0 // indirect + golang.org/x/sync v0.12.0 // indirect ) diff --git a/go.sum b/go.sum index 32ba293..abdea0d 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -8,3 +10,9 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= +golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= diff --git a/internal/genbrowsersessionservices/generate_test.go b/internal/genbrowsersessionservices/generate_test.go new file mode 100644 index 0000000..9499f40 --- /dev/null +++ b/internal/genbrowsersessionservices/generate_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "bytes" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestGenerateDeterministic(t *testing.T) { + root := moduleRoot(t) + a, err := Generate(root) + if err != nil { + t.Fatal(err) + } + b, err := Generate(root) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(a, b) { + t.Fatal("Generate is not deterministic for identical inputs") + } +} + +func TestGenerateIncludesExpectedServices(t *testing.T) { + root := moduleRoot(t) + src, err := Generate(root) + if err != nil { + t.Fatal(err) + } + text := string(src) + for _, want := range []string{ + "type BrowserSessionClient struct", + "func newBrowserSessionClient(sessionID, kernelBase, jwt string, scoped []option.RequestOption) *BrowserSessionClient", + "\tReplays BrowserSessionReplayService", + "\tinnerFs := NewBrowserFService(scoped...)", + "type BrowserSessionComputerService struct", + "type BrowserSessionFService struct", + "type BrowserSessionFWatchService struct", + "type BrowserSessionLogService struct", + "type BrowserSessionPlaywrightService struct", + "type BrowserSessionProcessService struct", + "type BrowserSessionReplayService struct", + "func (s BrowserSessionProcessService) Kill(", + "params.ID = s.id", + "func (s BrowserSessionFService) WriteFile(ctx context.Context, contents io.Reader, params BrowserFWriteFileParams, opts ...option.RequestOption)", + } { + if !strings.Contains(text, want) { + t.Fatalf("generated source missing %q", want) + } + } +} + +func moduleRoot(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller") + } + // internal/genbrowsersessionservices/generate_test.go -> repo root + return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) +} diff --git a/internal/genbrowsersessionservices/main.go b/internal/genbrowsersessionservices/main.go new file mode 100644 index 0000000..796fcbf --- /dev/null +++ b/internal/genbrowsersessionservices/main.go @@ -0,0 +1,623 @@ +// Command genbrowsersessionservices emits browser_session_services_gen.go by +// analyzing BrowserService and nested *Browser*Service types in this module. +package main + +import ( + "bytes" + "flag" + "fmt" + "go/format" + "go/types" + "os" + "path/filepath" + "sort" + "strings" + + "golang.org/x/tools/go/packages" +) + +// browserSessionGenLoadStub provides placeholder BrowserSession* service types so +// the kernel package type-checks while this file is regenerated (see Overlay in Generate). +const browserSessionGenLoadStub = `package kernel + +import "github.com/kernel/kernel-go-sdk/option" + +type BrowserSessionClient struct { + sessionID string + opts []option.RequestOption + kernelBase string + jwt string +} + +func newBrowserSessionClient(sessionID, kernelBase, jwt string, scoped []option.RequestOption) *BrowserSessionClient { + return &BrowserSessionClient{sessionID: sessionID, opts: scoped, kernelBase: kernelBase, jwt: jwt} +} + +type BrowserSessionReplayService struct { + inner BrowserReplayService + id string +} +type BrowserSessionFService struct { + inner BrowserFService + id string + Watch BrowserSessionFWatchService +} +type BrowserSessionFWatchService struct { + inner BrowserFWatchService + id string +} +type BrowserSessionProcessService struct { + inner BrowserProcessService + id string +} +type BrowserSessionLogService struct { + inner BrowserLogService + id string +} +type BrowserSessionComputerService struct { + inner BrowserComputerService + id string +} +type BrowserSessionPlaywrightService struct { + inner BrowserPlaywrightService + id string +} +` + +func main() { + outPath := flag.String("output", "browser_session_services_gen.go", "path to write generated file (relative to -dir)") + dir := flag.String("dir", "", "module root (default: working directory)") + flag.Parse() + + root := *dir + if root == "" { + wd, err := os.Getwd() + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + root = wd + } + + src, err := Generate(root) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + path := *outPath + if !filepath.IsAbs(path) { + path = filepath.Join(root, path) + } + if err := os.WriteFile(path, src, 0o644); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +// Generate returns formatted Go source for browser session service wrappers. +func Generate(moduleRoot string) ([]byte, error) { + genPath := filepath.Join(moduleRoot, "browser_session_services_gen.go") + genAbs, err := filepath.Abs(genPath) + if err != nil { + return nil, err + } + cfg := &packages.Config{ + Mode: packages.NeedTypes | packages.NeedImports | packages.NeedName | packages.NeedModule, + Dir: moduleRoot, + Overlay: map[string][]byte{ + // Placeholder types so browser_session.go type-checks while the real file is regenerated. + genAbs: []byte(browserSessionGenLoadStub), + }, + } + pkgs, err := packages.Load(cfg, ".") + if err != nil { + return nil, err + } + if len(pkgs) != 1 { + return nil, fmt.Errorf("expected 1 package in %s, got %d", moduleRoot, len(pkgs)) + } + pkg := pkgs[0] + if len(pkg.Errors) > 0 { + return nil, fmt.Errorf("package load errors: %v", pkg.Errors) + } + if pkg.Name != "kernel" { + return nil, fmt.Errorf("expected package kernel, got %s", pkg.Name) + } + + services, err := discoverBrowserSessionServices(pkg) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + buf.WriteString(`// Code generated by internal/genbrowsersessionservices; DO NOT EDIT. + +package kernel + +import ( + "context" + "io" + "net/http" + + "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/ssestream" + "github.com/kernel/kernel-go-sdk/shared" +) +`) + + for _, s := range services { + emitService(&buf, pkg, s) + } + + topFields, err := discoverBrowserSessionClientFields(pkg) + if err != nil { + return nil, err + } + emitBrowserSessionClient(&buf, topFields) + + out := buf.Bytes() + formatted, err := format.Source(out) + if err != nil { + return nil, fmt.Errorf("format: %w\n%s", err, string(out)) + } + return formatted, nil +} + +type serviceInfo struct { + innerName string + sessionName string + named *types.Named + nested []nestedFieldInfo +} + +type nestedFieldInfo struct { + fieldName string + inner *types.Named + session string +} + +// clientTopField is one exported subresource field on [BrowserSessionClient], +// aligned with a [BrowserService] service field (same name and inner type). +type clientTopField struct { + fieldName string // e.g. Replays, Fs + innerName string // e.g. BrowserReplayService +} + +func discoverBrowserSessionClientFields(pkg *packages.Package) ([]clientTopField, error) { + obj := pkg.Types.Scope().Lookup("BrowserService") + if obj == nil { + return nil, fmt.Errorf("BrowserService not found") + } + named, ok := obj.Type().(*types.Named) + if !ok { + return nil, fmt.Errorf("BrowserService is not a named type") + } + st, ok := named.Underlying().(*types.Struct) + if !ok { + return nil, fmt.Errorf("BrowserService underlying is not a struct") + } + var out []clientTopField + for i := 0; i < st.NumFields(); i++ { + f := st.Field(i) + if !f.Exported() || f.Name() == "Options" { + continue + } + nn := derefNamed(f.Type()) + if nn == nil { + continue + } + name := nn.Obj().Name() + if !strings.HasPrefix(name, "Browser") || !strings.HasSuffix(name, "Service") { + continue + } + out = append(out, clientTopField{fieldName: f.Name(), innerName: name}) + } + if len(out) == 0 { + return nil, fmt.Errorf("no browser session subresource fields found on BrowserService") + } + return out, nil +} + +func emitBrowserSessionClient(buf *bytes.Buffer, top []clientTopField) { + buf.WriteString(` + +// BrowserSessionClient is a browser-scoped view of a browser session. Subresources +// use the session base_url and do not repeat the browser id in method +// signatures. SessionID is exposed for future routing extensions. +type BrowserSessionClient struct { + sessionID string + opts []option.RequestOption + kernelBase string + jwt string +`) + for _, f := range top { + buf.WriteByte('\t') + buf.WriteString(f.fieldName) + buf.WriteByte(' ') + buf.WriteString(sessionWrapperName(f.innerName)) + buf.WriteByte('\n') + } + buf.WriteString("}\n") + + buf.WriteString(` +func newBrowserSessionClient(sessionID, kernelBase, jwt string, scoped []option.RequestOption) *BrowserSessionClient { +`) + + hasFS := false + for _, f := range top { + if f.innerName == "BrowserFService" { + hasFS = true + break + } + } + if hasFS { + buf.WriteString("\tinnerFs := NewBrowserFService(scoped...)\n") + } + + buf.WriteString("\treturn &BrowserSessionClient{\n") + buf.WriteString("\t\tsessionID: sessionID,\n") + buf.WriteString("\t\topts: scoped,\n") + buf.WriteString("\t\tkernelBase: kernelBase,\n") + buf.WriteString("\t\tjwt: jwt,\n") + + for _, f := range top { + if f.innerName == "BrowserFService" { + buf.WriteString("\t\tFs: BrowserSessionFService{\n") + buf.WriteString("\t\t\tinner: innerFs,\n") + buf.WriteString("\t\t\tid: sessionID,\n") + buf.WriteString("\t\t\tWatch: BrowserSessionFWatchService{inner: innerFs.Watch, id: sessionID},\n") + buf.WriteString("\t\t},\n") + continue + } + wrap := sessionWrapperName(f.innerName) + newFn := "New" + f.innerName + buf.WriteByte('\t') + buf.WriteByte('\t') + buf.WriteString(f.fieldName) + buf.WriteString(": ") + buf.WriteString(wrap) + buf.WriteString("{inner: ") + buf.WriteString(newFn) + buf.WriteString("(scoped...), id: sessionID},\n") + } + buf.WriteString("\t}\n}\n") +} + +func discoverBrowserSessionServices(pkg *packages.Package) ([]serviceInfo, error) { + obj := pkg.Types.Scope().Lookup("BrowserService") + if obj == nil { + return nil, fmt.Errorf("BrowserService not found") + } + named, ok := obj.Type().(*types.Named) + if !ok { + return nil, fmt.Errorf("BrowserService is not a named type") + } + + seen := map[string]*types.Named{} + var walk func(*types.Named) + walk = func(n *types.Named) { + name := n.Obj().Name() + if name == "BrowserService" { + // descend without recording + } else { + if !strings.HasPrefix(name, "Browser") || !strings.HasSuffix(name, "Service") { + return + } + if _, dup := seen[name]; dup { + return + } + seen[name] = n + } + st, ok := n.Underlying().(*types.Struct) + if !ok { + return + } + for i := 0; i < st.NumFields(); i++ { + f := st.Field(i) + if !f.Exported() || f.Embedded() { + continue + } + if f.Name() == "Options" { + continue + } + nn := derefNamed(f.Type()) + if nn == nil { + continue + } + walk(nn) + } + } + walk(named) + + var names []string + for n := range seen { + names = append(names, n) + } + sort.Strings(names) + + var out []serviceInfo + for _, name := range names { + n := seen[name] + st := n.Underlying().(*types.Struct) + var nested []nestedFieldInfo + for i := 0; i < st.NumFields(); i++ { + f := st.Field(i) + if !f.Exported() || f.Embedded() || f.Name() == "Options" { + continue + } + nn := derefNamed(f.Type()) + if nn == nil { + continue + } + fn := nn.Obj().Name() + if !strings.HasPrefix(fn, "Browser") || !strings.HasSuffix(fn, "Service") { + continue + } + if _, ok := seen[fn]; !ok { + continue + } + nested = append(nested, nestedFieldInfo{ + fieldName: f.Name(), + inner: nn, + session: sessionWrapperName(fn), + }) + } + sort.Slice(nested, func(i, j int) bool { return nested[i].fieldName < nested[j].fieldName }) + out = append(out, serviceInfo{ + innerName: name, + sessionName: sessionWrapperName(name), + named: n, + nested: nested, + }) + } + return out, nil +} + +func sessionWrapperName(innerService string) string { + mid := strings.TrimSuffix(strings.TrimPrefix(innerService, "Browser"), "Service") + return "BrowserSession" + mid + "Service" +} + +func derefNamed(t types.Type) *types.Named { + for { + switch u := t.(type) { + case *types.Pointer: + t = u.Elem() + default: + n, ok := t.(*types.Named) + if !ok { + return nil + } + return n + } + } +} + +func emitService(buf *bytes.Buffer, pkg *packages.Package, s serviceInfo) { + buf.WriteString("\n// ") + buf.WriteString(s.sessionName) + buf.WriteString(" exposes browser session APIs without passing browser id.\n") + buf.WriteString("type ") + buf.WriteString(s.sessionName) + buf.WriteString(" struct {\n") + buf.WriteString("\tinner ") + buf.WriteString(s.innerName) + buf.WriteString("\n") + buf.WriteString("\tid string\n") + for _, nf := range s.nested { + buf.WriteString("\t") + buf.WriteString(nf.fieldName) + buf.WriteString(" ") + buf.WriteString(nf.session) + buf.WriteString("\n") + } + buf.WriteString("}\n") + + ms := types.NewMethodSet(types.NewPointer(s.named)) + var methods []*types.Func + for i := 0; i < ms.Len(); i++ { + sel := ms.At(i) + fn, ok := sel.Obj().(*types.Func) + if !ok || !fn.Exported() { + continue + } + methods = append(methods, fn) + } + sort.Slice(methods, func(i, j int) bool { return methods[i].Name() < methods[j].Name() }) + + for _, fn := range methods { + sig := fn.Type().(*types.Signature) + emitMethod(buf, pkg, s.sessionName, sig, fn.Name()) + } +} + +// writeWrapperSignature writes "(params) results" for a browser session wrapper method. +// go/types.WriteSignature encodes final variadic parameters as slices; callers expect +// "opts ...option.RequestOption" like the underlying Stainless services. +func writeWrapperSignature(buf *bytes.Buffer, sig *types.Signature, q types.Qualifier) { + buf.WriteByte('(') + params := sig.Params() + for i := 0; i < params.Len(); i++ { + if i > 0 { + buf.WriteString(", ") + } + p := params.At(i) + if p.Name() != "" { + buf.WriteString(p.Name()) + buf.WriteByte(' ') + } + if sig.Variadic() && i == params.Len()-1 { + slice := p.Type().(*types.Slice) + buf.WriteString("...") + buf.WriteString(types.TypeString(slice.Elem(), q)) + continue + } + buf.WriteString(types.TypeString(p.Type(), q)) + } + buf.WriteByte(')') + writeResults(buf, sig.Results(), q) +} + +func writeResults(buf *bytes.Buffer, results *types.Tuple, q types.Qualifier) { + switch results.Len() { + case 0: + return + case 1: + r := results.At(0) + if r.Name() == "" { + buf.WriteByte(' ') + buf.WriteString(types.TypeString(r.Type(), q)) + return + } + fallthrough + default: + buf.WriteString(" (") + for i := 0; i < results.Len(); i++ { + if i > 0 { + buf.WriteString(", ") + } + r := results.At(i) + if r.Name() != "" { + buf.WriteString(r.Name()) + buf.WriteByte(' ') + } + buf.WriteString(types.TypeString(r.Type(), q)) + } + buf.WriteByte(')') + } +} + +func emitMethod(buf *bytes.Buffer, pkg *packages.Package, sessionName string, innerSig *types.Signature, methodName string) { + params := innerSig.Params() + if params.Len() < 1 || !isContext(params.At(0).Type()) { + return + } + + qual := qualifier(pkg.Types) + wrapperSig := types.NewSignature(nil, wrapperParamTuple(innerSig), innerSig.Results(), innerSig.Variadic()) + + var preStmts []string + var callArgs []string + callArgs = append(callArgs, "ctx") + + for i := 1; i < params.Len(); i++ { + p := params.At(i) + pname := paramName(p, i) + if isVariadicOpts(innerSig, i) { + callArgs = append(callArgs, pname+"...") + break + } + if p.Name() == "id" && isStringType(p.Type()) { + callArgs = append(callArgs, "s.id") + continue + } + if hasStringIDField(p.Type()) { + preStmts = append(preStmts, fmt.Sprintf("\t%s.ID = s.id", pname)) + callArgs = append(callArgs, pname) + continue + } + callArgs = append(callArgs, pname) + } + + fmt.Fprintf(buf, "\nfunc (s %s) %s", sessionName, methodName) + writeWrapperSignature(buf, wrapperSig, qual) + buf.WriteString(" {\n") + for _, ln := range preStmts { + buf.WriteString(ln) + buf.WriteByte('\n') + } + innerCall := fmt.Sprintf("s.inner.%s(%s)", methodName, strings.Join(callArgs, ", ")) + if innerSig.Results().Len() == 0 { + fmt.Fprintf(buf, "\t%s\n", innerCall) + } else { + fmt.Fprintf(buf, "\treturn %s\n", innerCall) + } + buf.WriteString("}\n") +} + +// wrapperParamTuple is the inner API signature with the browser id path parameter removed. +func wrapperParamTuple(sig *types.Signature) *types.Tuple { + params := sig.Params() + var vars []*types.Var + vars = append(vars, params.At(0)) + last := params.Len() - 1 + for i := 1; i < params.Len(); i++ { + p := params.At(i) + if sig.Variadic() && i == last { + vars = append(vars, p) + break + } + if p.Name() == "id" && isStringType(p.Type()) { + continue + } + vars = append(vars, p) + } + return types.NewTuple(vars...) +} + +func paramName(p *types.Var, idx int) string { + if p.Name() != "" { + return p.Name() + } + return fmt.Sprintf("arg%d", idx) +} + +func isVariadicOpts(sig *types.Signature, idx int) bool { + if !sig.Variadic() || idx != sig.Params().Len()-1 { + return false + } + _, ok := sig.Params().At(idx).Type().(*types.Slice) + return ok +} + +func isContext(t types.Type) bool { + named, ok := t.(*types.Named) + if !ok { + return false + } + return named.Obj().Pkg() != nil && named.Obj().Pkg().Path() == "context" && named.Obj().Name() == "Context" +} + +func isStringType(t types.Type) bool { + b, ok := t.Underlying().(*types.Basic) + return ok && b.Kind() == types.String +} + +func hasStringIDField(t types.Type) bool { + str, ok := derefStruct(t) + if !ok { + return false + } + for i := 0; i < str.NumFields(); i++ { + f := str.Field(i) + if f.Name() == "ID" && isStringType(f.Type()) { + return true + } + } + return false +} + +func derefStruct(t types.Type) (*types.Struct, bool) { + if p, ok := t.(*types.Pointer); ok { + t = p.Elem() + } + switch u := t.(type) { + case *types.Named: + s, ok := u.Underlying().(*types.Struct) + return s, ok + case *types.Struct: + return u, true + default: + return nil, false + } +} + +func qualifier(pkg *types.Package) types.Qualifier { + return func(other *types.Package) string { + if other == pkg { + return "" + } + return other.Name() + } +} diff --git a/scripts/generate-browser-session b/scripts/generate-browser-session new file mode 100755 index 0000000..5d5479a --- /dev/null +++ b/scripts/generate-browser-session @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +go run ./internal/genbrowsersessionservices -output browser_session_services_gen.go diff --git a/scripts/lint b/scripts/lint index 7e03a7b..e577c0d 100755 --- a/scripts/lint +++ b/scripts/lint @@ -4,6 +4,12 @@ set -e cd "$(dirname "$0")/.." +echo "==> Regenerating browser-scoped bindings" +./scripts/generate-browser-session + +echo "==> Verifying generated browser-scoped bindings are committed" +git diff --exit-code -- browser_session_services_gen.go + echo "==> Running Go build" go build ./... From 92dc96e99e14eeb9eb6881115f330369bb3c7542 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Tue, 21 Apr 2026 16:24:57 -0400 Subject: [PATCH 06/20] docs: add browser-scoped raw http example Show the browser-scoped HTTPClient flow explicitly so the /curl/raw-backed public API is discoverable from a runnable Go example. Made-with: Cursor --- examples/browser-scoped/main.go | 44 +++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 examples/browser-scoped/main.go diff --git a/examples/browser-scoped/main.go b/examples/browser-scoped/main.go new file mode 100644 index 0000000..9f34f1e --- /dev/null +++ b/examples/browser-scoped/main.go @@ -0,0 +1,44 @@ +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) + }() + + scoped, err := client.ForBrowser(browser) + if err != nil { + panic(err) + } + + httpClient := scoped.HTTPClient() + 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) +} From 3452e537737576fdb645a4e5de0d9147e50cb33b Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 22 Apr 2026 10:58:03 -0400 Subject: [PATCH 07/20] refactor: remove browser session wrapper layer Route browser subresources and raw HTTP through the shared browser route cache so the SDK no longer needs the generated browser-scoped client surface. Made-with: Cursor --- browser.go | 37 ++ browser_routing.go | 71 ++ browser_routing_test.go | 113 ++++ browser_session.go | 82 --- browser_session_httpclient_test.go | 26 +- browser_session_services_gen.go | 261 -------- browser_session_test.go | 80 --- client.go | 12 +- examples/browser-scoped/main.go | 11 +- .../generate_test.go | 63 -- internal/genbrowsersessionservices/main.go | 623 ------------------ lib/browserscope/integration_test.go | 19 +- lib/browserscope/route_cache.go | 153 +++++ scripts/generate-browser-session | 7 - scripts/lint | 6 - 15 files changed, 426 insertions(+), 1138 deletions(-) create mode 100644 browser_routing.go create mode 100644 browser_routing_test.go delete mode 100644 browser_session.go delete mode 100644 browser_session_services_gen.go delete mode 100644 browser_session_test.go delete mode 100644 internal/genbrowsersessionservices/generate_test.go delete mode 100644 internal/genbrowsersessionservices/main.go create mode 100644 lib/browserscope/route_cache.go delete mode 100755 scripts/generate-browser-session diff --git a/browser.go b/browser.go index 4533fc1..1423df6 100644 --- a/browser.go +++ b/browser.go @@ -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/browserscope" "github.com/kernel/kernel-go-sdk/option" "github.com/kernel/kernel-go-sdk/packages/pagination" "github.com/kernel/kernel-go-sdk/packages/param" @@ -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 { + primeBrowserRouteCache(opts, browserscope.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL}) + } return res, err } @@ -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 { + primeBrowserRouteCache(opts, browserscope.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL}) + } return res, err } @@ -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 { + primeBrowserRouteCache(opts, browserscope.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL}) + } return res, err } @@ -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 { + primeBrowserRouteCache(opts, browserscope.Ref{SessionID: item.SessionID, BaseURL: item.BaseURL, CdpWsURL: item.CdpWsURL}) + } return res, nil } @@ -147,6 +160,25 @@ func (r *BrowserService) Curl(ctx context.Context, id string, body BrowserCurlPa return res, err } +// 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.PreRequestOptions(opts...) + if err != nil { + return browserscope.HTTPClient(route.BaseURL, route.JWT, nil), nil + } + return browserscope.HTTPClient(route.BaseURL, route.JWT, cfg.HTTPClient), nil +} + // Delete a browser session by ID func (r *BrowserService) DeleteByID(ctx context.Context, id string, opts ...option.RequestOption) (err error) { opts = slices.Concat(r.Options, opts) @@ -157,6 +189,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 } diff --git a/browser_routing.go b/browser_routing.go new file mode 100644 index 0000000..3c30647 --- /dev/null +++ b/browser_routing.go @@ -0,0 +1,71 @@ +package kernel + +import ( + "github.com/kernel/kernel-go-sdk/internal/requestconfig" + "github.com/kernel/kernel-go-sdk/lib/browserscope" + "github.com/kernel/kernel-go-sdk/option" +) + +// BrowserRoutingConfig controls which browser subresources route directly to the browser VM. +type BrowserRoutingConfig struct { + Enabled bool + DirectToVMSubresources []string +} + +type browserRoutingOption struct { + cache *browserscope.RouteCache + config BrowserRoutingConfig +} + +type browserRouteCacheOption struct { + cache *browserscope.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, browserscope.DirectVMRoutingMiddleware(o.cache, o.config.DirectToVMSubresources)) + return nil +} + +func (o *browserRoutingOption) browserRouteCache() *browserscope.RouteCache { + return o.cache +} + +func (o *browserRouteCacheOption) Apply(*requestconfig.RequestConfig) error { + return nil +} + +func (o *browserRouteCacheOption) browserRouteCache() *browserscope.RouteCache { + return o.cache +} + +func withBrowserRouteCache(cache *browserscope.RouteCache) option.RequestOption { + return &browserRouteCacheOption{cache: cache} +} + +func browserRouteCacheFromOptions(opts []option.RequestOption) *browserscope.RouteCache { + for _, opt := range opts { + if carrier, ok := opt.(interface{ browserRouteCache() *browserscope.RouteCache }); ok { + if cache := carrier.browserRouteCache(); cache != nil { + return cache + } + } + } + return nil +} + +func primeBrowserRouteCache(opts []option.RequestOption, refs ...browserscope.Ref) { + cache := browserRouteCacheFromOptions(opts) + for _, ref := range refs { + if cache != nil { + _ = cache.Prime(ref) + } + } +} diff --git a/browser_routing_test.go b/browser_routing_test.go new file mode 100644 index 0000000..4e0802b --- /dev/null +++ b/browser_routing_test.go @@ -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, DirectToVMSubresources: []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, DirectToVMSubresources: []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) + } +} diff --git a/browser_session.go b/browser_session.go deleted file mode 100644 index c541f01..0000000 --- a/browser_session.go +++ /dev/null @@ -1,82 +0,0 @@ -package kernel - -//go:generate go run ./internal/genbrowsersessionservices -output browser_session_services_gen.go - -import ( - "fmt" - "net/http" - "slices" - - "github.com/kernel/kernel-go-sdk/internal/requestconfig" - "github.com/kernel/kernel-go-sdk/lib/browserscope" - "github.com/kernel/kernel-go-sdk/option" -) - -// SessionID returns the control-plane browser session id. -func (b *BrowserSessionClient) SessionID() string { return b.sessionID } - -// HTTPClient returns an [http.Client] that performs egress HTTP through the -// browser's Chrome network stack via the browser session /curl/raw proxy. Each -// request must use an absolute http(s) URL; it is not rewritten to expose -// /curl/raw in the public API. -func (b *BrowserSessionClient) HTTPClient() *http.Client { - cfg, err := requestconfig.PreRequestOptions(b.opts...) - if err != nil { - return browserscope.HTTPClient(b.kernelBase, b.jwt, nil) - } - return browserscope.HTTPClient(b.kernelBase, b.jwt, cfg.HTTPClient) -} - -// ForBrowser returns a [BrowserSessionClient] for the given browser value. -// Supported types are [browserscope.Ref], [*BrowserNewResponse], [*BrowserGetResponse], -// [*BrowserListResponse], and [*BrowserUpdateResponse]. -func (c *Client) ForBrowser(v any, opts ...option.RequestOption) (*BrowserSessionClient, error) { - ref, err := browserSessionRefFrom(v) - if err != nil { - return nil, err - } - norm, err := ref.Normalize() - if err != nil { - return nil, err - } - - scoped := slices.Concat( - c.Options, - []option.RequestOption{ - option.WithBaseURL(norm.BaseURL), - option.WithMiddleware(browserscope.BrowserSessionMiddleware(norm.SessionID, norm.JWT)), - }, - opts, - ) - - return newBrowserSessionClient(norm.SessionID, norm.BaseURL, norm.JWT, scoped), nil -} - -func browserSessionRefFrom(v any) (browserscope.Ref, error) { - switch t := v.(type) { - case browserscope.Ref: - return t, nil - case *BrowserNewResponse: - if t == nil { - return browserscope.Ref{}, fmt.Errorf("kernel: ForBrowser: nil *BrowserNewResponse") - } - return browserscope.Ref{SessionID: t.SessionID, BaseURL: t.BaseURL, CdpWsURL: t.CdpWsURL}, nil - case *BrowserGetResponse: - if t == nil { - return browserscope.Ref{}, fmt.Errorf("kernel: ForBrowser: nil *BrowserGetResponse") - } - return browserscope.Ref{SessionID: t.SessionID, BaseURL: t.BaseURL, CdpWsURL: t.CdpWsURL}, nil - case *BrowserListResponse: - if t == nil { - return browserscope.Ref{}, fmt.Errorf("kernel: ForBrowser: nil *BrowserListResponse") - } - return browserscope.Ref{SessionID: t.SessionID, BaseURL: t.BaseURL, CdpWsURL: t.CdpWsURL}, nil - case *BrowserUpdateResponse: - if t == nil { - return browserscope.Ref{}, fmt.Errorf("kernel: ForBrowser: nil *BrowserUpdateResponse") - } - return browserscope.Ref{SessionID: t.SessionID, BaseURL: t.BaseURL, CdpWsURL: t.CdpWsURL}, nil - default: - return browserscope.Ref{}, fmt.Errorf("kernel: ForBrowser: unsupported type %T", v) - } -} diff --git a/browser_session_httpclient_test.go b/browser_session_httpclient_test.go index 99fb422..41ec505 100644 --- a/browser_session_httpclient_test.go +++ b/browser_session_httpclient_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "testing" + "github.com/kernel/kernel-go-sdk/lib/browserscope" "github.com/kernel/kernel-go-sdk/option" ) @@ -28,16 +29,16 @@ func TestBrowserSessionHTTPClientRawCurl(t *testing.T) { option.WithHTTPClient(srv.Client()), ) - sess, err := c.ForBrowser(&BrowserGetResponse{ + primeBrowserRouteCache(c.Options, browserscope.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) } - - hc := sess.HTTPClient() req, err := http.NewRequest(http.MethodGet, "https://httpbin.org/get", nil) if err != nil { t.Fatal(err) @@ -55,3 +56,22 @@ func TestBrowserSessionHTTPClientRawCurl(t *testing.T) { t.Fatal("expected raw query on curl/raw") } } + +func TestBrowserSessionHTTPClientRequiresCachedRoute(t *testing.T) { + c := NewClient( + option.WithBaseURL("https://api.example/"), + option.WithAPIKey("sk"), + ) + + primeBrowserRouteCache(c.Options, browserscope.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") + } +} diff --git a/browser_session_services_gen.go b/browser_session_services_gen.go deleted file mode 100644 index be7053b..0000000 --- a/browser_session_services_gen.go +++ /dev/null @@ -1,261 +0,0 @@ -// Code generated by internal/genbrowsersessionservices; DO NOT EDIT. - -package kernel - -import ( - "context" - "io" - "net/http" - - "github.com/kernel/kernel-go-sdk/option" - "github.com/kernel/kernel-go-sdk/packages/ssestream" - "github.com/kernel/kernel-go-sdk/shared" -) - -// BrowserSessionComputerService exposes browser session APIs without passing browser id. -type BrowserSessionComputerService struct { - inner BrowserComputerService - id string -} - -func (s BrowserSessionComputerService) Batch(ctx context.Context, body BrowserComputerBatchParams, opts ...option.RequestOption) (err error) { - return s.inner.Batch(ctx, s.id, body, opts...) -} - -func (s BrowserSessionComputerService) CaptureScreenshot(ctx context.Context, body BrowserComputerCaptureScreenshotParams, opts ...option.RequestOption) (res *http.Response, err error) { - return s.inner.CaptureScreenshot(ctx, s.id, body, opts...) -} - -func (s BrowserSessionComputerService) ClickMouse(ctx context.Context, body BrowserComputerClickMouseParams, opts ...option.RequestOption) (err error) { - return s.inner.ClickMouse(ctx, s.id, body, opts...) -} - -func (s BrowserSessionComputerService) DragMouse(ctx context.Context, body BrowserComputerDragMouseParams, opts ...option.RequestOption) (err error) { - return s.inner.DragMouse(ctx, s.id, body, opts...) -} - -func (s BrowserSessionComputerService) GetMousePosition(ctx context.Context, opts ...option.RequestOption) (res *BrowserComputerGetMousePositionResponse, err error) { - return s.inner.GetMousePosition(ctx, s.id, opts...) -} - -func (s BrowserSessionComputerService) MoveMouse(ctx context.Context, body BrowserComputerMoveMouseParams, opts ...option.RequestOption) (err error) { - return s.inner.MoveMouse(ctx, s.id, body, opts...) -} - -func (s BrowserSessionComputerService) PressKey(ctx context.Context, body BrowserComputerPressKeyParams, opts ...option.RequestOption) (err error) { - return s.inner.PressKey(ctx, s.id, body, opts...) -} - -func (s BrowserSessionComputerService) ReadClipboard(ctx context.Context, opts ...option.RequestOption) (res *BrowserComputerReadClipboardResponse, err error) { - return s.inner.ReadClipboard(ctx, s.id, opts...) -} - -func (s BrowserSessionComputerService) Scroll(ctx context.Context, body BrowserComputerScrollParams, opts ...option.RequestOption) (err error) { - return s.inner.Scroll(ctx, s.id, body, opts...) -} - -func (s BrowserSessionComputerService) SetCursorVisibility(ctx context.Context, body BrowserComputerSetCursorVisibilityParams, opts ...option.RequestOption) (res *BrowserComputerSetCursorVisibilityResponse, err error) { - return s.inner.SetCursorVisibility(ctx, s.id, body, opts...) -} - -func (s BrowserSessionComputerService) TypeText(ctx context.Context, body BrowserComputerTypeTextParams, opts ...option.RequestOption) (err error) { - return s.inner.TypeText(ctx, s.id, body, opts...) -} - -func (s BrowserSessionComputerService) WriteClipboard(ctx context.Context, body BrowserComputerWriteClipboardParams, opts ...option.RequestOption) (err error) { - return s.inner.WriteClipboard(ctx, s.id, body, opts...) -} - -// BrowserSessionFService exposes browser session APIs without passing browser id. -type BrowserSessionFService struct { - inner BrowserFService - id string - Watch BrowserSessionFWatchService -} - -func (s BrowserSessionFService) DeleteDirectory(ctx context.Context, body BrowserFDeleteDirectoryParams, opts ...option.RequestOption) (err error) { - return s.inner.DeleteDirectory(ctx, s.id, body, opts...) -} - -func (s BrowserSessionFService) DeleteFile(ctx context.Context, body BrowserFDeleteFileParams, opts ...option.RequestOption) (err error) { - return s.inner.DeleteFile(ctx, s.id, body, opts...) -} - -func (s BrowserSessionFService) DownloadDirZip(ctx context.Context, query BrowserFDownloadDirZipParams, opts ...option.RequestOption) (res *http.Response, err error) { - return s.inner.DownloadDirZip(ctx, s.id, query, opts...) -} - -func (s BrowserSessionFService) FileInfo(ctx context.Context, query BrowserFFileInfoParams, opts ...option.RequestOption) (res *BrowserFFileInfoResponse, err error) { - return s.inner.FileInfo(ctx, s.id, query, opts...) -} - -func (s BrowserSessionFService) ListFiles(ctx context.Context, query BrowserFListFilesParams, opts ...option.RequestOption) (res *[]BrowserFListFilesResponse, err error) { - return s.inner.ListFiles(ctx, s.id, query, opts...) -} - -func (s BrowserSessionFService) Move(ctx context.Context, body BrowserFMoveParams, opts ...option.RequestOption) (err error) { - return s.inner.Move(ctx, s.id, body, opts...) -} - -func (s BrowserSessionFService) NewDirectory(ctx context.Context, body BrowserFNewDirectoryParams, opts ...option.RequestOption) (err error) { - return s.inner.NewDirectory(ctx, s.id, body, opts...) -} - -func (s BrowserSessionFService) ReadFile(ctx context.Context, query BrowserFReadFileParams, opts ...option.RequestOption) (res *http.Response, err error) { - return s.inner.ReadFile(ctx, s.id, query, opts...) -} - -func (s BrowserSessionFService) SetFilePermissions(ctx context.Context, body BrowserFSetFilePermissionsParams, opts ...option.RequestOption) (err error) { - return s.inner.SetFilePermissions(ctx, s.id, body, opts...) -} - -func (s BrowserSessionFService) Upload(ctx context.Context, body BrowserFUploadParams, opts ...option.RequestOption) (err error) { - return s.inner.Upload(ctx, s.id, body, opts...) -} - -func (s BrowserSessionFService) UploadZip(ctx context.Context, body BrowserFUploadZipParams, opts ...option.RequestOption) (err error) { - return s.inner.UploadZip(ctx, s.id, body, opts...) -} - -func (s BrowserSessionFService) WriteFile(ctx context.Context, contents io.Reader, params BrowserFWriteFileParams, opts ...option.RequestOption) (err error) { - return s.inner.WriteFile(ctx, s.id, contents, params, opts...) -} - -// BrowserSessionFWatchService exposes browser session APIs without passing browser id. -type BrowserSessionFWatchService struct { - inner BrowserFWatchService - id string -} - -func (s BrowserSessionFWatchService) EventsStreaming(ctx context.Context, watchID string, query BrowserFWatchEventsParams, opts ...option.RequestOption) (stream *ssestream.Stream[BrowserFWatchEventsResponse]) { - query.ID = s.id - return s.inner.EventsStreaming(ctx, watchID, query, opts...) -} - -func (s BrowserSessionFWatchService) Start(ctx context.Context, body BrowserFWatchStartParams, opts ...option.RequestOption) (res *BrowserFWatchStartResponse, err error) { - return s.inner.Start(ctx, s.id, body, opts...) -} - -func (s BrowserSessionFWatchService) Stop(ctx context.Context, watchID string, body BrowserFWatchStopParams, opts ...option.RequestOption) (err error) { - body.ID = s.id - return s.inner.Stop(ctx, watchID, body, opts...) -} - -// BrowserSessionLogService exposes browser session APIs without passing browser id. -type BrowserSessionLogService struct { - inner BrowserLogService - id string -} - -func (s BrowserSessionLogService) StreamStreaming(ctx context.Context, query BrowserLogStreamParams, opts ...option.RequestOption) (stream *ssestream.Stream[shared.LogEvent]) { - return s.inner.StreamStreaming(ctx, s.id, query, opts...) -} - -// BrowserSessionPlaywrightService exposes browser session APIs without passing browser id. -type BrowserSessionPlaywrightService struct { - inner BrowserPlaywrightService - id string -} - -func (s BrowserSessionPlaywrightService) Execute(ctx context.Context, body BrowserPlaywrightExecuteParams, opts ...option.RequestOption) (res *BrowserPlaywrightExecuteResponse, err error) { - return s.inner.Execute(ctx, s.id, body, opts...) -} - -// BrowserSessionProcessService exposes browser session APIs without passing browser id. -type BrowserSessionProcessService struct { - inner BrowserProcessService - id string -} - -func (s BrowserSessionProcessService) Exec(ctx context.Context, body BrowserProcessExecParams, opts ...option.RequestOption) (res *BrowserProcessExecResponse, err error) { - return s.inner.Exec(ctx, s.id, body, opts...) -} - -func (s BrowserSessionProcessService) Kill(ctx context.Context, processID string, params BrowserProcessKillParams, opts ...option.RequestOption) (res *BrowserProcessKillResponse, err error) { - params.ID = s.id - return s.inner.Kill(ctx, processID, params, opts...) -} - -func (s BrowserSessionProcessService) Resize(ctx context.Context, processID string, params BrowserProcessResizeParams, opts ...option.RequestOption) (res *BrowserProcessResizeResponse, err error) { - params.ID = s.id - return s.inner.Resize(ctx, processID, params, opts...) -} - -func (s BrowserSessionProcessService) Spawn(ctx context.Context, body BrowserProcessSpawnParams, opts ...option.RequestOption) (res *BrowserProcessSpawnResponse, err error) { - return s.inner.Spawn(ctx, s.id, body, opts...) -} - -func (s BrowserSessionProcessService) Status(ctx context.Context, processID string, query BrowserProcessStatusParams, opts ...option.RequestOption) (res *BrowserProcessStatusResponse, err error) { - query.ID = s.id - return s.inner.Status(ctx, processID, query, opts...) -} - -func (s BrowserSessionProcessService) Stdin(ctx context.Context, processID string, params BrowserProcessStdinParams, opts ...option.RequestOption) (res *BrowserProcessStdinResponse, err error) { - params.ID = s.id - return s.inner.Stdin(ctx, processID, params, opts...) -} - -func (s BrowserSessionProcessService) StdoutStreamStreaming(ctx context.Context, processID string, query BrowserProcessStdoutStreamParams, opts ...option.RequestOption) (stream *ssestream.Stream[BrowserProcessStdoutStreamResponse]) { - query.ID = s.id - return s.inner.StdoutStreamStreaming(ctx, processID, query, opts...) -} - -// BrowserSessionReplayService exposes browser session APIs without passing browser id. -type BrowserSessionReplayService struct { - inner BrowserReplayService - id string -} - -func (s BrowserSessionReplayService) Download(ctx context.Context, replayID string, query BrowserReplayDownloadParams, opts ...option.RequestOption) (res *http.Response, err error) { - query.ID = s.id - return s.inner.Download(ctx, replayID, query, opts...) -} - -func (s BrowserSessionReplayService) List(ctx context.Context, opts ...option.RequestOption) (res *[]BrowserReplayListResponse, err error) { - return s.inner.List(ctx, s.id, opts...) -} - -func (s BrowserSessionReplayService) Start(ctx context.Context, body BrowserReplayStartParams, opts ...option.RequestOption) (res *BrowserReplayStartResponse, err error) { - return s.inner.Start(ctx, s.id, body, opts...) -} - -func (s BrowserSessionReplayService) Stop(ctx context.Context, replayID string, body BrowserReplayStopParams, opts ...option.RequestOption) (err error) { - body.ID = s.id - return s.inner.Stop(ctx, replayID, body, opts...) -} - -// BrowserSessionClient is a browser-scoped view of a browser session. Subresources -// use the session base_url and do not repeat the browser id in method -// signatures. SessionID is exposed for future routing extensions. -type BrowserSessionClient struct { - sessionID string - opts []option.RequestOption - kernelBase string - jwt string - Replays BrowserSessionReplayService - Fs BrowserSessionFService - Process BrowserSessionProcessService - Logs BrowserSessionLogService - Computer BrowserSessionComputerService - Playwright BrowserSessionPlaywrightService -} - -func newBrowserSessionClient(sessionID, kernelBase, jwt string, scoped []option.RequestOption) *BrowserSessionClient { - innerFs := NewBrowserFService(scoped...) - return &BrowserSessionClient{ - sessionID: sessionID, - opts: scoped, - kernelBase: kernelBase, - jwt: jwt, - Replays: BrowserSessionReplayService{inner: NewBrowserReplayService(scoped...), id: sessionID}, - Fs: BrowserSessionFService{ - inner: innerFs, - id: sessionID, - Watch: BrowserSessionFWatchService{inner: innerFs.Watch, id: sessionID}, - }, - Process: BrowserSessionProcessService{inner: NewBrowserProcessService(scoped...), id: sessionID}, - Logs: BrowserSessionLogService{inner: NewBrowserLogService(scoped...), id: sessionID}, - Computer: BrowserSessionComputerService{inner: NewBrowserComputerService(scoped...), id: sessionID}, - Playwright: BrowserSessionPlaywrightService{inner: NewBrowserPlaywrightService(scoped...), id: sessionID}, - } -} diff --git a/browser_session_test.go b/browser_session_test.go deleted file mode 100644 index 5ca48d0..0000000 --- a/browser_session_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package kernel - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/kernel/kernel-go-sdk/lib/browserscope" - "github.com/kernel/kernel-go-sdk/option" -) - -func TestForBrowserRewritesToKernelPaths(t *testing.T) { - var gotPath string - var gotAuth string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotPath = r.URL.Path - gotAuth = r.Header.Get("Authorization") - if r.URL.Query().Get("jwt") == "" { - http.Error(w, "jwt", http.StatusBadRequest) - return - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "duration_ms": 1, - "exit_code": 0, - "stderr_b64": "", - "stdout_b64": "", - }) - })) - defer srv.Close() - - c := NewClient( - option.WithBaseURL("https://api.example/"), - option.WithAPIKey("sk_test"), - option.WithHTTPClient(srv.Client()), - ) - - b := &BrowserGetResponse{ - SessionID: "sid-1", - BaseURL: srv.URL + "/browser/kernel", - CdpWsURL: "wss://x/browser/cdp?jwt=session-jwt", - } - - sess, err := c.ForBrowser(b) - if err != nil { - t.Fatal(err) - } - if sess.SessionID() != "sid-1" { - t.Fatalf("session id: %s", sess.SessionID()) - } - - _, err = sess.Process.Exec(context.Background(), BrowserProcessExecParams{Command: "true"}) - if err != nil { - t.Fatal(err) - } - - if gotPath != "/browser/kernel/process/exec" { - t.Fatalf("path: got %q", gotPath) - } - if gotAuth != "" { - t.Fatalf("authorization should be empty, got %q", gotAuth) - } -} - -func TestForBrowserRefNormalize(t *testing.T) { - ref := browserscope.Ref{ - SessionID: "s", - BaseURL: "https://x/browser/kernel", - JWT: "direct", - } - norm, err := ref.Normalize() - if err != nil { - t.Fatal(err) - } - if norm.JWT != "direct" { - t.Fatalf("jwt: %q", norm.JWT) - } -} diff --git a/client.go b/client.go index 880a657..9604f0c 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/browserscope" "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 *browserscope.RouteCache // Create and manage app deployments and stream deployment events. Deployments DeploymentService // List applications and versions. @@ -61,8 +64,15 @@ func DefaultClientOptions() []option.RequestOption { // the services and requests that this client makes. func NewClient(opts ...option.RequestOption) (r Client) { opts = append(DefaultClientOptions(), opts...) + cache := browserscope.NewRouteCache() + for _, opt := range opts { + if routing, ok := opt.(*browserRoutingOption); ok { + routing.cache = cache + } + } + opts = append(opts, withBrowserRouteCache(cache)) - r = Client{Options: opts} + r = Client{Options: opts, BrowserRouteCache: cache} r.Deployments = NewDeploymentService(opts...) r.Apps = NewAppService(opts...) diff --git a/examples/browser-scoped/main.go b/examples/browser-scoped/main.go index 9f34f1e..8369943 100644 --- a/examples/browser-scoped/main.go +++ b/examples/browser-scoped/main.go @@ -11,7 +11,12 @@ import ( func main() { ctx := context.Background() - client := kernel.NewClient() + client := kernel.NewClient( + kernel.WithBrowserRouting(kernel.BrowserRoutingConfig{ + Enabled: true, + DirectToVMSubresources: []string{"process"}, + }), + ) browser, err := client.Browsers.New(ctx, kernel.BrowserNewParams{ Headless: kernel.Bool(true), @@ -23,12 +28,10 @@ func main() { _ = client.Browsers.DeleteByID(context.Background(), browser.SessionID) }() - scoped, err := client.ForBrowser(browser) + httpClient, err := client.Browsers.HTTPClient(browser.SessionID) if err != nil { panic(err) } - - httpClient := scoped.HTTPClient() req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.com", nil) if err != nil { panic(err) diff --git a/internal/genbrowsersessionservices/generate_test.go b/internal/genbrowsersessionservices/generate_test.go deleted file mode 100644 index 9499f40..0000000 --- a/internal/genbrowsersessionservices/generate_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package main - -import ( - "bytes" - "path/filepath" - "runtime" - "strings" - "testing" -) - -func TestGenerateDeterministic(t *testing.T) { - root := moduleRoot(t) - a, err := Generate(root) - if err != nil { - t.Fatal(err) - } - b, err := Generate(root) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(a, b) { - t.Fatal("Generate is not deterministic for identical inputs") - } -} - -func TestGenerateIncludesExpectedServices(t *testing.T) { - root := moduleRoot(t) - src, err := Generate(root) - if err != nil { - t.Fatal(err) - } - text := string(src) - for _, want := range []string{ - "type BrowserSessionClient struct", - "func newBrowserSessionClient(sessionID, kernelBase, jwt string, scoped []option.RequestOption) *BrowserSessionClient", - "\tReplays BrowserSessionReplayService", - "\tinnerFs := NewBrowserFService(scoped...)", - "type BrowserSessionComputerService struct", - "type BrowserSessionFService struct", - "type BrowserSessionFWatchService struct", - "type BrowserSessionLogService struct", - "type BrowserSessionPlaywrightService struct", - "type BrowserSessionProcessService struct", - "type BrowserSessionReplayService struct", - "func (s BrowserSessionProcessService) Kill(", - "params.ID = s.id", - "func (s BrowserSessionFService) WriteFile(ctx context.Context, contents io.Reader, params BrowserFWriteFileParams, opts ...option.RequestOption)", - } { - if !strings.Contains(text, want) { - t.Fatalf("generated source missing %q", want) - } - } -} - -func moduleRoot(t *testing.T) string { - t.Helper() - _, file, _, ok := runtime.Caller(0) - if !ok { - t.Fatal("runtime.Caller") - } - // internal/genbrowsersessionservices/generate_test.go -> repo root - return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) -} diff --git a/internal/genbrowsersessionservices/main.go b/internal/genbrowsersessionservices/main.go deleted file mode 100644 index 796fcbf..0000000 --- a/internal/genbrowsersessionservices/main.go +++ /dev/null @@ -1,623 +0,0 @@ -// Command genbrowsersessionservices emits browser_session_services_gen.go by -// analyzing BrowserService and nested *Browser*Service types in this module. -package main - -import ( - "bytes" - "flag" - "fmt" - "go/format" - "go/types" - "os" - "path/filepath" - "sort" - "strings" - - "golang.org/x/tools/go/packages" -) - -// browserSessionGenLoadStub provides placeholder BrowserSession* service types so -// the kernel package type-checks while this file is regenerated (see Overlay in Generate). -const browserSessionGenLoadStub = `package kernel - -import "github.com/kernel/kernel-go-sdk/option" - -type BrowserSessionClient struct { - sessionID string - opts []option.RequestOption - kernelBase string - jwt string -} - -func newBrowserSessionClient(sessionID, kernelBase, jwt string, scoped []option.RequestOption) *BrowserSessionClient { - return &BrowserSessionClient{sessionID: sessionID, opts: scoped, kernelBase: kernelBase, jwt: jwt} -} - -type BrowserSessionReplayService struct { - inner BrowserReplayService - id string -} -type BrowserSessionFService struct { - inner BrowserFService - id string - Watch BrowserSessionFWatchService -} -type BrowserSessionFWatchService struct { - inner BrowserFWatchService - id string -} -type BrowserSessionProcessService struct { - inner BrowserProcessService - id string -} -type BrowserSessionLogService struct { - inner BrowserLogService - id string -} -type BrowserSessionComputerService struct { - inner BrowserComputerService - id string -} -type BrowserSessionPlaywrightService struct { - inner BrowserPlaywrightService - id string -} -` - -func main() { - outPath := flag.String("output", "browser_session_services_gen.go", "path to write generated file (relative to -dir)") - dir := flag.String("dir", "", "module root (default: working directory)") - flag.Parse() - - root := *dir - if root == "" { - wd, err := os.Getwd() - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - root = wd - } - - src, err := Generate(root) - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } - - path := *outPath - if !filepath.IsAbs(path) { - path = filepath.Join(root, path) - } - if err := os.WriteFile(path, src, 0o644); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -// Generate returns formatted Go source for browser session service wrappers. -func Generate(moduleRoot string) ([]byte, error) { - genPath := filepath.Join(moduleRoot, "browser_session_services_gen.go") - genAbs, err := filepath.Abs(genPath) - if err != nil { - return nil, err - } - cfg := &packages.Config{ - Mode: packages.NeedTypes | packages.NeedImports | packages.NeedName | packages.NeedModule, - Dir: moduleRoot, - Overlay: map[string][]byte{ - // Placeholder types so browser_session.go type-checks while the real file is regenerated. - genAbs: []byte(browserSessionGenLoadStub), - }, - } - pkgs, err := packages.Load(cfg, ".") - if err != nil { - return nil, err - } - if len(pkgs) != 1 { - return nil, fmt.Errorf("expected 1 package in %s, got %d", moduleRoot, len(pkgs)) - } - pkg := pkgs[0] - if len(pkg.Errors) > 0 { - return nil, fmt.Errorf("package load errors: %v", pkg.Errors) - } - if pkg.Name != "kernel" { - return nil, fmt.Errorf("expected package kernel, got %s", pkg.Name) - } - - services, err := discoverBrowserSessionServices(pkg) - if err != nil { - return nil, err - } - - var buf bytes.Buffer - buf.WriteString(`// Code generated by internal/genbrowsersessionservices; DO NOT EDIT. - -package kernel - -import ( - "context" - "io" - "net/http" - - "github.com/kernel/kernel-go-sdk/option" - "github.com/kernel/kernel-go-sdk/packages/ssestream" - "github.com/kernel/kernel-go-sdk/shared" -) -`) - - for _, s := range services { - emitService(&buf, pkg, s) - } - - topFields, err := discoverBrowserSessionClientFields(pkg) - if err != nil { - return nil, err - } - emitBrowserSessionClient(&buf, topFields) - - out := buf.Bytes() - formatted, err := format.Source(out) - if err != nil { - return nil, fmt.Errorf("format: %w\n%s", err, string(out)) - } - return formatted, nil -} - -type serviceInfo struct { - innerName string - sessionName string - named *types.Named - nested []nestedFieldInfo -} - -type nestedFieldInfo struct { - fieldName string - inner *types.Named - session string -} - -// clientTopField is one exported subresource field on [BrowserSessionClient], -// aligned with a [BrowserService] service field (same name and inner type). -type clientTopField struct { - fieldName string // e.g. Replays, Fs - innerName string // e.g. BrowserReplayService -} - -func discoverBrowserSessionClientFields(pkg *packages.Package) ([]clientTopField, error) { - obj := pkg.Types.Scope().Lookup("BrowserService") - if obj == nil { - return nil, fmt.Errorf("BrowserService not found") - } - named, ok := obj.Type().(*types.Named) - if !ok { - return nil, fmt.Errorf("BrowserService is not a named type") - } - st, ok := named.Underlying().(*types.Struct) - if !ok { - return nil, fmt.Errorf("BrowserService underlying is not a struct") - } - var out []clientTopField - for i := 0; i < st.NumFields(); i++ { - f := st.Field(i) - if !f.Exported() || f.Name() == "Options" { - continue - } - nn := derefNamed(f.Type()) - if nn == nil { - continue - } - name := nn.Obj().Name() - if !strings.HasPrefix(name, "Browser") || !strings.HasSuffix(name, "Service") { - continue - } - out = append(out, clientTopField{fieldName: f.Name(), innerName: name}) - } - if len(out) == 0 { - return nil, fmt.Errorf("no browser session subresource fields found on BrowserService") - } - return out, nil -} - -func emitBrowserSessionClient(buf *bytes.Buffer, top []clientTopField) { - buf.WriteString(` - -// BrowserSessionClient is a browser-scoped view of a browser session. Subresources -// use the session base_url and do not repeat the browser id in method -// signatures. SessionID is exposed for future routing extensions. -type BrowserSessionClient struct { - sessionID string - opts []option.RequestOption - kernelBase string - jwt string -`) - for _, f := range top { - buf.WriteByte('\t') - buf.WriteString(f.fieldName) - buf.WriteByte(' ') - buf.WriteString(sessionWrapperName(f.innerName)) - buf.WriteByte('\n') - } - buf.WriteString("}\n") - - buf.WriteString(` -func newBrowserSessionClient(sessionID, kernelBase, jwt string, scoped []option.RequestOption) *BrowserSessionClient { -`) - - hasFS := false - for _, f := range top { - if f.innerName == "BrowserFService" { - hasFS = true - break - } - } - if hasFS { - buf.WriteString("\tinnerFs := NewBrowserFService(scoped...)\n") - } - - buf.WriteString("\treturn &BrowserSessionClient{\n") - buf.WriteString("\t\tsessionID: sessionID,\n") - buf.WriteString("\t\topts: scoped,\n") - buf.WriteString("\t\tkernelBase: kernelBase,\n") - buf.WriteString("\t\tjwt: jwt,\n") - - for _, f := range top { - if f.innerName == "BrowserFService" { - buf.WriteString("\t\tFs: BrowserSessionFService{\n") - buf.WriteString("\t\t\tinner: innerFs,\n") - buf.WriteString("\t\t\tid: sessionID,\n") - buf.WriteString("\t\t\tWatch: BrowserSessionFWatchService{inner: innerFs.Watch, id: sessionID},\n") - buf.WriteString("\t\t},\n") - continue - } - wrap := sessionWrapperName(f.innerName) - newFn := "New" + f.innerName - buf.WriteByte('\t') - buf.WriteByte('\t') - buf.WriteString(f.fieldName) - buf.WriteString(": ") - buf.WriteString(wrap) - buf.WriteString("{inner: ") - buf.WriteString(newFn) - buf.WriteString("(scoped...), id: sessionID},\n") - } - buf.WriteString("\t}\n}\n") -} - -func discoverBrowserSessionServices(pkg *packages.Package) ([]serviceInfo, error) { - obj := pkg.Types.Scope().Lookup("BrowserService") - if obj == nil { - return nil, fmt.Errorf("BrowserService not found") - } - named, ok := obj.Type().(*types.Named) - if !ok { - return nil, fmt.Errorf("BrowserService is not a named type") - } - - seen := map[string]*types.Named{} - var walk func(*types.Named) - walk = func(n *types.Named) { - name := n.Obj().Name() - if name == "BrowserService" { - // descend without recording - } else { - if !strings.HasPrefix(name, "Browser") || !strings.HasSuffix(name, "Service") { - return - } - if _, dup := seen[name]; dup { - return - } - seen[name] = n - } - st, ok := n.Underlying().(*types.Struct) - if !ok { - return - } - for i := 0; i < st.NumFields(); i++ { - f := st.Field(i) - if !f.Exported() || f.Embedded() { - continue - } - if f.Name() == "Options" { - continue - } - nn := derefNamed(f.Type()) - if nn == nil { - continue - } - walk(nn) - } - } - walk(named) - - var names []string - for n := range seen { - names = append(names, n) - } - sort.Strings(names) - - var out []serviceInfo - for _, name := range names { - n := seen[name] - st := n.Underlying().(*types.Struct) - var nested []nestedFieldInfo - for i := 0; i < st.NumFields(); i++ { - f := st.Field(i) - if !f.Exported() || f.Embedded() || f.Name() == "Options" { - continue - } - nn := derefNamed(f.Type()) - if nn == nil { - continue - } - fn := nn.Obj().Name() - if !strings.HasPrefix(fn, "Browser") || !strings.HasSuffix(fn, "Service") { - continue - } - if _, ok := seen[fn]; !ok { - continue - } - nested = append(nested, nestedFieldInfo{ - fieldName: f.Name(), - inner: nn, - session: sessionWrapperName(fn), - }) - } - sort.Slice(nested, func(i, j int) bool { return nested[i].fieldName < nested[j].fieldName }) - out = append(out, serviceInfo{ - innerName: name, - sessionName: sessionWrapperName(name), - named: n, - nested: nested, - }) - } - return out, nil -} - -func sessionWrapperName(innerService string) string { - mid := strings.TrimSuffix(strings.TrimPrefix(innerService, "Browser"), "Service") - return "BrowserSession" + mid + "Service" -} - -func derefNamed(t types.Type) *types.Named { - for { - switch u := t.(type) { - case *types.Pointer: - t = u.Elem() - default: - n, ok := t.(*types.Named) - if !ok { - return nil - } - return n - } - } -} - -func emitService(buf *bytes.Buffer, pkg *packages.Package, s serviceInfo) { - buf.WriteString("\n// ") - buf.WriteString(s.sessionName) - buf.WriteString(" exposes browser session APIs without passing browser id.\n") - buf.WriteString("type ") - buf.WriteString(s.sessionName) - buf.WriteString(" struct {\n") - buf.WriteString("\tinner ") - buf.WriteString(s.innerName) - buf.WriteString("\n") - buf.WriteString("\tid string\n") - for _, nf := range s.nested { - buf.WriteString("\t") - buf.WriteString(nf.fieldName) - buf.WriteString(" ") - buf.WriteString(nf.session) - buf.WriteString("\n") - } - buf.WriteString("}\n") - - ms := types.NewMethodSet(types.NewPointer(s.named)) - var methods []*types.Func - for i := 0; i < ms.Len(); i++ { - sel := ms.At(i) - fn, ok := sel.Obj().(*types.Func) - if !ok || !fn.Exported() { - continue - } - methods = append(methods, fn) - } - sort.Slice(methods, func(i, j int) bool { return methods[i].Name() < methods[j].Name() }) - - for _, fn := range methods { - sig := fn.Type().(*types.Signature) - emitMethod(buf, pkg, s.sessionName, sig, fn.Name()) - } -} - -// writeWrapperSignature writes "(params) results" for a browser session wrapper method. -// go/types.WriteSignature encodes final variadic parameters as slices; callers expect -// "opts ...option.RequestOption" like the underlying Stainless services. -func writeWrapperSignature(buf *bytes.Buffer, sig *types.Signature, q types.Qualifier) { - buf.WriteByte('(') - params := sig.Params() - for i := 0; i < params.Len(); i++ { - if i > 0 { - buf.WriteString(", ") - } - p := params.At(i) - if p.Name() != "" { - buf.WriteString(p.Name()) - buf.WriteByte(' ') - } - if sig.Variadic() && i == params.Len()-1 { - slice := p.Type().(*types.Slice) - buf.WriteString("...") - buf.WriteString(types.TypeString(slice.Elem(), q)) - continue - } - buf.WriteString(types.TypeString(p.Type(), q)) - } - buf.WriteByte(')') - writeResults(buf, sig.Results(), q) -} - -func writeResults(buf *bytes.Buffer, results *types.Tuple, q types.Qualifier) { - switch results.Len() { - case 0: - return - case 1: - r := results.At(0) - if r.Name() == "" { - buf.WriteByte(' ') - buf.WriteString(types.TypeString(r.Type(), q)) - return - } - fallthrough - default: - buf.WriteString(" (") - for i := 0; i < results.Len(); i++ { - if i > 0 { - buf.WriteString(", ") - } - r := results.At(i) - if r.Name() != "" { - buf.WriteString(r.Name()) - buf.WriteByte(' ') - } - buf.WriteString(types.TypeString(r.Type(), q)) - } - buf.WriteByte(')') - } -} - -func emitMethod(buf *bytes.Buffer, pkg *packages.Package, sessionName string, innerSig *types.Signature, methodName string) { - params := innerSig.Params() - if params.Len() < 1 || !isContext(params.At(0).Type()) { - return - } - - qual := qualifier(pkg.Types) - wrapperSig := types.NewSignature(nil, wrapperParamTuple(innerSig), innerSig.Results(), innerSig.Variadic()) - - var preStmts []string - var callArgs []string - callArgs = append(callArgs, "ctx") - - for i := 1; i < params.Len(); i++ { - p := params.At(i) - pname := paramName(p, i) - if isVariadicOpts(innerSig, i) { - callArgs = append(callArgs, pname+"...") - break - } - if p.Name() == "id" && isStringType(p.Type()) { - callArgs = append(callArgs, "s.id") - continue - } - if hasStringIDField(p.Type()) { - preStmts = append(preStmts, fmt.Sprintf("\t%s.ID = s.id", pname)) - callArgs = append(callArgs, pname) - continue - } - callArgs = append(callArgs, pname) - } - - fmt.Fprintf(buf, "\nfunc (s %s) %s", sessionName, methodName) - writeWrapperSignature(buf, wrapperSig, qual) - buf.WriteString(" {\n") - for _, ln := range preStmts { - buf.WriteString(ln) - buf.WriteByte('\n') - } - innerCall := fmt.Sprintf("s.inner.%s(%s)", methodName, strings.Join(callArgs, ", ")) - if innerSig.Results().Len() == 0 { - fmt.Fprintf(buf, "\t%s\n", innerCall) - } else { - fmt.Fprintf(buf, "\treturn %s\n", innerCall) - } - buf.WriteString("}\n") -} - -// wrapperParamTuple is the inner API signature with the browser id path parameter removed. -func wrapperParamTuple(sig *types.Signature) *types.Tuple { - params := sig.Params() - var vars []*types.Var - vars = append(vars, params.At(0)) - last := params.Len() - 1 - for i := 1; i < params.Len(); i++ { - p := params.At(i) - if sig.Variadic() && i == last { - vars = append(vars, p) - break - } - if p.Name() == "id" && isStringType(p.Type()) { - continue - } - vars = append(vars, p) - } - return types.NewTuple(vars...) -} - -func paramName(p *types.Var, idx int) string { - if p.Name() != "" { - return p.Name() - } - return fmt.Sprintf("arg%d", idx) -} - -func isVariadicOpts(sig *types.Signature, idx int) bool { - if !sig.Variadic() || idx != sig.Params().Len()-1 { - return false - } - _, ok := sig.Params().At(idx).Type().(*types.Slice) - return ok -} - -func isContext(t types.Type) bool { - named, ok := t.(*types.Named) - if !ok { - return false - } - return named.Obj().Pkg() != nil && named.Obj().Pkg().Path() == "context" && named.Obj().Name() == "Context" -} - -func isStringType(t types.Type) bool { - b, ok := t.Underlying().(*types.Basic) - return ok && b.Kind() == types.String -} - -func hasStringIDField(t types.Type) bool { - str, ok := derefStruct(t) - if !ok { - return false - } - for i := 0; i < str.NumFields(); i++ { - f := str.Field(i) - if f.Name() == "ID" && isStringType(f.Type()) { - return true - } - } - return false -} - -func derefStruct(t types.Type) (*types.Struct, bool) { - if p, ok := t.(*types.Pointer); ok { - t = p.Elem() - } - switch u := t.(type) { - case *types.Named: - s, ok := u.Underlying().(*types.Struct) - return s, ok - case *types.Struct: - return u, true - default: - return nil, false - } -} - -func qualifier(pkg *types.Package) types.Qualifier { - return func(other *types.Package) string { - if other == pkg { - return "" - } - return other.Name() - } -} diff --git a/lib/browserscope/integration_test.go b/lib/browserscope/integration_test.go index 6feef68..7c4e5eb 100644 --- a/lib/browserscope/integration_test.go +++ b/lib/browserscope/integration_test.go @@ -13,7 +13,7 @@ import ( "github.com/kernel/kernel-go-sdk/option" ) -func TestIntegrationBrowserSessionClient(t *testing.T) { +func TestIntegrationBrowserRouting(t *testing.T) { apiKey := strings.TrimSpace(os.Getenv("KERNEL_API_KEY")) baseURL := strings.TrimSpace(os.Getenv("KERNEL_BASE_URL")) if apiKey == "" || baseURL == "" { @@ -26,6 +26,10 @@ func TestIntegrationBrowserSessionClient(t *testing.T) { client := kernel.NewClient( option.WithAPIKey(apiKey), option.WithBaseURL(baseURL), + kernel.WithBrowserRouting(kernel.BrowserRoutingConfig{ + Enabled: true, + DirectToVMSubresources: []string{"process"}, + }), ) browser, err := client.Browsers.New(ctx, kernel.BrowserNewParams{ @@ -43,12 +47,7 @@ func TestIntegrationBrowserSessionClient(t *testing.T) { t.Fatal("expected browser base_url to be set") } - scoped, err := client.ForBrowser(browser) - if err != nil { - t.Fatalf("for browser: %v", err) - } - - execRes, err := scoped.Process.Exec(ctx, kernel.BrowserProcessExecParams{ + execRes, err := client.Browsers.Process.Exec(ctx, browser.SessionID, kernel.BrowserProcessExecParams{ Command: "echo", Args: []string{"hello"}, }) @@ -63,7 +62,11 @@ func TestIntegrationBrowserSessionClient(t *testing.T) { if err != nil { t.Fatalf("new request: %v", err) } - resp, err := scoped.HTTPClient().Do(req) + 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) } diff --git a/lib/browserscope/route_cache.go b/lib/browserscope/route_cache.go new file mode 100644 index 0000000..b0d8d40 --- /dev/null +++ b/lib/browserscope/route_cache.go @@ -0,0 +1,153 @@ +package browserscope + +import ( + "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 +} + +// 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) +} + +// Prime validates the browser reference and stores it in the cache. +func (c *RouteCache) Prime(ref Ref) error { + norm, err := ref.Normalize() + if err != nil { + return err + } + c.Store(Route{ + SessionID: norm.SessionID, + BaseURL: norm.BaseURL, + JWT: norm.JWT, + }) + return nil +} + +// DirectVMRoutingMiddleware rewrites allowlisted browser subresource requests to +// the browser VM using cached base_url and jwt data. +func DirectVMRoutingMiddleware(cache *RouteCache, directToVMSubresources []string) option.Middleware { + allowed := map[string]struct{}{} + for _, subresource := range directToVMSubresources { + if trimmed := strings.TrimSpace(subresource); trimmed != "" { + allowed[trimmed] = struct{}{} + } + } + + return func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { + sessionID, subresource, suffix, ok := parseDirectVMPath(req.URL.Path) + if !ok { + return next(req) + } + if _, ok := allowed[subresource]; !ok { + return next(req) + } + route, ok := cache.Load(sessionID) + if !ok { + return next(req) + } + + 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) + + return next(req) + } +} + +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/scripts/generate-browser-session b/scripts/generate-browser-session deleted file mode 100755 index 5d5479a..0000000 --- a/scripts/generate-browser-session +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash - -set -e - -cd "$(dirname "$0")/.." - -go run ./internal/genbrowsersessionservices -output browser_session_services_gen.go diff --git a/scripts/lint b/scripts/lint index e577c0d..7e03a7b 100755 --- a/scripts/lint +++ b/scripts/lint @@ -4,12 +4,6 @@ set -e cd "$(dirname "$0")/.." -echo "==> Regenerating browser-scoped bindings" -./scripts/generate-browser-session - -echo "==> Verifying generated browser-scoped bindings are committed" -git diff --exit-code -- browser_session_services_gen.go - echo "==> Running Go build" go build ./... From 6bdf25f058d2e0bb552bd47ba4478222d97aa254 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 22 Apr 2026 11:43:34 -0400 Subject: [PATCH 08/20] refactor: simplify direct-to-vm route caching Drop the extra cache priming helper, remove metro wording, and rename the example so the go diff stays focused on direct-to-VM routing. Made-with: Cursor --- browser.go | 16 ++++++++-------- browser_routing.go | 19 ++++++++++++++++--- browser_session_httpclient_test.go | 4 ++-- browserpool.go | 2 +- .../main.go | 0 invocation.go | 2 +- lib/browserscope/ref.go | 6 +++--- lib/browserscope/route_cache.go | 14 -------------- 8 files changed, 31 insertions(+), 32 deletions(-) rename examples/{browser-scoped => browser-routing}/main.go (100%) diff --git a/browser.go b/browser.go index 1423df6..867d233 100644 --- a/browser.go +++ b/browser.go @@ -71,7 +71,7 @@ func (r *BrowserService) New(ctx context.Context, body BrowserNewParams, opts .. path := "browsers" err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) if err == nil && res != nil { - primeBrowserRouteCache(opts, browserscope.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL}) + storeBrowserRouteCache(opts, browserscope.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL}) } return res, err } @@ -86,7 +86,7 @@ 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 { - primeBrowserRouteCache(opts, browserscope.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL}) + storeBrowserRouteCache(opts, browserscope.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL}) } return res, err } @@ -101,7 +101,7 @@ 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 { - primeBrowserRouteCache(opts, browserscope.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL}) + storeBrowserRouteCache(opts, browserscope.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL}) } return res, err } @@ -123,7 +123,7 @@ func (r *BrowserService) List(ctx context.Context, query BrowserListParams, opts } res.SetPageConfig(cfg, raw) for _, item := range res.Items { - primeBrowserRouteCache(opts, browserscope.Ref{SessionID: item.SessionID, BaseURL: item.BaseURL, CdpWsURL: item.CdpWsURL}) + storeBrowserRouteCache(opts, browserscope.Ref{SessionID: item.SessionID, BaseURL: item.BaseURL, CdpWsURL: item.CdpWsURL}) } return res, nil } @@ -343,7 +343,7 @@ type BrowserNewResponse struct { TimeoutSeconds int64 `json:"timeout_seconds" api:"required"` // Websocket URL for WebDriver BiDi connections to the browser session WebdriverWsURL string `json:"webdriver_ws_url" api:"required"` - // Metro-API HTTP base URL for this browser session. + // Direct-to-VM HTTP base URL for this browser session. BaseURL string `json:"base_url"` // Remote URL for live viewing the browser session. Only available for non-headless // browsers. @@ -426,7 +426,7 @@ type BrowserGetResponse struct { TimeoutSeconds int64 `json:"timeout_seconds" api:"required"` // Websocket URL for WebDriver BiDi connections to the browser session WebdriverWsURL string `json:"webdriver_ws_url" api:"required"` - // Metro-API HTTP base URL for this browser session. + // Direct-to-VM HTTP base URL for this browser session. BaseURL string `json:"base_url"` // Remote URL for live viewing the browser session. Only available for non-headless // browsers. @@ -509,7 +509,7 @@ type BrowserUpdateResponse struct { TimeoutSeconds int64 `json:"timeout_seconds" api:"required"` // Websocket URL for WebDriver BiDi connections to the browser session WebdriverWsURL string `json:"webdriver_ws_url" api:"required"` - // Metro-API HTTP base URL for this browser session. + // Direct-to-VM HTTP base URL for this browser session. BaseURL string `json:"base_url"` // Remote URL for live viewing the browser session. Only available for non-headless // browsers. @@ -592,7 +592,7 @@ type BrowserListResponse struct { TimeoutSeconds int64 `json:"timeout_seconds" api:"required"` // Websocket URL for WebDriver BiDi connections to the browser session WebdriverWsURL string `json:"webdriver_ws_url" api:"required"` - // Metro-API HTTP base URL for this browser session. + // Direct-to-VM HTTP base URL for this browser session. BaseURL string `json:"base_url"` // Remote URL for live viewing the browser session. Only available for non-headless // browsers. diff --git a/browser_routing.go b/browser_routing.go index 3c30647..3378f57 100644 --- a/browser_routing.go +++ b/browser_routing.go @@ -61,11 +61,24 @@ func browserRouteCacheFromOptions(opts []option.RequestOption) *browserscope.Rou return nil } -func primeBrowserRouteCache(opts []option.RequestOption, refs ...browserscope.Ref) { +func storeBrowserRouteCache(opts []option.RequestOption, refs ...browserscope.Ref) { cache := browserRouteCacheFromOptions(opts) for _, ref := range refs { - if cache != nil { - _ = cache.Prime(ref) + route, ok := browserRouteFromRef(ref) + if cache != nil && ok { + cache.Store(route) } } } + +func browserRouteFromRef(ref browserscope.Ref) (browserscope.Route, bool) { + norm, err := ref.Normalize() + if err != nil { + return browserscope.Route{}, false + } + return browserscope.Route{ + SessionID: norm.SessionID, + BaseURL: norm.BaseURL, + JWT: norm.JWT, + }, true +} diff --git a/browser_session_httpclient_test.go b/browser_session_httpclient_test.go index 41ec505..d846cb2 100644 --- a/browser_session_httpclient_test.go +++ b/browser_session_httpclient_test.go @@ -29,7 +29,7 @@ func TestBrowserSessionHTTPClientRawCurl(t *testing.T) { option.WithHTTPClient(srv.Client()), ) - primeBrowserRouteCache(c.Options, browserscope.Ref{ + storeBrowserRouteCache(c.Options, browserscope.Ref{ SessionID: "sid", BaseURL: srv.URL + "/browser/kernel", CdpWsURL: "wss://x/browser/cdp?jwt=j1", @@ -63,7 +63,7 @@ func TestBrowserSessionHTTPClientRequiresCachedRoute(t *testing.T) { option.WithAPIKey("sk"), ) - primeBrowserRouteCache(c.Options, browserscope.Ref{ + storeBrowserRouteCache(c.Options, browserscope.Ref{ SessionID: "sid", BaseURL: "https://browser-session.test/browser/kernel", CdpWsURL: "wss://x/browser/cdp?jwt=j1", diff --git a/browserpool.go b/browserpool.go index 3069e49..009bce2 100644 --- a/browserpool.go +++ b/browserpool.go @@ -255,7 +255,7 @@ type BrowserPoolAcquireResponse struct { TimeoutSeconds int64 `json:"timeout_seconds" api:"required"` // Websocket URL for WebDriver BiDi connections to the browser session WebdriverWsURL string `json:"webdriver_ws_url" api:"required"` - // Metro-API HTTP base URL for this browser session. + // Direct-to-VM HTTP base URL for this browser session. BaseURL string `json:"base_url"` // Remote URL for live viewing the browser session. Only available for non-headless // browsers. diff --git a/examples/browser-scoped/main.go b/examples/browser-routing/main.go similarity index 100% rename from examples/browser-scoped/main.go rename to examples/browser-routing/main.go diff --git a/invocation.go b/invocation.go index 872a775..23346ca 100644 --- a/invocation.go +++ b/invocation.go @@ -552,7 +552,7 @@ type InvocationListBrowsersResponseBrowser struct { TimeoutSeconds int64 `json:"timeout_seconds" api:"required"` // Websocket URL for WebDriver BiDi connections to the browser session WebdriverWsURL string `json:"webdriver_ws_url" api:"required"` - // Metro-API HTTP base URL for this browser session. + // Direct-to-VM HTTP base URL for this browser session. BaseURL string `json:"base_url"` // Remote URL for live viewing the browser session. Only available for non-headless // browsers. diff --git a/lib/browserscope/ref.go b/lib/browserscope/ref.go index 603a94b..5c384dd 100644 --- a/lib/browserscope/ref.go +++ b/lib/browserscope/ref.go @@ -5,9 +5,9 @@ import ( "strings" ) -// Ref identifies a browser session for browser-scoped HTTP calls. SessionID is -// reserved for future client-side routing; browser-scoped requests rewrite -// the /browsers/{SessionID}/ path segment against the returned base_url. +// 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 diff --git a/lib/browserscope/route_cache.go b/lib/browserscope/route_cache.go index b0d8d40..dc3cd5d 100644 --- a/lib/browserscope/route_cache.go +++ b/lib/browserscope/route_cache.go @@ -62,20 +62,6 @@ func (c *RouteCache) Delete(sessionID string) { delete(c.routes, sessionID) } -// Prime validates the browser reference and stores it in the cache. -func (c *RouteCache) Prime(ref Ref) error { - norm, err := ref.Normalize() - if err != nil { - return err - } - c.Store(Route{ - SessionID: norm.SessionID, - BaseURL: norm.BaseURL, - JWT: norm.JWT, - }) - return nil -} - // DirectVMRoutingMiddleware rewrites allowlisted browser subresource requests to // the browser VM using cached base_url and jwt data. func DirectVMRoutingMiddleware(cache *RouteCache, directToVMSubresources []string) option.Middleware { From 909c377e21adddbaede526ba2a356352652e947b Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 22 Apr 2026 12:57:08 -0400 Subject: [PATCH 09/20] refactor: rename browser routing subresources config Rename the browser routing allowlist field to Subresources so the direct-to-VM configuration is shorter and easier to read. Made-with: Cursor --- browser_routing.go | 6 +++--- browser_routing_test.go | 4 ++-- examples/browser-routing/main.go | 4 ++-- lib/browserscope/integration_test.go | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/browser_routing.go b/browser_routing.go index 3378f57..5e42fdb 100644 --- a/browser_routing.go +++ b/browser_routing.go @@ -8,8 +8,8 @@ import ( // BrowserRoutingConfig controls which browser subresources route directly to the browser VM. type BrowserRoutingConfig struct { - Enabled bool - DirectToVMSubresources []string + Enabled bool + Subresources []string } type browserRoutingOption struct { @@ -30,7 +30,7 @@ func (o *browserRoutingOption) Apply(r *requestconfig.RequestConfig) error { if !o.config.Enabled { return nil } - r.Middlewares = append(r.Middlewares, browserscope.DirectVMRoutingMiddleware(o.cache, o.config.DirectToVMSubresources)) + r.Middlewares = append(r.Middlewares, browserscope.DirectVMRoutingMiddleware(o.cache, o.config.Subresources)) return nil } diff --git a/browser_routing_test.go b/browser_routing_test.go index 4e0802b..fede55c 100644 --- a/browser_routing_test.go +++ b/browser_routing_test.go @@ -45,7 +45,7 @@ func TestBrowserRoutingWarmsCacheAndRoutesAllowlistedSubresources(t *testing.T) option.WithBaseURL(srv.URL), option.WithAPIKey("sk_test"), option.WithHTTPClient(srv.Client()), - WithBrowserRouting(BrowserRoutingConfig{Enabled: true, DirectToVMSubresources: []string{"process"}}), + WithBrowserRouting(BrowserRoutingConfig{Enabled: true, Subresources: []string{"process"}}), ) if _, err := client.Browsers.New(context.Background(), BrowserNewParams{}); err != nil { @@ -97,7 +97,7 @@ func TestBrowserRoutingSkipsSubresourcesOutsideConfiguredAllowlist(t *testing.T) option.WithBaseURL(srv.URL), option.WithAPIKey("sk_test"), option.WithHTTPClient(srv.Client()), - WithBrowserRouting(BrowserRoutingConfig{Enabled: true, DirectToVMSubresources: []string{"computer"}}), + WithBrowserRouting(BrowserRoutingConfig{Enabled: true, Subresources: []string{"computer"}}), ) if _, err := client.Browsers.New(context.Background(), BrowserNewParams{}); err != nil { diff --git a/examples/browser-routing/main.go b/examples/browser-routing/main.go index 8369943..3e3591c 100644 --- a/examples/browser-routing/main.go +++ b/examples/browser-routing/main.go @@ -13,8 +13,8 @@ func main() { ctx := context.Background() client := kernel.NewClient( kernel.WithBrowserRouting(kernel.BrowserRoutingConfig{ - Enabled: true, - DirectToVMSubresources: []string{"process"}, + Enabled: true, + Subresources: []string{"process"}, }), ) diff --git a/lib/browserscope/integration_test.go b/lib/browserscope/integration_test.go index 7c4e5eb..f2bef82 100644 --- a/lib/browserscope/integration_test.go +++ b/lib/browserscope/integration_test.go @@ -27,8 +27,8 @@ func TestIntegrationBrowserRouting(t *testing.T) { option.WithAPIKey(apiKey), option.WithBaseURL(baseURL), kernel.WithBrowserRouting(kernel.BrowserRoutingConfig{ - Enabled: true, - DirectToVMSubresources: []string{"process"}, + Enabled: true, + Subresources: []string{"process"}, }), ) From 77bda33323c48a60ab652bf42a39cc2bc1f909a0 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 22 Apr 2026 15:22:11 -0400 Subject: [PATCH 10/20] fix: clean up go browser routing follow-ups Rename the handwritten routing helpers to browserrouting, fix the shared-cache and RawPath issues, and revert the generated/module churn that should not stay in the PR. Made-with: Cursor --- browser.go | 37 ++----- browser_http_client.go | 34 ++++++ browser_routing.go | 26 ++--- browser_session_httpclient_test.go | 6 +- browserpool.go | 2 +- client.go | 15 ++- go.mod | 5 +- go.sum | 8 -- invocation.go | 2 +- lib/browserrouting/integration_test.go | 87 +++++++++++++++ lib/browserrouting/jwt.go | 25 +++++ lib/browserrouting/jwt_test.go | 21 ++++ lib/browserrouting/rawcurl.go | 81 ++++++++++++++ lib/browserrouting/rawcurl_test.go | 54 ++++++++++ lib/browserrouting/ref.go | 43 ++++++++ lib/browserrouting/route_cache.go | 140 +++++++++++++++++++++++++ lib/browserrouting/route_cache_test.go | 52 +++++++++ option/requestoption.go | 2 +- 18 files changed, 576 insertions(+), 64 deletions(-) create mode 100644 browser_http_client.go create mode 100644 lib/browserrouting/integration_test.go create mode 100644 lib/browserrouting/jwt.go create mode 100644 lib/browserrouting/jwt_test.go create mode 100644 lib/browserrouting/rawcurl.go create mode 100644 lib/browserrouting/rawcurl_test.go create mode 100644 lib/browserrouting/ref.go create mode 100644 lib/browserrouting/route_cache.go create mode 100644 lib/browserrouting/route_cache_test.go diff --git a/browser.go b/browser.go index 867d233..21ef07d 100644 --- a/browser.go +++ b/browser.go @@ -19,7 +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/browserscope" + "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" @@ -71,7 +71,7 @@ func (r *BrowserService) New(ctx context.Context, body BrowserNewParams, opts .. path := "browsers" err = requestconfig.ExecuteNewRequest(ctx, http.MethodPost, path, body, &res, opts...) if err == nil && res != nil { - storeBrowserRouteCache(opts, browserscope.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL}) + storeBrowserRouteCache(opts, browserrouting.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL}) } return res, err } @@ -86,7 +86,7 @@ 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, browserscope.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL}) + storeBrowserRouteCache(opts, browserrouting.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL}) } return res, err } @@ -101,7 +101,7 @@ 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, browserscope.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL}) + storeBrowserRouteCache(opts, browserrouting.Ref{SessionID: res.SessionID, BaseURL: res.BaseURL, CdpWsURL: res.CdpWsURL}) } return res, err } @@ -123,7 +123,7 @@ func (r *BrowserService) List(ctx context.Context, query BrowserListParams, opts } res.SetPageConfig(cfg, raw) for _, item := range res.Items { - storeBrowserRouteCache(opts, browserscope.Ref{SessionID: item.SessionID, BaseURL: item.BaseURL, CdpWsURL: item.CdpWsURL}) + storeBrowserRouteCache(opts, browserrouting.Ref{SessionID: item.SessionID, BaseURL: item.BaseURL, CdpWsURL: item.CdpWsURL}) } return res, nil } @@ -160,25 +160,6 @@ func (r *BrowserService) Curl(ctx context.Context, id string, body BrowserCurlPa return res, err } -// 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.PreRequestOptions(opts...) - if err != nil { - return browserscope.HTTPClient(route.BaseURL, route.JWT, nil), nil - } - return browserscope.HTTPClient(route.BaseURL, route.JWT, cfg.HTTPClient), nil -} - // Delete a browser session by ID func (r *BrowserService) DeleteByID(ctx context.Context, id string, opts ...option.RequestOption) (err error) { opts = slices.Concat(r.Options, opts) @@ -343,7 +324,7 @@ type BrowserNewResponse struct { TimeoutSeconds int64 `json:"timeout_seconds" api:"required"` // Websocket URL for WebDriver BiDi connections to the browser session WebdriverWsURL string `json:"webdriver_ws_url" api:"required"` - // Direct-to-VM HTTP base URL for this browser session. + // Metro-API HTTP base URL for this browser session. BaseURL string `json:"base_url"` // Remote URL for live viewing the browser session. Only available for non-headless // browsers. @@ -426,7 +407,7 @@ type BrowserGetResponse struct { TimeoutSeconds int64 `json:"timeout_seconds" api:"required"` // Websocket URL for WebDriver BiDi connections to the browser session WebdriverWsURL string `json:"webdriver_ws_url" api:"required"` - // Direct-to-VM HTTP base URL for this browser session. + // Metro-API HTTP base URL for this browser session. BaseURL string `json:"base_url"` // Remote URL for live viewing the browser session. Only available for non-headless // browsers. @@ -509,7 +490,7 @@ type BrowserUpdateResponse struct { TimeoutSeconds int64 `json:"timeout_seconds" api:"required"` // Websocket URL for WebDriver BiDi connections to the browser session WebdriverWsURL string `json:"webdriver_ws_url" api:"required"` - // Direct-to-VM HTTP base URL for this browser session. + // Metro-API HTTP base URL for this browser session. BaseURL string `json:"base_url"` // Remote URL for live viewing the browser session. Only available for non-headless // browsers. @@ -592,7 +573,7 @@ type BrowserListResponse struct { TimeoutSeconds int64 `json:"timeout_seconds" api:"required"` // Websocket URL for WebDriver BiDi connections to the browser session WebdriverWsURL string `json:"webdriver_ws_url" api:"required"` - // Direct-to-VM HTTP base URL for this browser session. + // Metro-API HTTP base URL for this browser session. BaseURL string `json:"base_url"` // Remote URL for live viewing the browser session. Only available for non-headless // browsers. diff --git a/browser_http_client.go b/browser_http_client.go new file mode 100644 index 0000000..d128d0c --- /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 browserrouting.NewHTTPClient(route.BaseURL, route.JWT, nil), nil + } + + return browserrouting.NewHTTPClient(route.BaseURL, route.JWT, cfg.HTTPClient), nil +} diff --git a/browser_routing.go b/browser_routing.go index 5e42fdb..330e951 100644 --- a/browser_routing.go +++ b/browser_routing.go @@ -2,7 +2,7 @@ package kernel import ( "github.com/kernel/kernel-go-sdk/internal/requestconfig" - "github.com/kernel/kernel-go-sdk/lib/browserscope" + "github.com/kernel/kernel-go-sdk/lib/browserrouting" "github.com/kernel/kernel-go-sdk/option" ) @@ -13,12 +13,12 @@ type BrowserRoutingConfig struct { } type browserRoutingOption struct { - cache *browserscope.RouteCache + cache *browserrouting.RouteCache config BrowserRoutingConfig } type browserRouteCacheOption struct { - cache *browserscope.RouteCache + cache *browserrouting.RouteCache } // WithBrowserRouting enables direct-to-VM routing for the configured browser subresources. @@ -30,11 +30,11 @@ func (o *browserRoutingOption) Apply(r *requestconfig.RequestConfig) error { if !o.config.Enabled { return nil } - r.Middlewares = append(r.Middlewares, browserscope.DirectVMRoutingMiddleware(o.cache, o.config.Subresources)) + r.Middlewares = append(r.Middlewares, browserrouting.DirectVMRoutingMiddleware(o.cache, o.config.Subresources)) return nil } -func (o *browserRoutingOption) browserRouteCache() *browserscope.RouteCache { +func (o *browserRoutingOption) browserRouteCache() *browserrouting.RouteCache { return o.cache } @@ -42,17 +42,17 @@ func (o *browserRouteCacheOption) Apply(*requestconfig.RequestConfig) error { return nil } -func (o *browserRouteCacheOption) browserRouteCache() *browserscope.RouteCache { +func (o *browserRouteCacheOption) browserRouteCache() *browserrouting.RouteCache { return o.cache } -func withBrowserRouteCache(cache *browserscope.RouteCache) option.RequestOption { +func withBrowserRouteCache(cache *browserrouting.RouteCache) option.RequestOption { return &browserRouteCacheOption{cache: cache} } -func browserRouteCacheFromOptions(opts []option.RequestOption) *browserscope.RouteCache { +func browserRouteCacheFromOptions(opts []option.RequestOption) *browserrouting.RouteCache { for _, opt := range opts { - if carrier, ok := opt.(interface{ browserRouteCache() *browserscope.RouteCache }); ok { + if carrier, ok := opt.(interface{ browserRouteCache() *browserrouting.RouteCache }); ok { if cache := carrier.browserRouteCache(); cache != nil { return cache } @@ -61,7 +61,7 @@ func browserRouteCacheFromOptions(opts []option.RequestOption) *browserscope.Rou return nil } -func storeBrowserRouteCache(opts []option.RequestOption, refs ...browserscope.Ref) { +func storeBrowserRouteCache(opts []option.RequestOption, refs ...browserrouting.Ref) { cache := browserRouteCacheFromOptions(opts) for _, ref := range refs { route, ok := browserRouteFromRef(ref) @@ -71,12 +71,12 @@ func storeBrowserRouteCache(opts []option.RequestOption, refs ...browserscope.Re } } -func browserRouteFromRef(ref browserscope.Ref) (browserscope.Route, bool) { +func browserRouteFromRef(ref browserrouting.Ref) (browserrouting.Route, bool) { norm, err := ref.Normalize() if err != nil { - return browserscope.Route{}, false + return browserrouting.Route{}, false } - return browserscope.Route{ + return browserrouting.Route{ SessionID: norm.SessionID, BaseURL: norm.BaseURL, JWT: norm.JWT, diff --git a/browser_session_httpclient_test.go b/browser_session_httpclient_test.go index d846cb2..63d0f99 100644 --- a/browser_session_httpclient_test.go +++ b/browser_session_httpclient_test.go @@ -6,7 +6,7 @@ import ( "net/http/httptest" "testing" - "github.com/kernel/kernel-go-sdk/lib/browserscope" + "github.com/kernel/kernel-go-sdk/lib/browserrouting" "github.com/kernel/kernel-go-sdk/option" ) @@ -29,7 +29,7 @@ func TestBrowserSessionHTTPClientRawCurl(t *testing.T) { option.WithHTTPClient(srv.Client()), ) - storeBrowserRouteCache(c.Options, browserscope.Ref{ + storeBrowserRouteCache(c.Options, browserrouting.Ref{ SessionID: "sid", BaseURL: srv.URL + "/browser/kernel", CdpWsURL: "wss://x/browser/cdp?jwt=j1", @@ -63,7 +63,7 @@ func TestBrowserSessionHTTPClientRequiresCachedRoute(t *testing.T) { option.WithAPIKey("sk"), ) - storeBrowserRouteCache(c.Options, browserscope.Ref{ + storeBrowserRouteCache(c.Options, browserrouting.Ref{ SessionID: "sid", BaseURL: "https://browser-session.test/browser/kernel", CdpWsURL: "wss://x/browser/cdp?jwt=j1", diff --git a/browserpool.go b/browserpool.go index 009bce2..3069e49 100644 --- a/browserpool.go +++ b/browserpool.go @@ -255,7 +255,7 @@ type BrowserPoolAcquireResponse struct { TimeoutSeconds int64 `json:"timeout_seconds" api:"required"` // Websocket URL for WebDriver BiDi connections to the browser session WebdriverWsURL string `json:"webdriver_ws_url" api:"required"` - // Direct-to-VM HTTP base URL for this browser session. + // Metro-API HTTP base URL for this browser session. BaseURL string `json:"base_url"` // Remote URL for live viewing the browser session. Only available for non-headless // browsers. diff --git a/client.go b/client.go index 9604f0c..1b719cf 100644 --- a/client.go +++ b/client.go @@ -9,7 +9,7 @@ import ( "slices" "github.com/kernel/kernel-go-sdk/internal/requestconfig" - "github.com/kernel/kernel-go-sdk/lib/browserscope" + "github.com/kernel/kernel-go-sdk/lib/browserrouting" "github.com/kernel/kernel-go-sdk/option" ) @@ -19,7 +19,7 @@ import ( type Client struct { Options []option.RequestOption // BrowserRouteCache stores cached base_url and jwt data for direct-to-VM routing. - BrowserRouteCache *browserscope.RouteCache + BrowserRouteCache *browserrouting.RouteCache // Create and manage app deployments and stream deployment events. Deployments DeploymentService // List applications and versions. @@ -64,13 +64,18 @@ func DefaultClientOptions() []option.RequestOption { // the services and requests that this client makes. func NewClient(opts ...option.RequestOption) (r Client) { opts = append(DefaultClientOptions(), opts...) - cache := browserscope.NewRouteCache() + cache := browserrouting.NewRouteCache() + nextOpts := make([]option.RequestOption, 0, len(opts)+1) for _, opt := range opts { if routing, ok := opt.(*browserRoutingOption); ok { - routing.cache = cache + cloned := *routing + cloned.cache = cache + nextOpts = append(nextOpts, &cloned) + continue } + nextOpts = append(nextOpts, opt) } - opts = append(opts, withBrowserRouteCache(cache)) + opts = append(nextOpts, withBrowserRouteCache(cache)) r = Client{Options: opts, BrowserRouteCache: cache} diff --git a/go.mod b/go.mod index cd5b1e5..e5d1e20 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,13 @@ module github.com/kernel/kernel-go-sdk -go 1.23.0 +go 1.22 require ( github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 - golang.org/x/tools v0.31.0 ) require ( github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect - golang.org/x/mod v0.24.0 // indirect - golang.org/x/sync v0.12.0 // indirect ) diff --git a/go.sum b/go.sum index abdea0d..32ba293 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -10,9 +8,3 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= -golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= -golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= -golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= -golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= diff --git a/invocation.go b/invocation.go index 23346ca..872a775 100644 --- a/invocation.go +++ b/invocation.go @@ -552,7 +552,7 @@ type InvocationListBrowsersResponseBrowser struct { TimeoutSeconds int64 `json:"timeout_seconds" api:"required"` // Websocket URL for WebDriver BiDi connections to the browser session WebdriverWsURL string `json:"webdriver_ws_url" api:"required"` - // Direct-to-VM HTTP base URL for this browser session. + // Metro-API HTTP base URL for this browser session. BaseURL string `json:"base_url"` // Remote URL for live viewing the browser session. Only available for non-headless // browsers. diff --git a/lib/browserrouting/integration_test.go b/lib/browserrouting/integration_test.go new file mode 100644 index 0000000..f1d29db --- /dev/null +++ b/lib/browserrouting/integration_test.go @@ -0,0 +1,87 @@ +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") + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + client := kernel.NewClient( + option.WithAPIKey(apiKey), + option.WithBaseURL(baseURL), + kernel.WithBrowserRouting(kernel.BrowserRoutingConfig{ + Enabled: true, + Subresources: []string{"process"}, + }), + ) + + 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..98fca6e --- /dev/null +++ b/lib/browserrouting/route_cache.go @@ -0,0 +1,140 @@ +package browserrouting + +import ( + "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 +} + +// 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) { + sessionID, subresource, suffix, ok := parseDirectVMPath(req.URL.Path) + if !ok { + return next(req) + } + if _, ok := allowed[subresource]; !ok { + return next(req) + } + route, ok := cache.Load(sessionID) + if !ok { + return next(req) + } + + 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 = "" + + return next(req) + } +} + +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..aa84b58 --- /dev/null +++ b/lib/browserrouting/route_cache_test.go @@ -0,0 +1,52 @@ +package browserrouting + +import ( + "net/http" + "net/url" + "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")) + } +} diff --git a/option/requestoption.go b/option/requestoption.go index 82b0927..fd632db 100644 --- a/option/requestoption.go +++ b/option/requestoption.go @@ -56,7 +56,7 @@ type HTTPClient interface { // For custom uses cases, it is recommended to provide an [*http.Client] with a custom // [http.RoundTripper] as its transport, rather than directly implementing [HTTPClient]. func WithHTTPClient(client HTTPClient) RequestOption { - return requestconfig.PreRequestOptionFunc(func(r *requestconfig.RequestConfig) error { + return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error { if client == nil { return fmt.Errorf("requestoption: custom http client cannot be nil") } From d594f39a352e2ad5639807f222bb16535cce72c6 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 22 Apr 2026 15:22:21 -0400 Subject: [PATCH 11/20] fix: remove old go browser scope package Drop the superseded lib/browserscope files now that the renamed browserrouting package owns the direct-to-VM helpers. Made-with: Cursor --- lib/browserscope/integration_test.go | 87 ----------------- lib/browserscope/jwt.go | 25 ----- lib/browserscope/jwt_test.go | 21 ---- lib/browserscope/middleware.go | 41 -------- lib/browserscope/middleware_test.go | 57 ----------- lib/browserscope/rawcurl.go | 81 ---------------- lib/browserscope/rawcurl_test.go | 54 ----------- lib/browserscope/ref.go | 43 --------- lib/browserscope/route_cache.go | 139 --------------------------- 9 files changed, 548 deletions(-) delete mode 100644 lib/browserscope/integration_test.go delete mode 100644 lib/browserscope/jwt.go delete mode 100644 lib/browserscope/jwt_test.go delete mode 100644 lib/browserscope/middleware.go delete mode 100644 lib/browserscope/middleware_test.go delete mode 100644 lib/browserscope/rawcurl.go delete mode 100644 lib/browserscope/rawcurl_test.go delete mode 100644 lib/browserscope/ref.go delete mode 100644 lib/browserscope/route_cache.go diff --git a/lib/browserscope/integration_test.go b/lib/browserscope/integration_test.go deleted file mode 100644 index f2bef82..0000000 --- a/lib/browserscope/integration_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package browserscope_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") - } - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - - client := kernel.NewClient( - option.WithAPIKey(apiKey), - option.WithBaseURL(baseURL), - kernel.WithBrowserRouting(kernel.BrowserRoutingConfig{ - Enabled: true, - Subresources: []string{"process"}, - }), - ) - - 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/browserscope/jwt.go b/lib/browserscope/jwt.go deleted file mode 100644 index 5e1f857..0000000 --- a/lib/browserscope/jwt.go +++ /dev/null @@ -1,25 +0,0 @@ -package browserscope - -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("browserscope: empty websocket url") - } - u, err := url.Parse(wsURL) - if err != nil { - return "", fmt.Errorf("browserscope: parse websocket url: %w", err) - } - jwt := u.Query().Get("jwt") - if jwt == "" { - return "", fmt.Errorf("browserscope: missing jwt query parameter") - } - return jwt, nil -} diff --git a/lib/browserscope/jwt_test.go b/lib/browserscope/jwt_test.go deleted file mode 100644 index 220ce77..0000000 --- a/lib/browserscope/jwt_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package browserscope - -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/browserscope/middleware.go b/lib/browserscope/middleware.go deleted file mode 100644 index 8f9f7bf..0000000 --- a/lib/browserscope/middleware.go +++ /dev/null @@ -1,41 +0,0 @@ -package browserscope - -import ( - "net/http" - "strings" - - "github.com/kernel/kernel-go-sdk/option" -) - -// BrowserSessionMiddleware prepares requests for a browser session base_url: -// it strips the control-plane browsers/{session_id} path prefix, attaches jwt, -// and removes Authorization so the Kernel API key is not forwarded to the browser. -func BrowserSessionMiddleware(sessionID, jwt string) option.Middleware { - marker := "/browsers/" + sessionID + "/" - return func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { - req.Header.Del("Authorization") - - if jwt != "" { - q := req.URL.Query() - if q.Get("jwt") == "" { - q.Set("jwt", jwt) - req.URL.RawQuery = q.Encode() - } - } - - if sessionID != "" { - if idx := strings.Index(req.URL.Path, marker); idx >= 0 { - prefix := strings.TrimRight(req.URL.Path[:idx], "/") - rest := strings.TrimPrefix(req.URL.Path[idx+len(marker):], "/") - if rest == "" { - rest = "/" - } else { - rest = "/" + rest - } - req.URL.Path = prefix + rest - } - } - - return next(req) - } -} diff --git a/lib/browserscope/middleware_test.go b/lib/browserscope/middleware_test.go deleted file mode 100644 index afd1efe..0000000 --- a/lib/browserscope/middleware_test.go +++ /dev/null @@ -1,57 +0,0 @@ -package browserscope - -import ( - "net/http" - "net/url" - "testing" - - "github.com/kernel/kernel-go-sdk/option" -) - -func TestBrowserSessionMiddleware(t *testing.T) { - mw := BrowserSessionMiddleware("sess1", "tok") - var final *http.Request - next := func(req *http.Request) (*http.Response, error) { - final = req - return nil, nil - } - - u, err := url.Parse("https://host/browser/kernel/browsers/sess1/process/exec?x=1") - if err != nil { - t.Fatal(err) - } - u.Path = "/browser/kernel/browsers/sess1/process/exec" - req := &http.Request{URL: u, Header: http.Header{"Authorization": {"Bearer sk"}}} - _, _ = mw(req, next) - - if final.Header.Get("Authorization") != "" { - t.Fatal("authorization should be stripped") - } - if final.URL.Query().Get("jwt") != "tok" { - t.Fatalf("jwt query: got %q", final.URL.Query().Get("jwt")) - } - if final.URL.Path != "/browser/kernel/process/exec" { - t.Fatalf("path rewrite: got %s", final.URL.Path) - } -} - -func TestBrowserSessionMiddlewarePreservesExistingJWT(t *testing.T) { - mw := BrowserSessionMiddleware("sess1", "tok") - var final *http.Request - next := func(req *http.Request) (*http.Response, error) { - final = req - return nil, nil - } - - u, _ := url.Parse("https://host/browser/kernel/browsers/sess1/fs/list_files?jwt=already") - u.Path = "/browser/kernel/browsers/sess1/fs/list_files" - req := &http.Request{URL: u} - _, _ = mw(req, next) - if final.URL.Query().Get("jwt") != "already" { - t.Fatalf("jwt: got %q want already", final.URL.Query().Get("jwt")) - } -} - -func TestBrowserSessionMiddlewareType(t *testing.T) { - var _ option.Middleware = BrowserSessionMiddleware("a", "b") -} diff --git a/lib/browserscope/rawcurl.go b/lib/browserscope/rawcurl.go deleted file mode 100644 index a53fc04..0000000 --- a/lib/browserscope/rawcurl.go +++ /dev/null @@ -1,81 +0,0 @@ -package browserscope - -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) - -// HTTPClient returns an [http.Client] that performs browser egress HTTP via the -// browser session base_url and internal /curl/raw path. -func HTTPClient(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("browserscope: raw curl requires an absolute request URL (got %q)", req.URL) - } - if req.URL.Scheme != "http" && req.URL.Scheme != "https" { - return nil, fmt.Errorf("browserscope: raw curl requires http or https scheme") - } - if t.browserBaseURL == "" { - return nil, fmt.Errorf("browserscope: browser base_url is required") - } - if t.jwt == "" { - return nil, fmt.Errorf("browserscope: 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/browserscope/rawcurl_test.go b/lib/browserscope/rawcurl_test.go deleted file mode 100644 index f39a622..0000000 --- a/lib/browserscope/rawcurl_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package browserscope - -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/browserscope/ref.go b/lib/browserscope/ref.go deleted file mode 100644 index 5c384dd..0000000 --- a/lib/browserscope/ref.go +++ /dev/null @@ -1,43 +0,0 @@ -package browserscope - -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("browserscope: base_url is required") - } - if strings.TrimSpace(r.SessionID) == "" { - return Ref{}, fmt.Errorf("browserscope: 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("browserscope: 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/browserscope/route_cache.go b/lib/browserscope/route_cache.go deleted file mode 100644 index dc3cd5d..0000000 --- a/lib/browserscope/route_cache.go +++ /dev/null @@ -1,139 +0,0 @@ -package browserscope - -import ( - "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 -} - -// 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, directToVMSubresources []string) option.Middleware { - allowed := map[string]struct{}{} - for _, subresource := range directToVMSubresources { - if trimmed := strings.TrimSpace(subresource); trimmed != "" { - allowed[trimmed] = struct{}{} - } - } - - return func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { - sessionID, subresource, suffix, ok := parseDirectVMPath(req.URL.Path) - if !ok { - return next(req) - } - if _, ok := allowed[subresource]; !ok { - return next(req) - } - route, ok := cache.Load(sessionID) - if !ok { - return next(req) - } - - 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) - - return next(req) - } -} - -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 -} From c0430e3e2952f10648ed0181edf85a69230e9279 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 03:08:22 +0000 Subject: [PATCH 12/20] chore(internal): more robust bootstrap script --- scripts/bootstrap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 681e57f34b3199a4d720fb517da08b3bbd6bc0fb Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Fri, 24 Apr 2026 11:08:51 -0400 Subject: [PATCH 13/20] refactor: move go browser routing rollout to env Stop exposing browser routing rollout controls on the client constructor and derive direct-to-VM subresource routing from KERNEL_BROWSER_ROUTING_SUBRESOURCES instead, defaulting to curl while keeping raw HTTP helpers cache-backed. --- browser_routing.go | 41 +++++++++++++++++--------- browser_routing_test.go | 41 ++++++++++++++++++++++++-- client.go | 15 +++------- examples/browser-routing/main.go | 7 +---- lib/browserrouting/integration_test.go | 5 +--- 5 files changed, 72 insertions(+), 37 deletions(-) diff --git a/browser_routing.go b/browser_routing.go index 330e951..5d49567 100644 --- a/browser_routing.go +++ b/browser_routing.go @@ -1,36 +1,31 @@ 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" ) -// BrowserRoutingConfig controls which browser subresources route directly to the browser VM. -type BrowserRoutingConfig struct { - Enabled bool - Subresources []string -} +const browserRoutingSubresourcesEnv = "KERNEL_BROWSER_ROUTING_SUBRESOURCES" type browserRoutingOption struct { - cache *browserrouting.RouteCache - config BrowserRoutingConfig + cache *browserrouting.RouteCache + subresources []string } 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 withBrowserRoutingSubresources(cache *browserrouting.RouteCache, subresources []string) option.RequestOption { + return &browserRoutingOption{cache: cache, subresources: subresources} } 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)) + r.Middlewares = append(r.Middlewares, browserrouting.DirectVMRoutingMiddleware(o.cache, o.subresources)) return nil } @@ -82,3 +77,21 @@ func browserRouteFromRef(ref browserrouting.Ref) (browserrouting.Route, bool) { 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 index fede55c..e1dd48e 100644 --- a/browser_routing_test.go +++ b/browser_routing_test.go @@ -5,12 +5,15 @@ import ( "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 @@ -45,7 +48,6 @@ func TestBrowserRoutingWarmsCacheAndRoutesAllowlistedSubresources(t *testing.T) 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 { @@ -70,6 +72,8 @@ func TestBrowserRoutingWarmsCacheAndRoutesAllowlistedSubresources(t *testing.T) } 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) { @@ -97,7 +101,6 @@ func TestBrowserRoutingSkipsSubresourcesOutsideConfiguredAllowlist(t *testing.T) 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 { @@ -111,3 +114,37 @@ func TestBrowserRoutingSkipsSubresourcesOutsideConfiguredAllowlist(t *testing.T) 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/client.go b/client.go index 1b719cf..cf6e7d7 100644 --- a/client.go +++ b/client.go @@ -65,17 +65,10 @@ func DefaultClientOptions() []option.RequestOption { func NewClient(opts ...option.RequestOption) (r Client) { opts = append(DefaultClientOptions(), opts...) cache := browserrouting.NewRouteCache() - nextOpts := make([]option.RequestOption, 0, len(opts)+1) - for _, opt := range opts { - if routing, ok := opt.(*browserRoutingOption); ok { - cloned := *routing - cloned.cache = cache - nextOpts = append(nextOpts, &cloned) - continue - } - nextOpts = append(nextOpts, opt) - } - opts = append(nextOpts, withBrowserRouteCache(cache)) + opts = append(opts, + withBrowserRouteCache(cache), + withBrowserRoutingSubresources(cache, browserRoutingSubresourcesFromEnv()), + ) r = Client{Options: opts, BrowserRouteCache: cache} diff --git a/examples/browser-routing/main.go b/examples/browser-routing/main.go index 3e3591c..f7e0976 100644 --- a/examples/browser-routing/main.go +++ b/examples/browser-routing/main.go @@ -11,12 +11,7 @@ import ( func main() { ctx := context.Background() - client := kernel.NewClient( - kernel.WithBrowserRouting(kernel.BrowserRoutingConfig{ - Enabled: true, - Subresources: []string{"process"}, - }), - ) + client := kernel.NewClient() browser, err := client.Browsers.New(ctx, kernel.BrowserNewParams{ Headless: kernel.Bool(true), diff --git a/lib/browserrouting/integration_test.go b/lib/browserrouting/integration_test.go index f1d29db..a3a736f 100644 --- a/lib/browserrouting/integration_test.go +++ b/lib/browserrouting/integration_test.go @@ -19,6 +19,7 @@ func TestIntegrationBrowserRouting(t *testing.T) { 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() @@ -26,10 +27,6 @@ func TestIntegrationBrowserRouting(t *testing.T) { client := kernel.NewClient( option.WithAPIKey(apiKey), option.WithBaseURL(baseURL), - kernel.WithBrowserRouting(kernel.BrowserRoutingConfig{ - Enabled: true, - Subresources: []string{"process"}, - }), ) browser, err := client.Browsers.New(ctx, kernel.BrowserNewParams{ From 1ec1358150d20982fc582c634e68f42322d1fa4c Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Fri, 24 Apr 2026 11:42:53 -0400 Subject: [PATCH 14/20] fix: propagate browser HTTP client config errors Return request config errors from Browsers.HTTPClient instead of silently falling back to the default client, so invalid options do not drop custom HTTP behavior without notice. Add a regression test for the failure path. Made-with: Cursor --- browser_http_client.go | 2 +- browser_session_httpclient_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/browser_http_client.go b/browser_http_client.go index d128d0c..f2a19eb 100644 --- a/browser_http_client.go +++ b/browser_http_client.go @@ -27,7 +27,7 @@ func (r *BrowserService) HTTPClient(id string, opts ...option.RequestOption) (*h 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 + return nil, err } return browserrouting.NewHTTPClient(route.BaseURL, route.JWT, cfg.HTTPClient), nil diff --git a/browser_session_httpclient_test.go b/browser_session_httpclient_test.go index 63d0f99..c12f204 100644 --- a/browser_session_httpclient_test.go +++ b/browser_session_httpclient_test.go @@ -1,11 +1,13 @@ 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" ) @@ -75,3 +77,27 @@ func TestBrowserSessionHTTPClientRequiresCachedRoute(t *testing.T) { 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") + } +} From e23defc4abad6e49ae854ead1d1a015a2ecad097 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 17:56:49 +0000 Subject: [PATCH 15/20] feat: Expose browser_session_id on managed auth connection --- .stats.yml | 4 ++-- authconnection.go | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) 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/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 From 62078d3213dbe4ede9600968b48d5c8e7aa118e4 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Fri, 24 Apr 2026 15:58:22 -0400 Subject: [PATCH 16/20] refactor: move browser route cache sync into middleware Centralize browser route cache warm-up and eviction in the shared routing middleware so browser service methods can stay generic while direct-to-VM routing still learns browser base URLs from API responses. Made-with: Cursor --- browser.go | 18 --- lib/browserrouting/route_cache.go | 163 +++++++++++++++++++++---- lib/browserrouting/route_cache_test.go | 146 ++++++++++++++++++++++ 3 files changed, 288 insertions(+), 39 deletions(-) diff --git a/browser.go b/browser.go index 21ef07d..4533fc1 100644 --- a/browser.go +++ b/browser.go @@ -19,7 +19,6 @@ 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" @@ -70,9 +69,6 @@ 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 } @@ -85,9 +81,6 @@ 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 } @@ -100,9 +93,6 @@ 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 } @@ -122,9 +112,6 @@ 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 } @@ -170,11 +157,6 @@ 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 } diff --git a/lib/browserrouting/route_cache.go b/lib/browserrouting/route_cache.go index 98fca6e..f520a5e 100644 --- a/lib/browserrouting/route_cache.go +++ b/lib/browserrouting/route_cache.go @@ -1,6 +1,10 @@ package browserrouting import ( + "bytes" + "encoding/json" + "io" + "mime" "net/http" "net/url" "strings" @@ -73,39 +77,156 @@ func DirectVMRoutingMiddleware(cache *RouteCache, subresources []string) option. } return func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { + cacheSessionID, cacheablePath := parseBrowserMetadataPath(req.URL.Path) sessionID, subresource, suffix, ok := parseDirectVMPath(req.URL.Path) if !ok { - return next(req) - } - if _, ok := allowed[subresource]; !ok { - return next(req) + res, err := next(req) + if err != nil { + return res, err + } + if req.Method == http.MethodDelete && cacheSessionID != "" && isSuccessfulResponse(res) { + cache.Delete(cacheSessionID) + } + if cacheablePath { + if err := sniffAndPopulateCache(res, cache); err != nil { + return nil, err + } + } + return res, nil } - route, ok := cache.Load(sessionID) - if !ok { - return next(req) + 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 = "" + } } - base, err := url.Parse(route.BaseURL) + res, err := next(req) if err != nil { - return nil, err + return res, err + } + if req.Method == http.MethodDelete && cacheSessionID != "" && isSuccessfulResponse(res) { + cache.Delete(cacheSessionID) + } + if cacheablePath { + if err := sniffAndPopulateCache(res, cache); err != nil { + return nil, err + } + } + return res, nil + } +} + +func parseBrowserMetadataPath(path string) (sessionID string, ok bool) { + parts := strings.Split(strings.Trim(path, "/"), "/") + for i := 0; i < len(parts); i++ { + if parts[i] != "browsers" { + continue + } + switch len(parts) - i { + case 1: + return "", true + case 2: + if parts[i+1] == "" { + return "", false + } + return parts[i+1], true + } + } + return "", false +} + +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) } - 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() + case map[string]any: + for _, child := range value { + if child != nil { + populateCache(child, cache) } } + } +} - 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 = "" +func routeFromValue(value any) (Route, bool) { + record, ok := value.(map[string]any) + if !ok { + return Route{}, false + } - return next(req) + 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) { diff --git a/lib/browserrouting/route_cache_test.go b/lib/browserrouting/route_cache_test.go index aa84b58..5336fe1 100644 --- a/lib/browserrouting/route_cache_test.go +++ b/lib/browserrouting/route_cache_test.go @@ -1,8 +1,10 @@ package browserrouting import ( + "io" "net/http" "net/url" + "strings" "testing" ) @@ -50,3 +52,147 @@ func TestDirectVMRoutingMiddlewareClearsStaleRawPath(t *testing.T) { t.Fatalf("expected authorization to be stripped, got %q", got.Header.Get("Authorization")) } } + +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 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") + } +} From b293866537bddb183ad7e8d014c0e2c298d715a9 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Fri, 24 Apr 2026 16:21:27 -0400 Subject: [PATCH 17/20] fix: tighten browser route metadata parsing Reject subresource paths when detecting browser metadata responses and unify the post-response cache sync path so routing middleware does not carry dead duplicate logic. Made-with: Cursor --- lib/browserrouting/route_cache.go | 79 ++++++++++++-------------- lib/browserrouting/route_cache_test.go | 9 +++ 2 files changed, 45 insertions(+), 43 deletions(-) diff --git a/lib/browserrouting/route_cache.go b/lib/browserrouting/route_cache.go index f520a5e..d6eb25c 100644 --- a/lib/browserrouting/route_cache.go +++ b/lib/browserrouting/route_cache.go @@ -79,42 +79,29 @@ func DirectVMRoutingMiddleware(cache *RouteCache, subresources []string) option. return func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { cacheSessionID, cacheablePath := parseBrowserMetadataPath(req.URL.Path) sessionID, subresource, suffix, ok := parseDirectVMPath(req.URL.Path) - if !ok { - res, err := next(req) - if err != nil { - return res, err - } - if req.Method == http.MethodDelete && cacheSessionID != "" && isSuccessfulResponse(res) { - cache.Delete(cacheSessionID) - } - if cacheablePath { - if err := sniffAndPopulateCache(res, cache); err != nil { - return nil, err - } - } - return res, nil - } - 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() + 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 = "" + 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 = "" + } } } @@ -122,15 +109,7 @@ func DirectVMRoutingMiddleware(cache *RouteCache, subresources []string) option. if err != nil { return res, err } - if req.Method == http.MethodDelete && cacheSessionID != "" && isSuccessfulResponse(res) { - cache.Delete(cacheSessionID) - } - if cacheablePath { - if err := sniffAndPopulateCache(res, cache); err != nil { - return nil, err - } - } - return res, nil + return finalizeResponse(req, res, cache, cacheSessionID, cacheablePath) } } @@ -148,11 +127,25 @@ func parseBrowserMetadataPath(path string) (sessionID string, ok bool) { return "", false } return parts[i+1], true + default: + return "", false } } return "", false } +func finalizeResponse(req *http.Request, res *http.Response, cache *RouteCache, cacheSessionID string, cacheablePath bool) (*http.Response, error) { + if req.Method == http.MethodDelete && cacheSessionID != "" && isSuccessfulResponse(res) { + cache.Delete(cacheSessionID) + } + if cacheablePath { + if err := sniffAndPopulateCache(res, cache); err != nil { + return nil, err + } + } + 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 diff --git a/lib/browserrouting/route_cache_test.go b/lib/browserrouting/route_cache_test.go index 5336fe1..d75c0f8 100644 --- a/lib/browserrouting/route_cache_test.go +++ b/lib/browserrouting/route_cache_test.go @@ -53,6 +53,15 @@ func TestDirectVMRoutingMiddlewareClearsStaleRawPath(t *testing.T) { } } +func TestParseBrowserMetadataPathRejectsSubresourcePaths(t *testing.T) { + if sessionID, ok := parseBrowserMetadataPath("/browsers/sess-1/process/exec"); ok || sessionID != "" { + t.Fatalf("expected subresource path to be rejected, got sessionID=%q ok=%v", sessionID, ok) + } + if sessionID, ok := parseBrowserMetadataPath("/browsers/sess-1/browsers"); ok || sessionID != "" { + t.Fatalf("expected nested browsers subresource path to be rejected, got sessionID=%q ok=%v", sessionID, ok) + } +} + func TestDirectVMRoutingMiddlewarePopulatesCacheFromJSONResponse(t *testing.T) { cache := NewRouteCache() middleware := DirectVMRoutingMiddleware(cache, nil) From 0e44ff363c53b1225d48313be6376b2cda7b057f Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Fri, 24 Apr 2026 16:37:05 -0400 Subject: [PATCH 18/20] fix: make browser route deletion win over sniffing Process cache sniffing before successful browser delete eviction so delete responses that include browser metadata cannot reinsert stale route entries. Add a regression test for JSON delete responses. Made-with: Cursor --- lib/browserrouting/route_cache.go | 6 ++--- lib/browserrouting/route_cache_test.go | 37 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/lib/browserrouting/route_cache.go b/lib/browserrouting/route_cache.go index d6eb25c..bd045af 100644 --- a/lib/browserrouting/route_cache.go +++ b/lib/browserrouting/route_cache.go @@ -135,14 +135,14 @@ func parseBrowserMetadataPath(path string) (sessionID string, ok bool) { } func finalizeResponse(req *http.Request, res *http.Response, cache *RouteCache, cacheSessionID string, cacheablePath bool) (*http.Response, error) { - if req.Method == http.MethodDelete && cacheSessionID != "" && isSuccessfulResponse(res) { - cache.Delete(cacheSessionID) - } if cacheablePath { if err := sniffAndPopulateCache(res, cache); err != nil { return nil, err } } + if req.Method == http.MethodDelete && cacheSessionID != "" && isSuccessfulResponse(res) { + cache.Delete(cacheSessionID) + } return res, nil } diff --git a/lib/browserrouting/route_cache_test.go b/lib/browserrouting/route_cache_test.go index d75c0f8..27cb026 100644 --- a/lib/browserrouting/route_cache_test.go +++ b/lib/browserrouting/route_cache_test.go @@ -205,3 +205,40 @@ func TestDirectVMRoutingMiddlewareEvictsCacheOnSuccessfulBrowserDelete(t *testin t.Fatal("expected successful browser delete 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") + } +} From 4f754d13f38e5b545de8d84cb26de28f6f2f892a Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Fri, 24 Apr 2026 17:06:45 -0400 Subject: [PATCH 19/20] fix: keep browser pool routing in sync with cache Warm the direct VM route cache from browser pool acquire responses and evict released sessions by sniffing the pool release request body in middleware. Made-with: Cursor --- lib/browserrouting/route_cache.go | 110 ++++++++++++++++++++----- lib/browserrouting/route_cache_test.go | 108 ++++++++++++++++++++++-- 2 files changed, 194 insertions(+), 24 deletions(-) diff --git a/lib/browserrouting/route_cache.go b/lib/browserrouting/route_cache.go index bd045af..3631bd1 100644 --- a/lib/browserrouting/route_cache.go +++ b/lib/browserrouting/route_cache.go @@ -26,6 +26,11 @@ type RouteCache struct { 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{}} @@ -77,7 +82,10 @@ func DirectVMRoutingMiddleware(cache *RouteCache, subresources []string) option. } return func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { - cacheSessionID, cacheablePath := parseBrowserMetadataPath(req.URL.Path) + 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 { @@ -109,39 +117,103 @@ func DirectVMRoutingMiddleware(cache *RouteCache, subresources []string) option. if err != nil { return res, err } - return finalizeResponse(req, res, cache, cacheSessionID, cacheablePath) + return finalizeResponse(res, cache, lifecycle) } } -func parseBrowserMetadataPath(path string) (sessionID string, ok bool) { - parts := strings.Split(strings.Trim(path, "/"), "/") +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++ { - if parts[i] != "browsers" { - continue + 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] } - switch len(parts) - i { - case 1: - return "", true - case 2: - if parts[i+1] == "" { - return "", false + 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 parts[i+1], true + 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 "", false + 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 } - return "", false + sessionID, _ := payload["session_id"].(string) + return strings.TrimSpace(sessionID), nil } -func finalizeResponse(req *http.Request, res *http.Response, cache *RouteCache, cacheSessionID string, cacheablePath bool) (*http.Response, error) { - if cacheablePath { +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 req.Method == http.MethodDelete && cacheSessionID != "" && isSuccessfulResponse(res) { - cache.Delete(cacheSessionID) + if lifecycle.evictSessionID != "" && isSuccessfulResponse(res) { + cache.Delete(lifecycle.evictSessionID) } return res, nil } diff --git a/lib/browserrouting/route_cache_test.go b/lib/browserrouting/route_cache_test.go index 27cb026..b489941 100644 --- a/lib/browserrouting/route_cache_test.go +++ b/lib/browserrouting/route_cache_test.go @@ -53,12 +53,28 @@ func TestDirectVMRoutingMiddlewareClearsStaleRawPath(t *testing.T) { } } -func TestParseBrowserMetadataPathRejectsSubresourcePaths(t *testing.T) { - if sessionID, ok := parseBrowserMetadataPath("/browsers/sess-1/process/exec"); ok || sessionID != "" { - t.Fatalf("expected subresource path to be rejected, got sessionID=%q ok=%v", sessionID, ok) +func TestParseCacheLifecycleRejectsBrowserSubresourcePaths(t *testing.T) { + cases := []string{ + "/browsers/sess-1/process/exec", + "/browsers/sess-1/browsers", } - if sessionID, ok := parseBrowserMetadataPath("/browsers/sess-1/browsers"); ok || sessionID != "" { - t.Fatalf("expected nested browsers subresource path to be rejected, got sessionID=%q ok=%v", sessionID, ok) + + 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) + } } } @@ -140,6 +156,41 @@ func TestDirectVMRoutingMiddlewarePopulatesCacheFromNestedJSONResponse(t *testin } } +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) @@ -206,6 +257,53 @@ func TestDirectVMRoutingMiddlewareEvictsCacheOnSuccessfulBrowserDelete(t *testin } } +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{ From 01bdd6accdf7f491adbbcd182d5e7771e07695e9 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 21:15:43 +0000 Subject: [PATCH 20/20] release: 0.51.0 --- .release-please-manifest.json | 2 +- CHANGELOG.md | 42 +++++++++++++++++++++++++++++++++++ README.md | 2 +- internal/version.go | 2 +- 4 files changed, 45 insertions(+), 3 deletions(-) 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/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/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