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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 15 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,25 +205,29 @@ Commands with JSON output support:
### Browser Management

- `kernel browsers list` - List running browsers
- `--query <q>` - Search by name, session ID, profile ID, proxy ID, or pool name
- `--tag <KEY=VALUE>` - Filter by tag, repeatable; a session must match every pair
- `--output json`, `-o json` - Output raw JSON array
- `kernel browsers create` - Create a new browser session
- `-s, --stealth` - Launch browser in stealth mode to avoid detection
- `-H, --headless` - Launch browser without GUI access
- `--kiosk` - Launch browser in kiosk mode
- `--start-url <url>` - Initial page to open on launch
- `--pool-id <id>` - Acquire a browser from the specified pool (mutually exclusive with --pool-name; ignores other session flags)
- `--name <name>` - Optional unique name for the session (set at creation; used to find it later by name)
- `--tag <KEY=VALUE>` - Set a tag on the session, repeatable; up to 50 pairs
- `--pool-id <id>` - Acquire a browser from the specified pool (mutually exclusive with --pool-name; ignores other session flags). `--name`/`--tag` still apply to the acquired session.
- `--pool-name <name>` - Acquire a browser from the pool name (mutually exclusive with --pool-id; ignores other session flags)
- `--telemetry=all` - Enable telemetry for all categories
- `--telemetry=off` - Disable telemetry
- `--telemetry=<list>` - Per-category config, e.g. `--telemetry=network=on,page=off`
- `--output json`, `-o json` - Output raw JSON object
- _Note: When a pool is specified, omit other session configuration flags—pool settings determine profile, proxy, viewport, etc._
- `kernel browsers delete <id>` - Delete a browser
- `kernel browsers view <id>` - Get live view URL for a 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 get <id>` - Get detailed browser session info
- `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>` - Update a running browser session
- `kernel browsers update <id-or-name>` - Update a running browser session by ID or name
- `--telemetry=all` - Enable telemetry for all categories
- `--telemetry=off` - Disable telemetry
- `--telemetry=<list>` - Per-category config, e.g. `--telemetry=network=on,page=off`
Expand Down Expand Up @@ -264,6 +268,8 @@ Commands with JSON output support:
- `--force` - Force delete even if browsers are leased
- `kernel browser-pools acquire <id-or-name>` - Acquire a browser from the pool
- `--timeout <seconds>` - Acquire timeout before returning 204
- `--name <name>` - Optional name for the acquired session (applies to this lease; cleared on release)
- `--tag <KEY=VALUE>` - Set a tag on the acquired session, repeatable; applies to this lease
- `--output json`, `-o json` - Output raw JSON object
- `kernel browser-pools release <id-or-name>` - Release a browser back to the pool
- `--session-id <id>` - Browser session ID to release (required)
Expand Down Expand Up @@ -457,10 +463,9 @@ Per-category updates are partial — only categories you name are changed; other
- `--country <code>` - ISO 3166 country code or "EU" (location-based types)
- `--city <name>` - City name (no spaces, e.g. sanfrancisco) (residential, mobile; requires `--country`)
- `--state <code>` - Two-letter state code (residential, mobile)
- `--zip <zip>` - US ZIP code (residential, mobile)
- `--asn <asn>` - Autonomous system number (e.g., AS15169) (residential, mobile)
- `--zip <zip>` - US ZIP code (residential)
- `--asn <asn>` - Autonomous system number (e.g., AS15169) (residential)
- `--os <os>` - Operating system: windows, macos, android (residential)
- `--carrier <carrier>` - Mobile carrier (mobile)
- `--host <host>` - Proxy host (custom; required)
- `--port <port>` - Proxy port (custom; required)
- `--username <username>` - Username for proxy authentication (custom)
Expand Down Expand Up @@ -781,8 +786,8 @@ kernel proxies create --type custom --host proxy.example.com --port 8080 --usern
# Create a residential proxy with location and OS
kernel proxies create --type residential --country US --city sanfrancisco --state CA --zip 94107 --asn AS15169 --os windows --name "SF Residential"

# Create a mobile proxy with carrier
kernel proxies create --type mobile --country US --carrier verizon --name "US Mobile"
# Create a mobile proxy
kernel proxies create --type mobile --country US --city sanfrancisco --name "US Mobile"

# Get proxy details
kernel proxies get prx_123
Expand Down
84 changes: 69 additions & 15 deletions cmd/browser_pools.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ import (
"github.com/kernel/cli/pkg/util"
"github.com/kernel/kernel-go-sdk"
"github.com/kernel/kernel-go-sdk/option"
"github.com/kernel/kernel-go-sdk/packages/pagination"
"github.com/pterm/pterm"
"github.com/spf13/cobra"
)

// BrowserPoolsService defines the subset of the Kernel SDK browser pools client that we use.
type BrowserPoolsService interface {
List(ctx context.Context, opts ...option.RequestOption) (res *[]kernel.BrowserPool, err error)
List(ctx context.Context, query kernel.BrowserPoolListParams, opts ...option.RequestOption) (res *pagination.OffsetPagination[kernel.BrowserPool], err error)
New(ctx context.Context, body kernel.BrowserPoolNewParams, opts ...option.RequestOption) (res *kernel.BrowserPool, err error)
Get(ctx context.Context, id string, opts ...option.RequestOption) (res *kernel.BrowserPool, err error)
Update(ctx context.Context, id string, body kernel.BrowserPoolUpdateParams, opts ...option.RequestOption) (res *kernel.BrowserPool, err error)
Expand All @@ -29,6 +30,8 @@ type BrowserPoolsCmd struct {
}

type BrowserPoolsListInput struct {
Limit int
Offset int
Output string
}

Expand All @@ -37,20 +40,33 @@ func (c BrowserPoolsCmd) List(ctx context.Context, in BrowserPoolsListInput) err
return err
}

pools, err := c.client.List(ctx)
params := kernel.BrowserPoolListParams{}
if in.Limit > 0 {
params.Limit = kernel.Int(int64(in.Limit))
}
if in.Offset > 0 {
params.Offset = kernel.Int(int64(in.Offset))
}

page, err := c.client.List(ctx, params)
if err != nil {
return util.CleanedUpSdkError{Err: err}
}

var pools []kernel.BrowserPool
if page != nil {
pools = page.Items
}

if in.Output == "json" {
if pools == nil || len(*pools) == 0 {
if len(pools) == 0 {
fmt.Println("[]")
return nil
}
return util.PrintPrettyJSONSlice(*pools)
return util.PrintPrettyJSONSlice(pools)
}

if pools == nil || len(*pools) == 0 {
if len(pools) == 0 {
pterm.Info.Println("No browser pools found")
return nil
}
Expand All @@ -59,7 +75,7 @@ func (c BrowserPoolsCmd) List(ctx context.Context, in BrowserPoolsListInput) err
{"ID", "Name", "Available", "Acquired", "Created At", "Size"},
}

for _, p := range *pools {
for _, p := range pools {
tableData = append(tableData, []string{
p.ID,
util.OrDash(p.Name),
Expand Down Expand Up @@ -250,7 +266,7 @@ func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput)
params.Name = kernel.String(in.Name)
}
if in.Size > 0 {
params.Size = in.Size
params.Size = kernel.Int(in.Size)
}
if in.FillRate > 0 {
params.FillRatePerMinute = kernel.Int(in.FillRate)
Expand Down Expand Up @@ -338,18 +354,34 @@ func (c BrowserPoolsCmd) Delete(ctx context.Context, in BrowserPoolsDeleteInput)
type BrowserPoolsAcquireInput struct {
IDOrName string
TimeoutSeconds int64
Name string
Tags map[string]string
Output string
}

// buildAcquireParams builds the SDK params for acquiring a browser from a pool.
// Shared by `browser-pools acquire` and the `browsers create --pool-id/--pool-name`
// path so the per-lease name/tags forwarding cannot silently diverge between them.
func buildAcquireParams(name string, tags map[string]string, timeoutSeconds int64) kernel.BrowserPoolAcquireParams {
params := kernel.BrowserPoolAcquireParams{}
if timeoutSeconds > 0 {
params.AcquireTimeoutSeconds = kernel.Int(timeoutSeconds)
}
if name != "" {
params.Name = kernel.Opt(name)
}
if len(tags) > 0 {
params.Tags = kernel.Tags(tags)
}
return params
}

func (c BrowserPoolsCmd) Acquire(ctx context.Context, in BrowserPoolsAcquireInput) error {
if err := validateJSONOutput(in.Output); err != nil {
return err
}

params := kernel.BrowserPoolAcquireParams{}
if in.TimeoutSeconds > 0 {
params.AcquireTimeoutSeconds = kernel.Int(in.TimeoutSeconds)
}
params := buildAcquireParams(in.Name, in.Tags, in.TimeoutSeconds)
resp, err := c.client.Acquire(ctx, in.IDOrName, params)
if err != nil {
return util.CleanedUpSdkError{Err: err}
Expand All @@ -370,12 +402,20 @@ func (c BrowserPoolsCmd) Acquire(ctx context.Context, in BrowserPoolsAcquireInpu
tableData := pterm.TableData{
{"Property", "Value"},
{"Session ID", resp.SessionID},
{"CDP WebSocket URL", resp.CdpWsURL},
{"Live View URL", resp.BrowserLiveViewURL},
}
if resp.Name != "" {
tableData = append(tableData, []string{"Name", resp.Name})
}
tableData = append(tableData,
[]string{"CDP WebSocket URL", resp.CdpWsURL},
[]string{"Live View URL", resp.BrowserLiveViewURL},
)
if resp.StartURL != "" {
tableData = append(tableData, []string{"Start URL", resp.StartURL})
}
if len(resp.Tags) > 0 {
tableData = append(tableData, []string{"Tags", formatTags(resp.Tags)})
}
PrintTableNoPad(tableData, true)
return nil
}
Expand Down Expand Up @@ -482,6 +522,8 @@ var browserPoolsFlushCmd = &cobra.Command{

func init() {
addJSONOutputFlag(browserPoolsListCmd)
browserPoolsListCmd.Flags().Int("limit", 0, "Maximum number of pools to return")
browserPoolsListCmd.Flags().Int("offset", 0, "Number of pools to skip (for pagination)")

addJSONOutputFlag(browserPoolsCreateCmd)
browserPoolsCreateCmd.Flags().String("name", "", "Optional unique name for the pool")
Expand Down Expand Up @@ -523,6 +565,8 @@ func init() {
browserPoolsDeleteCmd.Flags().Bool("force", false, "Force delete even if browsers are leased")

browserPoolsAcquireCmd.Flags().Int64("timeout", 0, "Acquire timeout in seconds")
browserPoolsAcquireCmd.Flags().String("name", "", "Optional name for the acquired session (applies to this lease; cleared on release)")
browserPoolsAcquireCmd.Flags().StringArray("tag", nil, "Set a tag KEY=VALUE on the acquired session (repeatable; applies to this lease)")
addJSONOutputFlag(browserPoolsAcquireCmd)

browserPoolsReleaseCmd.Flags().String("session-id", "", "Browser session ID to release")
Expand All @@ -542,8 +586,10 @@ func init() {
func runBrowserPoolsList(cmd *cobra.Command, args []string) error {
client := getKernelClient(cmd)
out, _ := cmd.Flags().GetString("output")
limit, _ := cmd.Flags().GetInt("limit")
offset, _ := cmd.Flags().GetInt("offset")
c := BrowserPoolsCmd{client: &client.BrowserPools}
return c.List(cmd.Context(), BrowserPoolsListInput{Output: out})
return c.List(cmd.Context(), BrowserPoolsListInput{Limit: limit, Offset: offset, Output: out})
}

func runBrowserPoolsCreate(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -656,9 +702,17 @@ func runBrowserPoolsDelete(cmd *cobra.Command, args []string) error {
func runBrowserPoolsAcquire(cmd *cobra.Command, args []string) error {
client := getKernelClient(cmd)
timeout, _ := cmd.Flags().GetInt64("timeout")
name, _ := cmd.Flags().GetString("name")
tags := tagsFromFlag(cmd, "tag")
output, _ := cmd.Flags().GetString("output")
c := BrowserPoolsCmd{client: &client.BrowserPools}
return c.Acquire(cmd.Context(), BrowserPoolsAcquireInput{IDOrName: args[0], TimeoutSeconds: timeout, Output: output})
return c.Acquire(cmd.Context(), BrowserPoolsAcquireInput{
IDOrName: args[0],
TimeoutSeconds: timeout,
Name: name,
Tags: tags,
Output: output,
})
}

func runBrowserPoolsRelease(cmd *cobra.Command, args []string) error {
Expand Down
130 changes: 130 additions & 0 deletions cmd/browser_pools_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package cmd

import (
"context"
"testing"

"github.com/kernel/kernel-go-sdk"
"github.com/kernel/kernel-go-sdk/option"
"github.com/kernel/kernel-go-sdk/packages/pagination"
"github.com/stretchr/testify/assert"
)

// FakeBrowserPoolsService is a configurable fake implementing BrowserPoolsService.
type FakeBrowserPoolsService struct {
AcquireFunc func(ctx context.Context, id string, body kernel.BrowserPoolAcquireParams, opts ...option.RequestOption) (*kernel.BrowserPoolAcquireResponse, error)
ListFunc func(ctx context.Context, query kernel.BrowserPoolListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserPool], error)
}

func (f *FakeBrowserPoolsService) List(ctx context.Context, query kernel.BrowserPoolListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserPool], error) {
if f.ListFunc != nil {
return f.ListFunc(ctx, query, opts...)
}
return &pagination.OffsetPagination[kernel.BrowserPool]{Items: []kernel.BrowserPool{}}, nil
}

func (f *FakeBrowserPoolsService) New(ctx context.Context, body kernel.BrowserPoolNewParams, opts ...option.RequestOption) (*kernel.BrowserPool, error) {
return &kernel.BrowserPool{}, nil
}

func (f *FakeBrowserPoolsService) Get(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.BrowserPool, error) {
return &kernel.BrowserPool{}, nil
}

func (f *FakeBrowserPoolsService) Update(ctx context.Context, id string, body kernel.BrowserPoolUpdateParams, opts ...option.RequestOption) (*kernel.BrowserPool, error) {
return &kernel.BrowserPool{}, nil
}

func (f *FakeBrowserPoolsService) Delete(ctx context.Context, id string, body kernel.BrowserPoolDeleteParams, opts ...option.RequestOption) error {
return nil
}

func (f *FakeBrowserPoolsService) Acquire(ctx context.Context, id string, body kernel.BrowserPoolAcquireParams, opts ...option.RequestOption) (*kernel.BrowserPoolAcquireResponse, error) {
if f.AcquireFunc != nil {
return f.AcquireFunc(ctx, id, body, opts...)
}
return &kernel.BrowserPoolAcquireResponse{}, nil
}

func (f *FakeBrowserPoolsService) Release(ctx context.Context, id string, body kernel.BrowserPoolReleaseParams, opts ...option.RequestOption) error {
return nil
}

func (f *FakeBrowserPoolsService) Flush(ctx context.Context, id string, opts ...option.RequestOption) error {
return nil
}

func TestBrowserPoolsAcquire_WithNameAndTags(t *testing.T) {
setupStdoutCapture(t)

var capturedID string
var captured kernel.BrowserPoolAcquireParams
fake := &FakeBrowserPoolsService{
AcquireFunc: func(ctx context.Context, id string, body kernel.BrowserPoolAcquireParams, opts ...option.RequestOption) (*kernel.BrowserPoolAcquireResponse, error) {
capturedID = id
captured = body
return &kernel.BrowserPoolAcquireResponse{
SessionID: "sess-acq",
CdpWsURL: "ws://cdp-acq",
Name: "lease-name",
Tags: kernel.Tags{"env": "prod"},
}, nil
},
}

c := BrowserPoolsCmd{client: fake}
err := c.Acquire(context.Background(), BrowserPoolsAcquireInput{
IDOrName: "my-pool",
Name: "lease-name",
Tags: map[string]string{"env": "prod"},
})
assert.NoError(t, err)

// Pool lookup is by id or name; name + tags are forwarded per-lease.
assert.Equal(t, "my-pool", capturedID)
assert.True(t, captured.Name.Valid())
assert.Equal(t, "lease-name", captured.Name.Value)
assert.Equal(t, "prod", captured.Tags["env"])

// And surfaced in the acquired-session table.
out := outBuf.String()
assert.Contains(t, out, "lease-name")
assert.Contains(t, out, "Tags")
assert.Contains(t, out, "env=prod")
}

func TestBrowserPoolsList_ForwardsLimitOffset(t *testing.T) {
setupStdoutCapture(t)

var captured kernel.BrowserPoolListParams
fake := &FakeBrowserPoolsService{
ListFunc: func(ctx context.Context, query kernel.BrowserPoolListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserPool], error) {
captured = query
return &pagination.OffsetPagination[kernel.BrowserPool]{Items: []kernel.BrowserPool{}}, nil
},
}

c := BrowserPoolsCmd{client: fake}
err := c.List(context.Background(), BrowserPoolsListInput{Limit: 4, Offset: 8})

assert.NoError(t, err)
assert.Equal(t, int64(4), captured.Limit.Value)
assert.Equal(t, int64(8), captured.Offset.Value)
}

// TestBuildAcquireParams covers the shared name/tags/timeout forwarding used by
// both `browser-pools acquire` and the `browsers create --pool-id` lease path.
func TestBuildAcquireParams(t *testing.T) {
p := buildAcquireParams("lease", map[string]string{"env": "prod"}, 30)
assert.True(t, p.Name.Valid())
assert.Equal(t, "lease", p.Name.Value)
assert.Equal(t, "prod", p.Tags["env"])
assert.True(t, p.AcquireTimeoutSeconds.Valid())
assert.Equal(t, int64(30), p.AcquireTimeoutSeconds.Value)

// Unset inputs produce an empty params struct (nothing forwarded).
empty := buildAcquireParams("", nil, 0)
assert.False(t, empty.Name.Valid())
assert.Len(t, empty.Tags, 0)
assert.False(t, empty.AcquireTimeoutSeconds.Valid())
}
Loading
Loading