diff --git a/README.md b/README.md index 4ae4a096..db90e6dc 100644 --- a/README.md +++ b/README.md @@ -292,6 +292,10 @@ kernel search contents srch_01jsearchresult --limit 3 --content-source browser - `kernel browsers delete ` - Delete a browser by ID or name - `kernel browsers view ` - Get live view URL for a browser by ID or name - `--output json`, `-o json` - Output JSON with liveViewUrl +- `kernel browsers live-view create ` - Create a live view URL scoped to read-only or control access. The URL works only for the live view, not CDP or other browser endpoints. + - `--scope ` - Access scope (default: read) + - `--ttl ` - Seconds until the URL expires (default: valid until the browser is deleted) + - `--output json`, `-o json` - Output raw JSON object - `kernel browsers get ` - Get detailed browser session info by ID or name - `--output json`, `-o json` - Output raw JSON object - `kernel browsers update ` - Update a running browser session by ID or name @@ -1657,6 +1661,9 @@ kernel browsers delete browser123 # Get live view URL kernel browsers view browser123 +# Share a read-only live view URL that expires in an hour +kernel browsers live-view create browser123 --scope read --ttl 3600 + # Make an HTTP request through the browser session kernel browsers curl browser123 https://example.com diff --git a/cmd/browsers.go b/cmd/browsers.go index 23e32188..e776db72 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -53,6 +53,11 @@ type BrowserReplaysService interface { Stop(ctx context.Context, replayID string, body kernel.BrowserReplayStopParams, opts ...option.RequestOption) (err error) } +// BrowserLiveViewService defines the subset we use for scoped live view URLs. +type BrowserLiveViewService interface { + New(ctx context.Context, idOrName string, body kernel.BrowserLiveViewNewParams, opts ...option.RequestOption) (res *kernel.BrowserLiveView, err error) +} + // BrowserFSService defines the subset we use for browser filesystem APIs. type BrowserFSService interface { NewDirectory(ctx context.Context, idOrName string, body kernel.BrowserFNewDirectoryParams, opts ...option.RequestOption) (err error) @@ -575,6 +580,13 @@ type BrowsersViewInput struct { Output string } +type BrowsersLiveViewCreateInput struct { + Identifier string + Scope string + TTLSeconds int64 + Output string +} + type BrowsersGetInput struct { Identifier string IncludeDeleted bool @@ -631,6 +643,7 @@ type BrowsersUpdateInput struct { type BrowsersCmd struct { browsers BrowsersService replays BrowserReplaysService + liveView BrowserLiveViewService fs BrowserFSService fsWatch BrowserFWatchService process BrowserProcessService @@ -1078,6 +1091,51 @@ func (b BrowsersCmd) View(ctx context.Context, in BrowsersViewInput) error { return nil } +func (b BrowsersCmd) LiveViewCreate(ctx context.Context, in BrowsersLiveViewCreateInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + + req := kernel.BrowserLiveViewRequestParam{} + switch in.Scope { + case "": + case string(kernel.BrowserLiveViewScopeRead), string(kernel.BrowserLiveViewScopeControl): + req.Scope = kernel.BrowserLiveViewScope(in.Scope) + default: + return fmt.Errorf("invalid --scope value: %s (must be one of read, control)", in.Scope) + } + if in.TTLSeconds < 0 { + return fmt.Errorf("invalid --ttl value: %d (must be a positive number of seconds)", in.TTLSeconds) + } + if in.TTLSeconds > 0 { + req.TtlSeconds = kernel.Opt(in.TTLSeconds) + } + + // Each call mints a URL that can't be revoked, so a retry after a lost + // response would leave an extra live URL behind. + res, err := b.liveView.New(ctx, in.Identifier, kernel.BrowserLiveViewNewParams{BrowserLiveViewRequest: req}, option.WithMaxRetries(0)) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + return util.PrintPrettyJSON(res) + } + + expires := "When the browser is deleted" + if !res.ExpiresAt.IsZero() { + expires = util.FormatLocal(res.ExpiresAt) + } + rows := pterm.TableData{ + {"Property", "Value"}, + {"URL", res.URL}, + {"Scope", string(res.Scope)}, + {"Expires", expires}, + } + PrintTableNoPad(rows, true) + return nil +} + func (b BrowsersCmd) Get(ctx context.Context, in BrowsersGetInput) error { if err := validateJSONOutput(in.Output); err != nil { return err @@ -3308,6 +3366,25 @@ func init() { logsRoot.AddCommand(logsStream) browsersCmd.AddCommand(logsRoot) + // live view + liveViewRoot := &cobra.Command{Use: "live-view", Short: "Create scoped live view URLs"} + liveViewCreate := &cobra.Command{ + Use: "create ", + Short: "Create a read-only or control live view URL for a browser", + Long: `Create a live view URL that grants either read-only or control access to a headful browser. + +The URL authorizes the live view only: it cannot connect to CDP, WebDriver, or any other +browser endpoint. It stays valid until the browser is deleted, or until --ttl seconds +elapse when set. It cannot be revoked before then, so treat it as a credential.`, + Args: cobra.ExactArgs(1), + RunE: runBrowsersLiveViewCreate, + } + liveViewCreate.Flags().String("scope", "", "Access scope: read (view only) or control (default: read)") + liveViewCreate.Flags().Int64("ttl", 0, "Seconds until the URL expires (default: valid until the browser is deleted)") + addJSONOutputFlag(liveViewCreate) + liveViewRoot.AddCommand(liveViewCreate) + browsersCmd.AddCommand(liveViewRoot) + // replays replaysRoot := &cobra.Command{Use: "replays", Short: "Manage browser replays"} replaysList := &cobra.Command{Use: "list ", Short: "List replays for a browser", Args: cobra.ExactArgs(1), RunE: runBrowsersReplaysList} @@ -4066,6 +4143,15 @@ func runBrowsersReplaysStart(cmd *cobra.Command, args []string) error { return b.ReplaysStart(cmd.Context(), BrowsersReplaysStartInput{Identifier: args[0], Framerate: fr, MaxDurationSeconds: md, RecordAudio: recordAudio, Output: output}) } +func runBrowsersLiveViewCreate(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + scope, _ := cmd.Flags().GetString("scope") + ttl, _ := cmd.Flags().GetInt64("ttl") + output, _ := cmd.Flags().GetString("output") + b := BrowsersCmd{liveView: &client.Browsers.LiveView} + return b.LiveViewCreate(cmd.Context(), BrowsersLiveViewCreateInput{Identifier: args[0], Scope: scope, TTLSeconds: ttl, Output: output}) +} + func runBrowsersReplaysStop(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) svc := client.Browsers diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index 3d927189..cb2534d3 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" "strings" + "sync/atomic" "testing" "time" @@ -3647,3 +3648,96 @@ func TestBrowsersUpdate_AllowedHostsValidation(t *testing.T) { }) } } + +// --- Tests for Live View --- + +type FakeLiveViewService struct { + NewFunc func(ctx context.Context, idOrName string, body kernel.BrowserLiveViewNewParams, opts ...option.RequestOption) (*kernel.BrowserLiveView, error) +} + +func (f *FakeLiveViewService) New(ctx context.Context, idOrName string, body kernel.BrowserLiveViewNewParams, opts ...option.RequestOption) (*kernel.BrowserLiveView, error) { + return f.NewFunc(ctx, idOrName, body, opts...) +} + +func TestBrowsersLiveViewCreate_SendsScopeAndTTL(t *testing.T) { + setupStdoutCapture(t) + var gotID string + var gotBody []byte + fake := &FakeLiveViewService{NewFunc: func(ctx context.Context, idOrName string, body kernel.BrowserLiveViewNewParams, opts ...option.RequestOption) (*kernel.BrowserLiveView, error) { + gotID = idOrName + gotBody, _ = body.MarshalJSON() + return &kernel.BrowserLiveView{URL: "https://live/view", Scope: kernel.BrowserLiveViewScopeControl, ExpiresAt: time.Unix(3600, 0)}, nil + }} + b := BrowsersCmd{liveView: fake} + err := b.LiveViewCreate(context.Background(), BrowsersLiveViewCreateInput{Identifier: "my-browser", Scope: "control", TTLSeconds: 600}) + require.NoError(t, err) + assert.Equal(t, "my-browser", gotID) + assert.JSONEq(t, `{"scope":"control","ttl_seconds":600}`, string(gotBody)) + out := outBuf.String() + assert.Contains(t, out, "https://live/view") + assert.Contains(t, out, "control") + assert.Contains(t, out, util.FormatLocal(time.Unix(3600, 0))) +} + +func TestBrowsersLiveViewCreate_OmitsUnsetFields(t *testing.T) { + setupStdoutCapture(t) + var gotBody []byte + fake := &FakeLiveViewService{NewFunc: func(ctx context.Context, idOrName string, body kernel.BrowserLiveViewNewParams, opts ...option.RequestOption) (*kernel.BrowserLiveView, error) { + gotBody, _ = body.MarshalJSON() + return &kernel.BrowserLiveView{URL: "https://live/view", Scope: kernel.BrowserLiveViewScopeRead}, nil + }} + b := BrowsersCmd{liveView: fake} + err := b.LiveViewCreate(context.Background(), BrowsersLiveViewCreateInput{Identifier: "id"}) + require.NoError(t, err) + assert.JSONEq(t, `{}`, string(gotBody)) + assert.Contains(t, outBuf.String(), "When the browser is deleted") +} + +func TestBrowsersLiveViewCreate_JSONOutput(t *testing.T) { + setupStdoutCapture(t) + var res kernel.BrowserLiveView + require.NoError(t, json.Unmarshal([]byte(`{"url":"https://live/view","scope":"read"}`), &res)) + fake := &FakeLiveViewService{NewFunc: func(ctx context.Context, idOrName string, body kernel.BrowserLiveViewNewParams, opts ...option.RequestOption) (*kernel.BrowserLiveView, error) { + return &res, nil + }} + b := BrowsersCmd{liveView: fake} + out := captureStdout(t, func() { + require.NoError(t, b.LiveViewCreate(context.Background(), BrowsersLiveViewCreateInput{Identifier: "id", Output: "json"})) + }) + assert.JSONEq(t, `{"url":"https://live/view","scope":"read"}`, out) +} + +func TestBrowsersLiveViewCreate_DoesNotRetry(t *testing.T) { + var requests atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + client := kernel.NewClient(option.WithBaseURL(srv.URL), option.WithAPIKey("test")) + b := BrowsersCmd{liveView: &client.Browsers.LiveView} + err := b.LiveViewCreate(context.Background(), BrowsersLiveViewCreateInput{Identifier: "id"}) + assert.Error(t, err) + assert.Equal(t, int32(1), requests.Load()) +} + +func TestBrowsersLiveViewCreate_RejectsInvalidFlags(t *testing.T) { + called := false + fake := &FakeLiveViewService{NewFunc: func(ctx context.Context, idOrName string, body kernel.BrowserLiveViewNewParams, opts ...option.RequestOption) (*kernel.BrowserLiveView, error) { + called = true + return &kernel.BrowserLiveView{}, nil + }} + b := BrowsersCmd{liveView: fake} + + err := b.LiveViewCreate(context.Background(), BrowsersLiveViewCreateInput{Identifier: "id", Scope: "write"}) + assert.ErrorContains(t, err, "invalid --scope value: write") + + err = b.LiveViewCreate(context.Background(), BrowsersLiveViewCreateInput{Identifier: "id", TTLSeconds: -1}) + assert.ErrorContains(t, err, "invalid --ttl value: -1") + + err = b.LiveViewCreate(context.Background(), BrowsersLiveViewCreateInput{Identifier: "id", Output: "yaml"}) + assert.Error(t, err) + + assert.False(t, called) +} diff --git a/go.mod b/go.mod index 6a28ebc3..aa26ffb6 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.145.0 + github.com/kernel/kernel-go-sdk v0.148.0 github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index d01fa942..befa2ec3 100644 --- a/go.sum +++ b/go.sum @@ -68,8 +68,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.145.0 h1:IJDd9I1nxpSMWlnVDqqJuieLhVJAz8dK0rYSrHFn1WE= -github.com/kernel/kernel-go-sdk v0.145.0/go.mod h1:IoxXdj7vgL3LwtAASaRDhs4qgp9AXwBuB3+3EiRBB+U= +github.com/kernel/kernel-go-sdk v0.148.0 h1:ShpqouDgYdfpCareZgC+YeHQHo+MuTkHYsM/vfAAvHs= +github.com/kernel/kernel-go-sdk v0.148.0/go.mod h1:IoxXdj7vgL3LwtAASaRDhs4qgp9AXwBuB3+3EiRBB+U= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=