Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,10 @@ kernel search contents srch_01jsearchresult --limit 3 --content-source browser
- `kernel browsers delete <id-or-name>` - Delete a browser by ID or name
- `kernel browsers view <id-or-name>` - Get live view URL for a browser by ID or name
- `--output json`, `-o json` - Output JSON with liveViewUrl
- `kernel browsers live-view create <id-or-name>` - 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 <read|control>` - Access scope (default: read)
- `--ttl <seconds>` - Seconds until the URL expires (default: valid until the browser is deleted)
- `--output json`, `-o json` - Output raw JSON object
- `kernel browsers get <id-or-name>` - Get detailed browser session info by ID or name
- `--output json`, `-o json` - Output raw JSON object
- `kernel browsers update <id-or-name>` - Update a running browser session by ID or name
Expand Down Expand Up @@ -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

Expand Down
86 changes: 86 additions & 0 deletions cmd/browsers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -631,6 +643,7 @@ type BrowsersUpdateInput struct {
type BrowsersCmd struct {
browsers BrowsersService
replays BrowserReplaysService
liveView BrowserLiveViewService
fs BrowserFSService
fsWatch BrowserFWatchService
process BrowserProcessService
Expand Down Expand Up @@ -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}
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved

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
Expand Down Expand Up @@ -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 <id-or-name>",
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 <id>", Short: "List replays for a browser", Args: cobra.ExactArgs(1), RunE: runBrowsersReplaysList}
Expand Down Expand Up @@ -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
Expand Down
94 changes: 94 additions & 0 deletions cmd/browsers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"

Expand Down Expand Up @@ -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)
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
Loading