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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ kernel search --request '{"query":"browser automation","include_domains":["examp
- `--kiosk` - Launch browser in kiosk mode
- `--region us-east|eu-west|ap-southeast` - Geographic region for the session. Fixed once the session is created; requires a Start-Up or Enterprise plan and defaults to `us-east`.
- `--private-host <host>` - Destination the browser reaches directly through the session's own network instead of Kernel-managed egress, for private hosts on a VPN or tunnel the session joins (repeatable or comma-separated, max 32). Accepts hostname patterns (`*.example.ts.net`), IPs (`10.1.30.63`, `[fd00::1]`), and private CIDRs (`100.64.0.0/10`). Replaces the default private ranges (RFC1918, `100.64.0.0/10`, `fc00::/7`); omit to keep them. Fixed once the session is created. Unrelated to a proxy's `--bypass-host`, which only chooses between upstream proxy and Kernel-managed direct egress.
- `--proxy-route '<host>[,<host>...]=<proxy>'` - Route matching browser requests through a selected proxy (repeatable, max 10 routes with 1–50 hosts each). Example: `--proxy-route 'api.ipify.org,*.ipify.org=name:my-dc-proxy'`. The proxy is an ID by default; use `id:<id>` or `name:<name>` explicitly. Exact hostnames beat wildcards; longer wildcard suffixes beat shorter ones. `*.example.com` matches subdomains, not `example.com`. Matching ignores case and ports. Unmatched hosts use `--proxy-*` or default egress, while `--start-url` uses the top-level proxy during setup. Routes are create-only and are not available on pool sessions.
- `--start-url <url>` - Initial page to open on launch
- `--proxy-id <id>` / `--proxy-name <name>` - Use that proxy for the session regardless of stealth (mutually exclusive with each other and with `--proxy-mode`)
- `--proxy-mode direct|default` - Egress mode instead of a selected proxy: `direct` for no proxy regardless of stealth, `default` for the stealth-derived default (Kernel's stealth proxy with `--stealth`, direct egress otherwise). Omit all proxy flags to get the default.
Expand Down
88 changes: 85 additions & 3 deletions cmd/browsers.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,10 +230,76 @@ func buildNetworkParam(privateHosts []string) (kernel.BrowserNetworkConfigParam,
if len(hosts) > maxPrivateHosts {
return network, fmt.Errorf("too many --private-host entries: %d (maximum %d)", len(hosts), maxPrivateHosts)
}
network.PrivateHosts = hosts
if len(hosts) > 0 {
network.PrivateHosts = hosts
}
return network, nil
}

const (
maxProxyRoutes = 10
maxProxyRouteHosts = 50
)

// parseProxyRoutes converts each --proxy-route HOST[,HOST...]=ID|id:ID|name:NAME
// into the SDK's create-only network parameter. Host semantics are validated by the API.
func parseProxyRoutes(values []string) ([]kernel.BrowserNetworkConfigProxyRouteParam, error) {
if len(values) > maxProxyRoutes {
return nil, fmt.Errorf("too many --proxy-route entries: %d (maximum %d)", len(values), maxProxyRoutes)
}
routes := make([]kernel.BrowserNetworkConfigProxyRouteParam, 0, len(values))
for _, value := range values {
hostList, selector, ok := strings.Cut(value, "=")
if !ok || strings.TrimSpace(selector) == "" || strings.Contains(selector, "=") {
return nil, fmt.Errorf("invalid --proxy-route %q: expected HOST[,HOST...]=PROXY", value)
}
hosts := strings.Split(hostList, ",")
if len(hosts) > maxProxyRouteHosts {
return nil, fmt.Errorf("too many hosts in --proxy-route: %d (maximum %d)", len(hosts), maxProxyRouteHosts)
}
for i, host := range hosts {
hosts[i] = strings.TrimSpace(host)
if hosts[i] == "" {
return nil, fmt.Errorf("invalid --proxy-route %q: hosts must be non-empty", value)
}
}
selector = strings.TrimSpace(selector)
proxy := kernel.BrowserNetworkConfigProxyRouteProxyParam{}
switch {
case strings.HasPrefix(selector, "name:"):
proxy.Name = kernel.Opt(strings.TrimSpace(strings.TrimPrefix(selector, "name:")))
if proxy.Name.Value == "" {
return nil, fmt.Errorf("invalid --proxy-route %q: proxy name must be non-empty", value)
}
case strings.HasPrefix(selector, "id:"):
selector = strings.TrimSpace(strings.TrimPrefix(selector, "id:"))
if selector == "" {
return nil, fmt.Errorf("invalid --proxy-route %q: proxy ID must be non-empty", value)
}
proxy.ID = kernel.Opt(selector)
default:
proxy.ID = kernel.Opt(selector)
}
routes = append(routes, kernel.BrowserNetworkConfigProxyRouteParam{Hosts: hosts, Proxy: proxy})
}
return routes, nil
}

func formatProxyRoutes(network kernel.BrowserNetworkConfig) string {
if len(network.ProxyRoutes) == 0 {
return "-"
}
routes := make([]string, 0, len(network.ProxyRoutes))
for _, route := range network.ProxyRoutes {
proxy := route.Proxy.ID
if proxy == "" {
proxy = route.Proxy.Name
}
routes = append(routes, strings.Join(route.Hosts, ", ")+" = "+proxy)
}
return strings.Join(routes, "; ")
}

// formatPrivateHosts renders a network configuration for table output. A missing
// private_hosts list means the API's default private ranges apply; an explicit
// empty list means nothing routes around Kernel-managed egress.
Expand Down Expand Up @@ -394,6 +460,7 @@ type BrowsersCreateInput struct {
ProxyMode string
Region string
PrivateHosts []string
ProxyRoutes []string
StartURL string
Extensions []string
Vaults []string
Expand Down Expand Up @@ -660,7 +727,12 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error {
if err != nil {
return err
}
if len(network.PrivateHosts) > 0 {
routes, err := parseProxyRoutes(in.ProxyRoutes)
if err != nil {
return err
}
network.ProxyRoutes = routes
if len(network.PrivateHosts) > 0 || len(network.ProxyRoutes) > 0 {
params.Network = network
}

Expand Down Expand Up @@ -732,7 +804,9 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error {
return util.PrintPrettyJSON(browser)
}

printBrowserSessionResult(browser.SessionID, browser.CdpWsURL, browser.BrowserLiveViewURL, browser.Profile, browser.ProfileSaveChanges, browser.StartURL, browser.Name, browser.Tags)
tableData := buildBrowserTableData(browser.SessionID, browser.CdpWsURL, browser.BrowserLiveViewURL, browser.Profile, browser.ProfileSaveChanges, browser.StartURL, browser.Name, browser.Tags)
tableData = append(tableData, []string{"Private Hosts", formatPrivateHosts(browser.Network)}, []string{"Proxy Routes", formatProxyRoutes(browser.Network)})
PrintTableNoPad(tableData, true)
if len(browser.Vaults) > 0 {
rows := pterm.TableData{{"Attached vault ID", "Name"}}
for _, vault := range browser.Vaults {
Expand Down Expand Up @@ -874,6 +948,7 @@ func (b BrowsersCmd) Get(ctx context.Context, in BrowsersGetInput) error {
tableData = append(tableData, []string{"Proxy", proxy})
}
tableData = append(tableData, []string{"Private Hosts", formatPrivateHosts(browser.Network)})
tableData = append(tableData, []string{"Proxy Routes", formatProxyRoutes(browser.Network)})
if vaults := formatVaultReferences(browser.Vaults); vaults != "" {
tableData = append(tableData, []string{"Vaults", vaults})
}
Expand Down Expand Up @@ -3172,6 +3247,7 @@ unrestricted code execution inside the browser VM and is not sandboxed.`,
browsersCreateCmd.Flags().String("proxy-mode", "", "Proxy egress mode instead of a selected proxy: 'direct' for no proxy regardless of stealth, or 'default' for the browser default (Kernel's stealth proxy when --stealth is set, direct egress otherwise)")
browsersCreateCmd.Flags().String("region", "", "Geographic region for the session: 'us-east', 'eu-west', or 'ap-southeast'. Fixed once the session is created; requires a Start-Up or Enterprise plan and defaults to us-east")
browsersCreateCmd.Flags().StringSlice("private-host", nil, "Destinations the browser reaches directly through its own network instead of Kernel-managed egress, for private hosts on a VPN or tunnel the session joins (repeat or comma-separated, max 32). Accepts hostname patterns ('*.example.ts.net'), IPs ('10.1.30.63', '[fd00::1]'), and private CIDRs ('100.64.0.0/10'). Replaces the default private ranges (RFC1918, 100.64.0.0/10, fc00::/7); omit to keep them. Fixed once the session is created")
browsersCreateCmd.Flags().StringArray("proxy-route", nil, "Route HOST[,HOST...]=PROXY through a proxy (repeatable, max 10 routes and 50 hosts per route). PROXY is an ID by default; use id:ID or name:NAME explicitly. Exact hosts beat wildcards (longer suffixes win); *.example.com excludes example.com. Unmatched hosts use --proxy-* or default egress; start_url uses the top-level proxy. Create-only")
browsersCreateCmd.Flags().String("start-url", "", "Initial page to open on launch")
browsersCreateCmd.Flags().StringSlice("extension", []string{}, "Extension IDs or names to load (repeatable; may be passed multiple times or comma-separated)")
browsersCreateCmd.Flags().String("viewport", "", "Browser viewport size (e.g., 1920x1080@25). Supported: 2560x1440@10, 1920x1080@25, 1920x1200@25, 1440x900@25, 1024x768@60, 1200x800@60, 1280x800@60")
Expand Down Expand Up @@ -3305,6 +3381,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error {
proxyMode, _ := cmd.Flags().GetString("proxy-mode")
region, _ := cmd.Flags().GetString("region")
privateHosts, _ := cmd.Flags().GetStringSlice("private-host")
proxyRoutes, _ := cmd.Flags().GetStringArray("proxy-route")
startURL, _ := cmd.Flags().GetString("start-url")
extensions, _ := cmd.Flags().GetStringSlice("extension")
vaults, _ := cmd.Flags().GetStringArray("vault")
Expand All @@ -3331,6 +3408,10 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error {
}
}

if (poolID != "" || poolName != "") && cmd.Flags().Changed("proxy-route") {
return fmt.Errorf("--proxy-route cannot be used with --pool-id or --pool-name; routes require a new browser")
}

if poolID != "" && poolName != "" {
pterm.Error.Println("must specify at most one of --pool-id or --pool-name")
return nil
Expand Down Expand Up @@ -3446,6 +3527,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error {
ProxyMode: proxyMode,
Region: region,
PrivateHosts: privateHosts,
ProxyRoutes: proxyRoutes,
StartURL: startURL,
Extensions: extensions,
Vaults: vaults,
Expand Down
115 changes: 115 additions & 0 deletions cmd/browsers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,121 @@ func TestBrowsersCreate_WithPrivateHosts(t *testing.T) {
}))
}

func TestParseProxyRoutes(t *testing.T) {
routes, err := parseProxyRoutes([]string{" api.ipify.org , *.ipify.org =name:my-dc-proxy", "other.example=id:proxy-123", "fallback.example=proxy-456"})
require.NoError(t, err)
require.Len(t, routes, 3)
assert.Equal(t, []string{"api.ipify.org", "*.ipify.org"}, routes[0].Hosts)
assert.Equal(t, "my-dc-proxy", routes[0].Proxy.Name.Value)
assert.False(t, routes[0].Proxy.ID.Valid())
assert.Equal(t, "proxy-123", routes[1].Proxy.ID.Value)
assert.Equal(t, "proxy-456", routes[2].Proxy.ID.Value)
assert.False(t, routes[2].Proxy.Name.Valid())

for _, value := range []string{"", "host", "=id:proxy", "host=", "host= id: ", "host=name: ", "host=proxy=other", "host,,other=proxy", "host,=proxy", ",host=proxy"} {
t.Run(value, func(t *testing.T) {
_, err := parseProxyRoutes([]string{value})
assert.Error(t, err)
})
}
_, err = parseProxyRoutes(make([]string, maxProxyRoutes+1))
assert.ErrorContains(t, err, "maximum 10")
atLimit := make([]string, maxProxyRoutes)
for i := range atLimit {
atLimit[i] = fmt.Sprintf("host-%d=proxy", i)
}
_, err = parseProxyRoutes(atLimit)
require.NoError(t, err)
_, err = parseProxyRoutes([]string{strings.Repeat("host,", maxProxyRouteHosts) + "host=proxy"})
assert.ErrorContains(t, err, "maximum 50")
_, err = parseProxyRoutes([]string{strings.Repeat("host,", maxProxyRouteHosts-1) + "host=proxy"})
require.NoError(t, err)
// Hostname and wildcard validity, duplicates, and overlaps belong to the API.
_, err = parseProxyRoutes([]string{"*.com=proxy", "*.com=proxy"})
assert.NoError(t, err)
}

func TestBrowsersCreate_WithProxyRoutes(t *testing.T) {
setupStdoutCapture(t)
var captured kernel.BrowserNewParams
fake := &FakeBrowsersService{
NewFunc: func(_ context.Context, body kernel.BrowserNewParams, _ ...option.RequestOption) (*kernel.BrowserNewResponse, error) {
captured = body
var resp kernel.BrowserNewResponse
err := json.Unmarshal([]byte(`{"session_id":"sess-routes","network":{"private_hosts":["internal.example"],"proxy_routes":[{"hosts":["api.ipify.org","*.ipify.org"],"proxy":{"id":"resolved-proxy"}}]}}`), &resp)
require.NoError(t, err)
return &resp, nil
},
}
b := BrowsersCmd{browsers: fake}
err := b.Create(context.Background(), BrowsersCreateInput{
PrivateHosts: []string{"internal.example"},
ProxyRoutes: []string{"api.ipify.org,*.ipify.org=name:my-dc-proxy", "other.example=proxy-456"},
ProxyMode: "direct",
})
require.NoError(t, err)
require.Len(t, captured.Network.ProxyRoutes, 2)
assert.Equal(t, []string{"api.ipify.org", "*.ipify.org"}, captured.Network.ProxyRoutes[0].Hosts)
assert.Equal(t, "my-dc-proxy", captured.Network.ProxyRoutes[0].Proxy.Name.Value)
assert.False(t, captured.Network.ProxyRoutes[0].Proxy.ID.Valid())
assert.Equal(t, "proxy-456", captured.Network.ProxyRoutes[1].Proxy.ID.Value)
assert.Equal(t, []string{"internal.example"}, captured.Network.PrivateHosts)
assert.Equal(t, kernel.BrowserProxyModeDirect, captured.Proxy.Mode)
raw, err := captured.MarshalJSON()
require.NoError(t, err)
assert.Contains(t, string(raw), `"proxy_routes":[{"hosts":["api.ipify.org","*.ipify.org"],"proxy":{"name":"my-dc-proxy"}}`)
assert.Contains(t, outBuf.String(), "Private Hosts")
assert.Contains(t, outBuf.String(), "Proxy Routes")
assert.Contains(t, outBuf.String(), "api.ipify.org, *.ipify.org = resolved-proxy")

jsonOutput := captureStdout(t, func() {
require.NoError(t, b.Create(context.Background(), BrowsersCreateInput{ProxyRoutes: []string{"host=proxy"}, Output: "json"}))
})
assert.Contains(t, jsonOutput, `"proxy_routes"`)
assert.Contains(t, jsonOutput, `"resolved-proxy"`)
assert.Nil(t, captured.Network.PrivateHosts)
assert.Len(t, captured.Network.ProxyRoutes, 1)
raw, err = captured.MarshalJSON()
require.NoError(t, err)
assert.Contains(t, string(raw), `"proxy_routes"`)
assert.NotContains(t, string(raw), `"private_hosts"`)
assert.Error(t, b.Create(context.Background(), BrowsersCreateInput{ProxyRoutes: []string{"host="}}))
}

func TestBrowsersGet_ProxyRoutes(t *testing.T) {
setupStdoutCapture(t)
fake := &FakeBrowsersService{
GetFunc: func(_ context.Context, _ string, _ kernel.BrowserGetParams, _ ...option.RequestOption) (*kernel.BrowserGetResponse, error) {
var resp kernel.BrowserGetResponse
err := json.Unmarshal([]byte(`{"session_id":"sess-routes","network":{"private_hosts":["internal.example"],"proxy_routes":[{"hosts":["api.ipify.org"],"proxy":{"id":"resolved-proxy"}}]}}`), &resp)
require.NoError(t, err)
return &resp, nil
},
}
b := BrowsersCmd{browsers: fake}
require.NoError(t, b.Get(context.Background(), BrowsersGetInput{Identifier: "sess-routes"}))
assert.Contains(t, outBuf.String(), "Private Hosts")
assert.Contains(t, outBuf.String(), "Proxy Routes")
assert.Contains(t, outBuf.String(), "api.ipify.org = resolved-proxy")
jsonOutput := captureStdout(t, func() {
require.NoError(t, b.Get(context.Background(), BrowsersGetInput{Identifier: "sess-routes", Output: "json"}))
})
assert.Contains(t, jsonOutput, `"proxy_routes"`)
assert.Contains(t, jsonOutput, `"resolved-proxy"`)
}

func TestProxyRouteFlagIsCreateOnly(t *testing.T) {
create, _, err := rootCmd.Find([]string{"browsers", "create"})
require.NoError(t, err)
assert.NotNil(t, create.Flags().Lookup("proxy-route"))
for _, path := range [][]string{{"browsers", "update"}, {"browser-pools", "create"}, {"browser-pools", "update"}} {
cmd, _, err := rootCmd.Find(path)
require.NoError(t, err)
assert.Nil(t, cmd.Flags().Lookup("proxy-route"))
}
assert.False(t, poolLeaseAllowedFlags()["proxy-route"])
}

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

Expand Down
10 changes: 5 additions & 5 deletions cmd/browsers_webmcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import (

// BrowserWebMCPService defines the subset we use for native page tools.
type BrowserWebMCPService interface {
ListTools(ctx context.Context, idOrName string, opts ...option.RequestOption) (*kernel.ToolsResponse, error)
ListTools(ctx context.Context, idOrName string, query kernel.BrowserWebmcpListToolsParams, opts ...option.RequestOption) (*kernel.ToolsResponse, error)
InvokeTool(ctx context.Context, idOrName string, body kernel.BrowserWebmcpInvokeToolParams, opts ...option.RequestOption) (*kernel.InvocationResult, error)
}

Expand All @@ -39,7 +39,7 @@ func (b BrowsersCmd) WebMCPList(ctx context.Context, in BrowsersWebMCPListInput)
if err := validateJSONOutput(in.Output); err != nil {
return err
}
res, err := b.webmcp.ListTools(ctx, in.Identifier)
res, err := b.webmcp.ListTools(ctx, in.Identifier, kernel.BrowserWebmcpListToolsParams{})
if err != nil {
return util.CleanedUpSdkError{Err: err}
}
Expand All @@ -53,10 +53,10 @@ func (b BrowsersCmd) WebMCPList(ctx context.Context, in BrowsersWebMCPListInput)
rows := pterm.TableData{{"Name", "Tool Ref", "Page URL", "Tab ID", "Read Only"}}
for _, tool := range res.Tools {
readOnly := "-"
if tool.Annotations.JSON.ReadOnly.Valid() {
readOnly = strconv.FormatBool(tool.Annotations.ReadOnly)
if tool.Tool.Annotations.JSON.ReadOnlyHint.Valid() {
readOnly = strconv.FormatBool(tool.Tool.Annotations.ReadOnlyHint)
}
rows = append(rows, []string{tool.Name, tool.ToolRef, tool.Source.PageURL, strconv.FormatInt(tool.Source.TabID, 10), readOnly})
rows = append(rows, []string{tool.Tool.Name, tool.ToolRef, tool.Source.PageURL, strconv.FormatInt(tool.Source.TabID, 10), readOnly})
}
PrintTableNoPad(rows, true)
return nil
Expand Down
8 changes: 4 additions & 4 deletions cmd/browsers_webmcp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ func executeWebMCPCommand(t *testing.T, handler http.HandlerFunc, stdin string,
return stdout, buf.String(), err
}

const webMCPToolsFixture = `{"tools":[{"name":"search","tool_ref":"opaque/ref+==","description":"Search the page","input_schema":{"type":"object"},"annotations":{"read_only":true,"autosubmit":false,"consequential":false,"untrusted_content":true},"source":{"window_id":1,"tab_id":42,"page_url":"https://example.com","page_title":"Example","frame":null}}],"future_field":true}`
const webMCPToolsFixture = `{"tools":[{"tool":{"name":"search","description":"Search the page","inputSchema":{"type":"object"},"annotations":{"readOnlyHint":true,"autosubmit":false}},"tool_ref":"opaque/ref+==","source":{"window_id":1,"tab_id":42,"page_url":"https://example.com","page_title":"Example","frame":null}}],"future_field":true}`

func TestWebMCPCommandWiring(t *testing.T) {
for _, name := range []string{"list", "invoke"} {
Expand Down Expand Up @@ -93,15 +93,15 @@ func TestWebMCPListEmpty(t *testing.T) {

func TestWebMCPListAnnotations(t *testing.T) {
for _, tc := range []struct{ annotation, want string }{
{`{"read_only":true}`, "true"},
{`{"read_only":false}`, "false"},
{`{"readOnlyHint":true}`, "true"},
{`{"readOnlyHint":false}`, "false"},
{`{}`, "-"},
{`null`, "-"},
} {
t.Run(tc.annotation, func(t *testing.T) {
_, table, err := executeWebMCPCommand(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"tools":[{"name":"search","annotations":%s}]}`, tc.annotation)
fmt.Fprintf(w, `{"tools":[{"tool":{"name":"search","annotations":%s}}]}`, tc.annotation)
}, "", "list", "my-browser")
require.NoError(t, err)
rows := strings.Split(strings.TrimSpace(table), "\n")
Expand Down
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.110.0
github.com/kernel/kernel-go-sdk v0.112.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
Loading
Loading