From e83444cce96be7aac041f18cd74710354174643b Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 19:27:55 +0000 Subject: [PATCH 1/6] CLI: Update SDK to 3c8b90c8bea096937d6cee06eabd6076e1801376 and add new commands/flags - Bump kernel-go-sdk to 3c8b90c8bea096937d6cee06eabd6076e1801376 - browser-pools create: add --allowed-host (BrowserPoolNewParams.Network.AllowedHosts) - browser-pools update: add --allowed-host / --clear-allowed-hosts (BrowserPoolUpdateParams.Network.AllowedHosts); clear flags preserve the other network flags passed in the same command - browser-pools get: show Allowed Hosts - browsers update/create: refresh help now that leased pooled browsers support allowlist changes; --pool-* + --allowed-host now points users at the pool flags - Fix: browsers create --private-host/--proxy-route without --allowed-host no longer sends an invalid empty "allowed_hosts": [] list Tested against the live API: browser-pools create --allowed-host, browser-pools get (Allowed Hosts row), browser-pools update --allowed-host --private-host, update --clear-allowed-hosts --private-host (kept private hosts), empty --allowed-host rejected, browser-pools acquire + browsers update --allowed-host on a leased browser + release, browsers create --private-host (no allowed_hosts sent). Resources cleaned up. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 ++-- cmd/browser_pools.go | 65 +++++++++++++++++++++++++++++---- cmd/browser_pools_test.go | 77 +++++++++++++++++++++++++++++++++++++++ cmd/browsers.go | 16 +++++--- go.mod | 2 +- go.sum | 4 +- 6 files changed, 151 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index cf46c8ed..2567d38c 100644 --- a/README.md +++ b/README.md @@ -268,7 +268,7 @@ kernel search contents srch_01jsearchresult --limit 3 --content-source browser - `--kiosk` - Launch browser in kiosk mode - `--region us-east|us-west|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 ` - 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. - - `--allowed-host ` - Set an egress allowlist at creation: the only destinations the browser may reach through Kernel-managed egress (repeatable or comma-separated, max 100). Other destinations are refused with a 403 whose `X-Kernel-Proxy-Error` header is `network_policy_denied`. Filters Kernel-managed egress only, not all browser VM traffic. Accepts exact hostnames (`example.com`), a leading wildcard that matches subdomains but not the domain itself (`*.example.com`), public IPs (`8.8.8.8`, `[2001:4860:4860::8888]`), and public CIDRs (`8.8.4.0/24`); no ports, paths, or schemes. `--start-url` must be allowed. Omit for unfiltered egress; an empty list is invalid. Requires proxy v3; cannot be combined with `--pool-id` or `--pool-name`, even with `--yes`. An existing list can be replaced or removed later with `browsers update --allowed-host` / `--clear-allowed-hosts`, but cannot be added later if omitted at creation or removed. + - `--allowed-host ` - Set an egress allowlist at creation: the only destinations the browser may reach through Kernel-managed egress (repeatable or comma-separated, max 100). Other destinations are refused with a 403 whose `X-Kernel-Proxy-Error` header is `network_policy_denied`. Filters Kernel-managed egress only, not all browser VM traffic. Accepts exact hostnames (`example.com`), a leading wildcard that matches subdomains but not the domain itself (`*.example.com`), public IPs (`8.8.8.8`, `[2001:4860:4860::8888]`), and public CIDRs (`8.8.4.0/24`); no ports, paths, or schemes. `--start-url` must be allowed. Omit for unfiltered egress; an empty list is invalid. Requires proxy v3; cannot be combined with `--pool-id` or `--pool-name`, even with `--yes` (set the allowlist on the pool with `kernel browser-pools create/update --allowed-host`). An existing list can be replaced or removed later with `browsers update --allowed-host` / `--clear-allowed-hosts`, but cannot be added later if omitted at creation or removed. - `--proxy-route '[,...]='` - 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:` or `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. Routes apply from the start of the session, including to `--start-url`. Routes are create-only and cannot be combined with `--pool-id`/`--pool-name`; configure them on the pool instead. - `--start-url ` - Initial page to open on launch - `--proxy-id ` / `--proxy-name ` - Use that proxy for the session regardless of stealth (mutually exclusive with each other and with `--proxy-mode`) @@ -304,7 +304,7 @@ kernel search contents srch_01jsearchresult --limit 3 --content-source browser - `--proxy-mode direct|default` - Change egress mode: `direct` for no proxy regardless of stealth, `default` to restore the browser default after using a selected proxy. Changing the proxy does not change stealth or CAPTCHA solver behavior. - `--clear-proxy` - Drop the selected proxy and restore the browser default (same as `--proxy-mode=default`) - `--disable-default-proxy` - Connect directly instead of through the default stealth proxy (same as `--proxy-mode=direct`); use `--disable-default-proxy=false` to restore the default - - `--allowed-host ` - Replace an existing egress allowlist only (repeatable or comma-separated, max 100), using the same entry rules as `browsers create --allowed-host`. PATCH cannot add an allowlist to a browser created without one or after removal. Omission leaves the existing list unchanged; an empty list is invalid. Applies without restarting the browser: new requests to destinations no longer allowed are normally refused within a few seconds and open connections to them are closed within about 30 seconds, but propagation can take up to 10 minutes during a deployment. Filters Kernel-managed egress only, not all browser VM traffic. `--start-url` in the same update must be allowed by the new list. Requires a browser created with proxy v3; not supported on pooled browsers. Mutually exclusive with `--clear-allowed-hosts` + - `--allowed-host ` - Replace an existing egress allowlist only (repeatable or comma-separated, max 100), using the same entry rules as `browsers create --allowed-host`. PATCH cannot add an allowlist to a browser created without one or after removal. Omission leaves the existing list unchanged; an empty list is invalid. Applies without restarting the browser: new requests to destinations no longer allowed are normally refused within a few seconds and open connections to them are closed within about 30 seconds, but propagation can take up to 10 minutes during a deployment. Filters Kernel-managed egress only, not all browser VM traffic. `--start-url` in the same update must be allowed by the new list. Requires a browser created with proxy v3. Supported on leased pooled browsers: the pool's allowlist is restored before reuse, or the browser is destroyed if it cannot be safely restored. Mutually exclusive with `--clear-allowed-hosts` - `--clear-allowed-hosts` - Remove the egress allowlist and return to unfiltered egress. Once removed, an allowlist cannot be added back to that browser - `--output json`, `-o json` - Output raw JSON object - `kernel browsers curl ` - Make HTTP requests through a browser session's Chrome network stack @@ -800,14 +800,14 @@ exists. - `--stealth`, `--headless`, `--kiosk` - Default pool configuration - `--memory 8GiB|16GiB` - Memory for headful browsers in the pool (default 8GiB) - `--refresh-on-profile-update` - Flush idle browsers when the pool's profile is updated (requires a profile) - - `--profile-id`, `--profile-name`, `--proxy-id`, `--region`, `--start-url`, `--extension`, `--viewport`, `--private-host`, `--proxy-route` - Same semantics as `kernel browsers create` (proxy routes apply to every browser warmed into the pool) + - `--profile-id`, `--profile-name`, `--proxy-id`, `--region`, `--start-url`, `--extension`, `--viewport`, `--private-host`, `--proxy-route`, `--allowed-host` - Same semantics as `kernel browsers create` (proxy routes and the egress allowlist apply to every browser warmed into the pool). Leased browsers can replace or remove the allowlist with `kernel browsers update`; per-lease changes are reset to the pool's allowlist on release, or the browser is replaced - `--chrome-policy ` / `--chrome-policy-file ` - Custom Chrome enterprise policy applied to every browser in the pool, as a JSON object or from a file (`-` for stdin). Same semantics as `kernel browsers create`. - `--telemetry=all` / `--telemetry=off` / `--telemetry=` - Telemetry applied to browsers warmed into the pool. Same semantics as `kernel browsers create`. - `--output json`, `-o json` - Output raw JSON object - `kernel browser-pools get ` - Get pool details - `--output json`, `-o json` - Output raw JSON object - `kernel browser-pools update ` - Update pool configuration - - Same flags as create (except `--region`, which is fixed at creation and cannot be updated) plus `--clear-profile`, `--clear-proxy`, `--clear-start-url`, `--clear-extensions`, `--clear-chrome-policy`, `--clear-private-hosts`, and `--clear-proxy-routes` for removing durable configuration. `--clear-private-hosts` removes the whole network configuration (restoring the default private IP ranges and dropping proxy routes). `--private-host` and `--proxy-route` replace the pool's whole network configuration, so pass both to keep both. `--fill-rate 0` pauses automatic filling. `--discard-all-idle` discards all idle browsers and refills the pool. `--telemetry`, `--memory`, and network updates only apply to browsers warmed after the update. + - Same flags as create (except `--region`, which is fixed at creation and cannot be updated) plus `--clear-profile`, `--clear-proxy`, `--clear-start-url`, `--clear-extensions`, `--clear-chrome-policy`, `--clear-private-hosts`, `--clear-proxy-routes`, and `--clear-allowed-hosts` for removing durable configuration. `--clear-private-hosts` removes the whole network configuration (restoring the default private IP ranges and dropping proxy routes and the allowlist). `--private-host`, `--proxy-route`, and `--allowed-host` replace the pool's whole network configuration, so pass all of them to keep them. Changing `--allowed-host` automatically replaces idle browsers; leased browsers keep their allowlist until release, when it is reset to the new list or the browser is replaced. `--fill-rate 0` pauses automatic filling. `--discard-all-idle` discards all idle browsers and refills the pool. `--telemetry`, `--memory`, and network updates only apply to browsers warmed after the update. - `--output json`, `-o json` - Output raw JSON object - `kernel browser-pools delete ` - Delete a pool - `--force` - Force delete even if browsers are leased diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index 98959922..8f701cc8 100644 --- a/cmd/browser_pools.go +++ b/cmd/browser_pools.go @@ -158,6 +158,7 @@ type BrowserPoolsCreateInput struct { Region string PrivateHosts []string ProxyRoutes []string + AllowedHosts []string StartURL string Extensions []string Viewport string @@ -248,7 +249,14 @@ func (c BrowserPoolsCmd) Create(ctx context.Context, in BrowserPoolsCreateInput) if len(routes) > 0 { network.ProxyRoutes = routes } - if len(network.PrivateHosts) > 0 || len(network.ProxyRoutes) > 0 { + allowedHosts, err := normalizeAllowedHosts(in.AllowedHosts) + if err != nil { + return err + } + if len(allowedHosts) > 0 { + network.AllowedHosts = allowedHosts + } + if len(network.PrivateHosts) > 0 || len(network.ProxyRoutes) > 0 || len(network.AllowedHosts) > 0 { params.Network = network } @@ -343,6 +351,7 @@ func (c BrowserPoolsCmd) Get(ctx context.Context, in BrowserPoolsGetInput) error {"Viewport", formatViewport(cfg.Viewport)}, {"Private Hosts", formatPrivateHosts(cfg.Network)}, {"Proxy Routes", formatProxyRoutes(cfg.Network)}, + {"Allowed Hosts", formatAllowedHosts(cfg.Network)}, {"Telemetry", formatPoolTelemetry(cfg.Telemetry)}, } @@ -374,6 +383,9 @@ type BrowserPoolsUpdateInput struct { ClearPrivateHosts bool ProxyRoutes []string ClearProxyRoutes bool + AllowedHosts []string + AllowedHostsProvided bool + ClearAllowedHosts bool Viewport string ChromePolicy string ChromePolicyFile string @@ -409,6 +421,15 @@ func validateBrowserPoolUpdateInput(in BrowserPoolsUpdateInput) error { if len(in.ProxyRoutes) > 0 && in.ClearProxyRoutes { return fmt.Errorf("cannot specify both --proxy-route and --clear-proxy-routes") } + if in.AllowedHostsProvided && in.ClearAllowedHosts { + return fmt.Errorf("cannot specify both --allowed-host and --clear-allowed-hosts") + } + if in.AllowedHostsProvided && in.ClearPrivateHosts { + return fmt.Errorf("cannot specify both --allowed-host and --clear-private-hosts") + } + if in.AllowedHostsProvided && len(normalizePrivateHosts(in.AllowedHosts)) == 0 { + return fmt.Errorf("at least one --allowed-host entry is required; use --clear-allowed-hosts to remove the pool's allowlist") + } return nil } @@ -515,7 +536,14 @@ func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) if len(routes) > 0 { network.ProxyRoutes = routes } - if len(network.PrivateHosts) > 0 || len(network.ProxyRoutes) > 0 { + allowedHosts, err := normalizeAllowedHosts(in.AllowedHosts) + if err != nil { + return err + } + if len(allowedHosts) > 0 { + network.AllowedHosts = allowedHosts + } + if len(network.PrivateHosts) > 0 || len(network.ProxyRoutes) > 0 || len(network.AllowedHosts) > 0 { params.Network = network } @@ -530,14 +558,23 @@ func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) } if in.ClearPrivateHosts { extraFields["network"] = map[string]any{} - } else if in.ClearProxyRoutes { - // The API replaces the whole network object, so keep any --private-host - // entries alongside the explicit empty route list. - clearRoutes := map[string]any{"proxy_routes": []any{}} + } else if in.ClearProxyRoutes || in.ClearAllowedHosts { + // The API replaces the whole network object, so keep any other network + // flags alongside the cleared fields. An omitted allowed_hosts removes the + // allowlist; proxy_routes needs an explicit empty list. + replacement := map[string]any{} + if in.ClearProxyRoutes { + replacement["proxy_routes"] = []any{} + } else if len(network.ProxyRoutes) > 0 { + replacement["proxy_routes"] = network.ProxyRoutes + } if len(network.PrivateHosts) > 0 { - clearRoutes["private_hosts"] = network.PrivateHosts + replacement["private_hosts"] = network.PrivateHosts + } + if len(network.AllowedHosts) > 0 { + replacement["allowed_hosts"] = network.AllowedHosts } - extraFields["network"] = clearRoutes + extraFields["network"] = replacement } if len(extraFields) > 0 { params.SetExtraFields(extraFields) @@ -841,6 +878,7 @@ func init() { browserPoolsCreateCmd.Flags().String("region", "", "Geographic region for the pool: 'us-east', 'us-west', 'eu-west', or 'ap-southeast'. Fixed once the pool is created; requires a Start-Up or Enterprise plan and defaults to us-east") browserPoolsCreateCmd.Flags().StringSlice("private-host", nil, "Destinations browsers in the pool reach directly through their own network instead of Kernel-managed egress, for private hosts on a VPN or tunnel they join (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") browserPoolsCreateCmd.Flags().StringArray("proxy-route", nil, "Route HOST[,HOST...]=PROXY through a proxy for browsers in the pool (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-id or default egress. Requires proxy v3") + browserPoolsCreateCmd.Flags().StringSlice("allowed-host", nil, "Set an egress allowlist for browsers in the pool: the only destinations they may reach through Kernel-managed egress (repeat or comma-separated, max 100); anything else is refused with a 403 (network_policy_denied). Accepts the same entries as 'browsers create --allowed-host'. Leased browsers can replace or remove it with 'browsers update'; per-lease changes are reset to the pool's allowlist on release (or the browser is replaced). Requires proxy v3") browserPoolsCreateCmd.Flags().String("start-url", "", "Initial page to open for new browsers") browserPoolsCreateCmd.Flags().StringSlice("extension", []string{}, "Extension IDs or names") browserPoolsCreateCmd.Flags().String("viewport", "", "Viewport size (e.g. 1280x800)") @@ -874,6 +912,8 @@ func init() { browserPoolsUpdateCmd.Flags().Bool("clear-private-hosts", false, "Remove the pool's network configuration (private-host override and proxy routes) and restore the default private ranges") browserPoolsUpdateCmd.Flags().StringArray("proxy-route", nil, "Replace the pool's proxy routes with HOST[,HOST...]=PROXY (repeatable, max 10 routes and 50 hosts per route). PROXY is an ID by default; use id:ID or name:NAME explicitly. Network flags replace the pool's whole network configuration, so pass --private-host too to keep a private-host override. Only applies to browsers created after the update. Requires proxy v3") browserPoolsUpdateCmd.Flags().Bool("clear-proxy-routes", false, "Remove the pool's proxy routes (combine with --private-host to keep a private-host override)") + browserPoolsUpdateCmd.Flags().StringSlice("allowed-host", nil, "Replace the pool's egress allowlist (repeat or comma-separated, max 100), using the same entry rules as 'browsers create --allowed-host'. An empty list is invalid. Changing the allowlist automatically replaces idle browsers; leased browsers keep theirs until release, when it is reset to the new list or the browser is replaced. Network flags replace the pool's whole network configuration, so pass --private-host and --proxy-route too to keep them. Requires proxy v3") + browserPoolsUpdateCmd.Flags().Bool("clear-allowed-hosts", false, "Remove the pool's egress allowlist (combine with --private-host or --proxy-route to keep those settings)") browserPoolsUpdateCmd.Flags().String("viewport", "", "Viewport size (e.g. 1280x800)") browserPoolsUpdateCmd.Flags().String("chrome-policy", "", "Custom Chrome enterprise policy as a JSON object") browserPoolsUpdateCmd.Flags().String("chrome-policy-file", "", "Read Chrome enterprise policy (JSON object) from a file (use '-' for stdin)") @@ -883,6 +923,8 @@ func init() { browserPoolsUpdateCmd.MarkFlagsMutuallyExclusive("proxy-route", "clear-proxy-routes") browserPoolsUpdateCmd.MarkFlagsMutuallyExclusive("proxy-route", "clear-private-hosts") browserPoolsUpdateCmd.MarkFlagsMutuallyExclusive("clear-proxy-routes", "clear-private-hosts") + browserPoolsUpdateCmd.MarkFlagsMutuallyExclusive("allowed-host", "clear-allowed-hosts") + browserPoolsUpdateCmd.MarkFlagsMutuallyExclusive("allowed-host", "clear-private-hosts") browserPoolsUpdateCmd.Flags().String("telemetry", "", "Update pool telemetry: --telemetry=all (reset to default set), --telemetry=off (disable), or --telemetry=console,network (merge those categories into the current selection). Applies only to browsers warmed after the update.") browserPoolsUpdateCmd.Flags().String("telemetry-cdp-exclude", "", "Leave the named CDP methods out of control telemetry's cdp_command events, comma-separated (e.g. Input.dispatchMouseEvent,Page.captureScreenshot); --telemetry-cdp-exclude=none clears the list. Excluded commands are still relayed to the browser, they just produce no event") browserPoolsUpdateCmd.Flags().Bool("discard-all-idle", false, "Discard all idle browsers") @@ -951,6 +993,7 @@ func runBrowserPoolsCreate(cmd *cobra.Command, args []string) error { region, _ := cmd.Flags().GetString("region") privateHosts, _ := cmd.Flags().GetStringSlice("private-host") proxyRoutes, _ := cmd.Flags().GetStringArray("proxy-route") + allowedHosts, _ := cmd.Flags().GetStringSlice("allowed-host") startURL, _ := cmd.Flags().GetString("start-url") extensions, _ := cmd.Flags().GetStringSlice("extension") viewport, _ := cmd.Flags().GetString("viewport") @@ -976,6 +1019,7 @@ func runBrowserPoolsCreate(cmd *cobra.Command, args []string) error { Region: region, PrivateHosts: privateHosts, ProxyRoutes: proxyRoutes, + AllowedHosts: allowedHosts, StartURL: startURL, Extensions: extensions, Viewport: viewport, @@ -1022,6 +1066,8 @@ func runBrowserPoolsUpdate(cmd *cobra.Command, args []string) error { clearPrivateHosts, _ := cmd.Flags().GetBool("clear-private-hosts") proxyRoutes, _ := cmd.Flags().GetStringArray("proxy-route") clearProxyRoutes, _ := cmd.Flags().GetBool("clear-proxy-routes") + allowedHosts, _ := cmd.Flags().GetStringSlice("allowed-host") + clearAllowedHosts, _ := cmd.Flags().GetBool("clear-allowed-hosts") viewport, _ := cmd.Flags().GetString("viewport") chromePolicy, _ := cmd.Flags().GetString("chrome-policy") chromePolicyFile, _ := cmd.Flags().GetString("chrome-policy-file") @@ -1055,6 +1101,9 @@ func runBrowserPoolsUpdate(cmd *cobra.Command, args []string) error { ClearPrivateHosts: clearPrivateHosts, ProxyRoutes: proxyRoutes, ClearProxyRoutes: clearProxyRoutes, + AllowedHosts: allowedHosts, + AllowedHostsProvided: cmd.Flags().Changed("allowed-host"), + ClearAllowedHosts: clearAllowedHosts, Viewport: viewport, ChromePolicy: chromePolicy, ChromePolicyFile: chromePolicyFile, diff --git a/cmd/browser_pools_test.go b/cmd/browser_pools_test.go index b2a827f7..68fb2b23 100644 --- a/cmd/browser_pools_test.go +++ b/cmd/browser_pools_test.go @@ -722,6 +722,26 @@ func TestBrowserPoolsUpdate_PrivateHostModes(t *testing.T) { input: BrowserPoolsUpdateInput{PrivateHosts: []string{"10.0.0.0/8"}, ClearProxyRoutes: true}, wantJSON: `"network":{"private_hosts":["10.0.0.0/8"],"proxy_routes":[]}`, }, + { + name: "replace allowed hosts", + input: BrowserPoolsUpdateInput{AllowedHosts: []string{" example.com ", "", "*.example.com"}, AllowedHostsProvided: true}, + wantJSON: `"network":{"allowed_hosts":["example.com","*.example.com"]}`, + }, + { + name: "clear allowed hosts", + input: BrowserPoolsUpdateInput{ClearAllowedHosts: true}, + wantJSON: `"network":{}`, + }, + { + name: "clear allowed hosts keeps private hosts", + input: BrowserPoolsUpdateInput{PrivateHosts: []string{"10.0.0.0/8"}, ClearAllowedHosts: true}, + wantJSON: `"network":{"private_hosts":["10.0.0.0/8"]}`, + }, + { + name: "clear proxy routes keeps allowed hosts", + input: BrowserPoolsUpdateInput{AllowedHosts: []string{"example.com"}, AllowedHostsProvided: true, ClearProxyRoutes: true}, + wantJSON: `"network":{"allowed_hosts":["example.com"],"proxy_routes":[]}`, + }, } for _, tt := range tests { @@ -839,3 +859,60 @@ func TestBrowserPoolsAcquire_WithTelemetryOverride(t *testing.T) { assert.NoError(t, err) assert.True(t, captured.Telemetry.Browser.Page.Enabled.Value) } + +func TestBrowserPoolsCreate_WithAllowedHosts(t *testing.T) { + setupStdoutCapture(t) + + var captured kernel.BrowserPoolNewParams + fake := &FakeBrowserPoolsService{ + NewFunc: func(ctx context.Context, body kernel.BrowserPoolNewParams, opts ...option.RequestOption) (*kernel.BrowserPool, error) { + captured = body + return &kernel.BrowserPool{ID: "pool-allow"}, nil + }, + } + c := BrowserPoolsCmd{client: fake} + + require.NoError(t, c.Create(context.Background(), BrowserPoolsCreateInput{ + Size: 1, + AllowedHosts: []string{" example.com ", "", "*.example.com"}, + })) + assert.Equal(t, []string{"example.com", "*.example.com"}, captured.Network.AllowedHosts) + raw, err := captured.MarshalJSON() + require.NoError(t, err) + assert.Contains(t, string(raw), `"network":{"allowed_hosts":["example.com","*.example.com"]}`) + + tooMany := make([]string, maxAllowedHosts+1) + for i := range tooMany { + tooMany[i] = fmt.Sprintf("h%d.example.com", i) + } + assert.Error(t, c.Create(context.Background(), BrowserPoolsCreateInput{Size: 1, AllowedHosts: tooMany})) +} + +func TestBrowserPoolsGet_ShowsAllowedHosts(t *testing.T) { + setupStdoutCapture(t) + + fake := &FakeBrowserPoolsService{ + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.BrowserPool, error) { + var pool kernel.BrowserPool + err := json.Unmarshal([]byte(`{"id":"pool-1","browser_pool_config":{"size":1,"network":{"allowed_hosts":["example.com","*.example.com"]}}}`), &pool) + return &pool, err + }, + } + require.NoError(t, (BrowserPoolsCmd{client: fake}).Get(context.Background(), BrowserPoolsGetInput{IDOrName: "pool-1"})) + + out := outBuf.String() + assert.Contains(t, out, "Allowed Hosts") + assert.Contains(t, out, "example.com, *.example.com") +} + +func TestBrowserPoolsUpdate_AllowedHostsValidation(t *testing.T) { + c := BrowserPoolsCmd{client: &FakeBrowserPoolsService{}} + for _, in := range []BrowserPoolsUpdateInput{ + {AllowedHosts: []string{"example.com"}, AllowedHostsProvided: true, ClearAllowedHosts: true}, + {AllowedHosts: []string{"example.com"}, AllowedHostsProvided: true, ClearPrivateHosts: true}, + {AllowedHosts: []string{" ", ""}, AllowedHostsProvided: true}, + } { + in.IDOrName = "pool-1" + assert.Error(t, c.Update(context.Background(), in)) + } +} diff --git a/cmd/browsers.go b/cmd/browsers.go index e47632ac..7757983f 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -790,7 +790,10 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { if err != nil { return err } - network.AllowedHosts = allowedHosts + // An empty allowed_hosts list is invalid, so leave it off unless set. + if len(allowedHosts) > 0 { + network.AllowedHosts = allowedHosts + } if len(network.PrivateHosts) > 0 || len(network.ProxyRoutes) > 0 || len(network.AllowedHosts) > 0 { params.Network = network } @@ -2943,9 +2946,10 @@ Notes: - Allowlist changes apply without restarting the browser. New requests to destinations no longer allowed are normally refused within a few seconds, and open connections to them are closed within about 30 seconds. Propagation can take up to 10 minutes during a deployment. - --start-url must be allowed by the new list. Requires a browser created with proxy v3; - not supported on pooled browsers. If the update fails, retry it: the new list may already - apply to some requests. + --start-url must be allowed by the new list. Requires a browser created with proxy v3. + Supported on leased pooled browsers: the pool's allowlist is restored before reuse, or the + browser is destroyed if it cannot be safely restored. If the update fails, retry it: the + new list may already apply to some requests. - Allowlists filter Kernel-managed egress only, not all browser VM traffic.`, Args: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { @@ -2995,7 +2999,7 @@ func init() { browsersUpdateCmd.Flags().Bool("clear-name", false, "Clear the browser session name") browsersUpdateCmd.Flags().StringArray("tag", nil, "Set a tag KEY=VALUE (repeatable; up to 50 pairs). Replaces the entire tag set; mutually exclusive with --clear-tags") browsersUpdateCmd.Flags().Bool("clear-tags", false, "Remove all tags from the browser session") - browsersUpdateCmd.Flags().StringSlice("allowed-host", nil, "Replace an existing egress allowlist (repeat or comma-separated, max 100), using the same entry rules as 'browsers create --allowed-host'. Cannot add one to a browser created without one or after removal. Omit to leave unchanged; an empty list is invalid. Applies without restarting the browser; --start-url must be allowed by the new list. Requires proxy v3; not supported on pooled browsers (mutually exclusive with --clear-allowed-hosts). See notes for propagation timing") + browsersUpdateCmd.Flags().StringSlice("allowed-host", nil, "Replace an existing egress allowlist (repeat or comma-separated, max 100), using the same entry rules as 'browsers create --allowed-host'. Cannot add one to a browser created without one or after removal. Omit to leave unchanged; an empty list is invalid. Applies without restarting the browser; --start-url must be allowed by the new list. Requires proxy v3; on leased pooled browsers the pool's allowlist is restored on release (mutually exclusive with --clear-allowed-hosts). See notes for propagation timing") browsersUpdateCmd.Flags().Bool("clear-allowed-hosts", false, "Remove the egress allowlist and return to unfiltered egress; an allowlist cannot be added back to this browser") browsersUpdateCmd.Flags().String("start-url", "", "Navigate the browser to this URL after applying the update. Overrides the restored tabs when a profile is loaded in the same update. Navigation is best-effort, so failures do not fail the update") @@ -3465,7 +3469,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { } if (poolID != "" || poolName != "") && cmd.Flags().Changed("allowed-host") { - return fmt.Errorf("--allowed-host cannot be used with --pool-id or --pool-name; browser pools do not support allowlists") + return fmt.Errorf("--allowed-host cannot be used with --pool-id or --pool-name; a leased browser uses the pool's allowlist (set it with 'browser-pools create/update --allowed-host')") } if poolID != "" && poolName != "" { diff --git a/go.mod b/go.mod index 30fe7453..c87986d7 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.122.1-0.20261009181557-f737b63ffd16 + github.com/kernel/kernel-go-sdk v0.122.1-0.20261009191411-3c8b90c8bea0 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 620be3c1..c90cbc70 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,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.122.1-0.20261009181557-f737b63ffd16 h1:/cyzSOIuZRlBmYJyXFBE0A62g+BZ+R+UNxA7VmZOJqE= -github.com/kernel/kernel-go-sdk v0.122.1-0.20261009181557-f737b63ffd16/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.122.1-0.20261009191411-3c8b90c8bea0 h1:1s7OwVkx8NZVahxz1lCbPrX6SPpHEkN1BJ9Crr8jHnY= +github.com/kernel/kernel-go-sdk v0.122.1-0.20261009191411-3c8b90c8bea0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= 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= From f44e578068f74a9f97a0cb3009b0fefe54371f72 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:16:24 +0000 Subject: [PATCH 2/6] CLI: Update SDK to 6d9c6187b9e9ddbe1a1546a48b1544dacbb62417 and filter telemetry stream by type server-side - Bump kernel-go-sdk to 6d9c6187b9e9ddbe1a1546a48b1544dacbb62417 - `kernel browsers telemetry stream --types` now sends BrowserTelemetryStreamParams.Type so the API filters events server-side (client-side filtering is kept as a fallback) Tested: browsers telemetry stream --replay all --types cdp_command,api_call (only matching events delivered; server sends id-only frames for skipped events, which the SDK decoder ignores); unfiltered stream for comparison; go test ./... Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- cmd/browsers.go | 2 +- cmd/browsers_telemetry.go | 5 +++++ cmd/browsers_telemetry_test.go | 20 ++++++++++++++++++++ go.mod | 2 +- go.sum | 4 ++-- 6 files changed, 30 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2567d38c..4301f561 100644 --- a/README.md +++ b/README.md @@ -886,7 +886,7 @@ Destinations are the OTLP/HTTP endpoints sessions export to. They belong to the - `kernel browsers telemetry stream ` - Stream live telemetry events (NDJSON with `-o json`) - `--categories ` - Filter by event category (`console`, `network`, `page`, `interaction`, `control`, `connection`, `system`, `screenshot`, `captcha`, `monitor`) - - `--types ` - Filter by event type (e.g. `network_response`, `console_error`) + - `--types ` - Deliver only these event types, filtered server-side (e.g. `captcha_solve_started`, `captcha_challenge_result`) - `--seq ` - Resume after sequence number N (Last-Event-ID); replays events with `seq > N`. Omit to stream from now. - `--replay all` - Replay buffered events on connect, starting from the oldest retained event (mutually exclusive with `--seq`) - `-o, --output json` - Output newline-delimited JSON envelopes diff --git a/cmd/browsers.go b/cmd/browsers.go index 7757983f..c27f4a90 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -3342,7 +3342,7 @@ followed automatically by Chromium.`, telemetryRoot := &cobra.Command{Use: "telemetry", Short: "Browser telemetry operations"} telemetryStream := &cobra.Command{Use: "stream ", Short: "Stream live telemetry events", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryStream} telemetryStream.Flags().StringSlice("categories", []string{}, "Filter by event category (console,network,page,interaction,control,platform,connection,system,screenshot,captcha,monitor)") - telemetryStream.Flags().StringSlice("types", []string{}, "Filter by event type (e.g. network_response,console_error)") + telemetryStream.Flags().StringSlice("types", []string{}, "Deliver only these event types, filtered server-side (e.g. captcha_solve_started,captcha_challenge_result)") telemetryStream.Flags().Int64("seq", -1, "Resume after sequence number N (Last-Event-ID); replays events with seq > N. Default -1 streams from now") telemetryStream.Flags().StringP("output", "o", "", "Output format: json for newline-delimited JSON envelopes") telemetryStream.Flags().String("replay", "", "Replay buffered events on connect: --replay=all starts from the oldest retained event") diff --git a/cmd/browsers_telemetry.go b/cmd/browsers_telemetry.go index 9893052a..16c60888 100644 --- a/cmd/browsers_telemetry.go +++ b/cmd/browsers_telemetry.go @@ -544,6 +544,11 @@ func (b BrowsersCmd) TelemetryStream(ctx context.Context, in BrowsersTelemetrySt if in.Replay != "" { params.Replay = kernel.Opt(in.Replay) } + // Filter types server-side so skipped events are never sent. The client-side + // shouldEmit check below still applies, and is the only filter for categories. + if len(in.Types) > 0 { + params.Type = in.Types + } stream := b.telemetry.StreamStreaming(ctx, br.SessionID, params) defer stream.Close() for stream.Next() { diff --git a/cmd/browsers_telemetry_test.go b/cmd/browsers_telemetry_test.go index df6e6c9e..f81f35bb 100644 --- a/cmd/browsers_telemetry_test.go +++ b/cmd/browsers_telemetry_test.go @@ -36,11 +36,13 @@ func captureStdout(t *testing.T, fn func()) string { type FakeBrowserTelemetryService struct { StreamFunc func() *ssestream.Stream[kernel.BrowserTelemetryStreamResponse] + LastStreamQuery kernel.BrowserTelemetryStreamParams EventsFunc func(ctx context.Context, id string, query kernel.BrowserTelemetryEventsParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse], error) EventsAutoPagingFunc func(id string, query kernel.BrowserTelemetryEventsParams, opts ...option.RequestOption) *pagination.OffsetPaginationAutoPager[kernel.BrowserTelemetryEventsResponse] } func (f *FakeBrowserTelemetryService) StreamStreaming(ctx context.Context, id string, query kernel.BrowserTelemetryStreamParams, opts ...option.RequestOption) *ssestream.Stream[kernel.BrowserTelemetryStreamResponse] { + f.LastStreamQuery = query if f.StreamFunc != nil { return f.StreamFunc() } @@ -250,6 +252,24 @@ func TestTelemetryStream_TypesFilterDropsNonMatching(t *testing.T) { assert.NotContains(t, outBuf.String(), "network_request") } +func TestTelemetryStream_TypesSentToServer(t *testing.T) { + setupStdoutCapture(t) + fakeBrowsers := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + return &kernel.BrowserGetResponse{SessionID: id}, nil + }} + fakeTelemetry := &FakeBrowserTelemetryService{} + b := BrowsersCmd{browsers: fakeBrowsers, telemetry: fakeTelemetry} + + err := b.TelemetryStream(context.Background(), BrowsersTelemetryStreamInput{ + Identifier: "session123", + Types: []string{"captcha_solve_started", "captcha_challenge_result"}, + Seq: -1, + }) + + assert.NoError(t, err) + assert.Equal(t, []string{"captcha_solve_started", "captcha_challenge_result"}, fakeTelemetry.LastStreamQuery.Type) +} + func TestTelemetryStream_SeqZeroErrors(t *testing.T) { b := BrowsersCmd{browsers: &FakeBrowsersService{}, telemetry: &FakeBrowserTelemetryService{}} diff --git a/go.mod b/go.mod index c87986d7..d56780de 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.122.1-0.20261009191411-3c8b90c8bea0 + github.com/kernel/kernel-go-sdk v0.122.1-0.20261009200608-6d9c6187b9e9 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 c90cbc70..f202df95 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,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.122.1-0.20261009191411-3c8b90c8bea0 h1:1s7OwVkx8NZVahxz1lCbPrX6SPpHEkN1BJ9Crr8jHnY= -github.com/kernel/kernel-go-sdk v0.122.1-0.20261009191411-3c8b90c8bea0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.122.1-0.20261009200608-6d9c6187b9e9 h1:sJM13XF7G0+fLdmrYypmUaCMS+0QgWt53N0ld7z834E= +github.com/kernel/kernel-go-sdk v0.122.1-0.20261009200608-6d9c6187b9e9/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= 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= From fab8b7a1271be8326bcf6f6d482de650ee79b08a Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:05:15 +0000 Subject: [PATCH 3/6] CLI: Update Go SDK to 64c58f83184c4ea19b63938eb496b86b2ff93a5f Bumps kernel-go-sdk to v0.123.0 (64c58f83184c). The SDK change since the previous version is release metadata only; full enumeration of api.md methods vs CLI commands found no new coverage gaps (config-registry endpoints are x-cli-skip). Tested: go build ./... (no new commands/flags to smoke test) Co-Authored-By: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index d56780de..6ad5c893 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.122.1-0.20261009200608-6d9c6187b9e9 + github.com/kernel/kernel-go-sdk v0.123.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 f202df95..e4e9f196 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,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.122.1-0.20261009200608-6d9c6187b9e9 h1:sJM13XF7G0+fLdmrYypmUaCMS+0QgWt53N0ld7z834E= -github.com/kernel/kernel-go-sdk v0.122.1-0.20261009200608-6d9c6187b9e9/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.123.0 h1:tSS3SAvFYNIilYEsGzTZdSZMqzvhz/JlEzzzpgzxvik= +github.com/kernel/kernel-go-sdk v0.123.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= 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= From 024cf30390b9054085ae00624f9f6cb15e0b7cca Mon Sep 17 00:00:00 2001 From: meliaj <17581886+meliaj@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:32:46 +0000 Subject: [PATCH 4/6] Reject empty --allowed-host and document pool allowlist removal An --allowed-host whose entries were all blank was dropped, so a browser or pool was created with unfiltered egress. Fail instead on create and update. Document that other pool network flags replace the whole network config and remove an existing allowlist, and stop sending an empty proxy_routes list on browser create. --- README.md | 2 +- cmd/browser_pools.go | 32 +++---- cmd/browser_pools_test.go | 64 +++++++++++--- cmd/browsers.go | 154 +++++++++++++++++---------------- cmd/browsers_telemetry_test.go | 4 + cmd/browsers_test.go | 60 +++++++++++++ 6 files changed, 213 insertions(+), 103 deletions(-) diff --git a/README.md b/README.md index 4301f561..12c544a9 100644 --- a/README.md +++ b/README.md @@ -807,7 +807,7 @@ exists. - `kernel browser-pools get ` - Get pool details - `--output json`, `-o json` - Output raw JSON object - `kernel browser-pools update ` - Update pool configuration - - Same flags as create (except `--region`, which is fixed at creation and cannot be updated) plus `--clear-profile`, `--clear-proxy`, `--clear-start-url`, `--clear-extensions`, `--clear-chrome-policy`, `--clear-private-hosts`, `--clear-proxy-routes`, and `--clear-allowed-hosts` for removing durable configuration. `--clear-private-hosts` removes the whole network configuration (restoring the default private IP ranges and dropping proxy routes and the allowlist). `--private-host`, `--proxy-route`, and `--allowed-host` replace the pool's whole network configuration, so pass all of them to keep them. Changing `--allowed-host` automatically replaces idle browsers; leased browsers keep their allowlist until release, when it is reset to the new list or the browser is replaced. `--fill-rate 0` pauses automatic filling. `--discard-all-idle` discards all idle browsers and refills the pool. `--telemetry`, `--memory`, and network updates only apply to browsers warmed after the update. + - Same flags as create (except `--region`, which is fixed at creation and cannot be updated) plus `--clear-profile`, `--clear-proxy`, `--clear-start-url`, `--clear-extensions`, `--clear-chrome-policy`, `--clear-private-hosts`, `--clear-proxy-routes`, and `--clear-allowed-hosts` for removing durable configuration. `--clear-private-hosts` removes the whole network configuration (restoring the default private IP ranges and dropping proxy routes and the allowlist). `--private-host`, `--proxy-route`, `--allowed-host`, `--clear-proxy-routes`, and `--clear-allowed-hosts` replace the pool's whole network configuration, so pass the other network flags to keep them; any network update without `--allowed-host` removes the pool's allowlist. Any change to the pool's allowlist, including removing it, automatically replaces idle browsers; leased browsers keep their allowlist until release, when it is reset to the new list or the browser is replaced. `--fill-rate 0` pauses automatic filling. `--discard-all-idle` discards all idle browsers and refills the pool. `--telemetry`, `--memory`, and other network updates only apply to browsers warmed after the update. - `--output json`, `-o json` - Output raw JSON object - `kernel browser-pools delete ` - Delete a pool - `--force` - Force delete even if browsers are leased diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index 8f701cc8..e8881004 100644 --- a/cmd/browser_pools.go +++ b/cmd/browser_pools.go @@ -159,6 +159,7 @@ type BrowserPoolsCreateInput struct { PrivateHosts []string ProxyRoutes []string AllowedHosts []string + AllowedHostsProvided bool StartURL string Extensions []string Viewport string @@ -249,7 +250,7 @@ func (c BrowserPoolsCmd) Create(ctx context.Context, in BrowserPoolsCreateInput) if len(routes) > 0 { network.ProxyRoutes = routes } - allowedHosts, err := normalizeAllowedHosts(in.AllowedHosts) + allowedHosts, err := normalizeAllowedHosts(in.AllowedHosts, in.AllowedHostsProvided, "omit --allowed-host for unfiltered egress") if err != nil { return err } @@ -421,15 +422,13 @@ func validateBrowserPoolUpdateInput(in BrowserPoolsUpdateInput) error { if len(in.ProxyRoutes) > 0 && in.ClearProxyRoutes { return fmt.Errorf("cannot specify both --proxy-route and --clear-proxy-routes") } - if in.AllowedHostsProvided && in.ClearAllowedHosts { + allowedHostsSet := in.AllowedHostsProvided || len(in.AllowedHosts) > 0 + if allowedHostsSet && in.ClearAllowedHosts { return fmt.Errorf("cannot specify both --allowed-host and --clear-allowed-hosts") } - if in.AllowedHostsProvided && in.ClearPrivateHosts { + if allowedHostsSet && in.ClearPrivateHosts { return fmt.Errorf("cannot specify both --allowed-host and --clear-private-hosts") } - if in.AllowedHostsProvided && len(normalizePrivateHosts(in.AllowedHosts)) == 0 { - return fmt.Errorf("at least one --allowed-host entry is required; use --clear-allowed-hosts to remove the pool's allowlist") - } return nil } @@ -443,6 +442,10 @@ func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) if err := validateBrowserPoolUpdateInput(in); err != nil { return err } + allowedHosts, err := normalizeAllowedHosts(in.AllowedHosts, in.AllowedHostsProvided, "use --clear-allowed-hosts to remove the pool's allowlist") + if err != nil { + return err + } params := kernel.BrowserPoolUpdateParams{} @@ -536,10 +539,6 @@ func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) if len(routes) > 0 { network.ProxyRoutes = routes } - allowedHosts, err := normalizeAllowedHosts(in.AllowedHosts) - if err != nil { - return err - } if len(allowedHosts) > 0 { network.AllowedHosts = allowedHosts } @@ -908,12 +907,12 @@ func init() { browserPoolsUpdateCmd.Flags().Bool("clear-start-url", false, "Clear the pool start URL") browserPoolsUpdateCmd.Flags().StringSlice("extension", []string{}, "Extension IDs or names") browserPoolsUpdateCmd.Flags().Bool("clear-extensions", false, "Remove all pool extensions") - browserPoolsUpdateCmd.Flags().StringSlice("private-host", nil, "Replace the destinations browsers in the pool reach directly through their own network instead of Kernel-managed egress (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'). Only applies to browsers created after the update") - browserPoolsUpdateCmd.Flags().Bool("clear-private-hosts", false, "Remove the pool's network configuration (private-host override and proxy routes) and restore the default private ranges") - browserPoolsUpdateCmd.Flags().StringArray("proxy-route", nil, "Replace the pool's proxy routes with HOST[,HOST...]=PROXY (repeatable, max 10 routes and 50 hosts per route). PROXY is an ID by default; use id:ID or name:NAME explicitly. Network flags replace the pool's whole network configuration, so pass --private-host too to keep a private-host override. Only applies to browsers created after the update. Requires proxy v3") - browserPoolsUpdateCmd.Flags().Bool("clear-proxy-routes", false, "Remove the pool's proxy routes (combine with --private-host to keep a private-host override)") - browserPoolsUpdateCmd.Flags().StringSlice("allowed-host", nil, "Replace the pool's egress allowlist (repeat or comma-separated, max 100), using the same entry rules as 'browsers create --allowed-host'. An empty list is invalid. Changing the allowlist automatically replaces idle browsers; leased browsers keep theirs until release, when it is reset to the new list or the browser is replaced. Network flags replace the pool's whole network configuration, so pass --private-host and --proxy-route too to keep them. Requires proxy v3") - browserPoolsUpdateCmd.Flags().Bool("clear-allowed-hosts", false, "Remove the pool's egress allowlist (combine with --private-host or --proxy-route to keep those settings)") + browserPoolsUpdateCmd.Flags().StringSlice("private-host", nil, "Replace the destinations browsers in the pool reach directly through their own network instead of Kernel-managed egress (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'). Network flags replace the pool's whole network configuration, so pass --proxy-route and --allowed-host too to keep them; an omitted --allowed-host removes the pool's allowlist. Only applies to browsers created after the update") + browserPoolsUpdateCmd.Flags().Bool("clear-private-hosts", false, "Remove the pool's network configuration (private-host override, proxy routes, and egress allowlist) and restore the default private ranges") + browserPoolsUpdateCmd.Flags().StringArray("proxy-route", nil, "Replace the pool's proxy routes with HOST[,HOST...]=PROXY (repeatable, max 10 routes and 50 hosts per route). PROXY is an ID by default; use id:ID or name:NAME explicitly. Network flags replace the pool's whole network configuration, so pass --private-host and --allowed-host too to keep them; an omitted --allowed-host removes the pool's allowlist. Only applies to browsers created after the update. Requires proxy v3") + browserPoolsUpdateCmd.Flags().Bool("clear-proxy-routes", false, "Remove the pool's proxy routes (combine with --private-host or --allowed-host to keep those settings; an omitted --allowed-host removes the pool's allowlist)") + browserPoolsUpdateCmd.Flags().StringSlice("allowed-host", nil, "Set or replace the pool's egress allowlist (repeat or comma-separated, max 100), using the same entry rules as 'browsers create --allowed-host'. An empty list is invalid. Changing the allowlist automatically replaces idle browsers; leased browsers keep theirs until release, when it is reset to the new list or the browser is replaced. Network flags replace the pool's whole network configuration, so pass --private-host and --proxy-route too to keep them. Requires proxy v3") + browserPoolsUpdateCmd.Flags().Bool("clear-allowed-hosts", false, "Remove the pool's egress allowlist and replace idle browsers (combine with --private-host or --proxy-route to keep those settings)") browserPoolsUpdateCmd.Flags().String("viewport", "", "Viewport size (e.g. 1280x800)") browserPoolsUpdateCmd.Flags().String("chrome-policy", "", "Custom Chrome enterprise policy as a JSON object") browserPoolsUpdateCmd.Flags().String("chrome-policy-file", "", "Read Chrome enterprise policy (JSON object) from a file (use '-' for stdin)") @@ -1020,6 +1019,7 @@ func runBrowserPoolsCreate(cmd *cobra.Command, args []string) error { PrivateHosts: privateHosts, ProxyRoutes: proxyRoutes, AllowedHosts: allowedHosts, + AllowedHostsProvided: cmd.Flags().Changed("allowed-host"), StartURL: startURL, Extensions: extensions, Viewport: viewport, diff --git a/cmd/browser_pools_test.go b/cmd/browser_pools_test.go index 68fb2b23..2b48c059 100644 --- a/cmd/browser_pools_test.go +++ b/cmd/browser_pools_test.go @@ -562,6 +562,10 @@ func TestBrowserPoolsUpdate_DurableClearAndZeroStates(t *testing.T) { } func TestBrowserPoolsUpdate_RejectsInvalidDurableInputs(t *testing.T) { + tooManyHosts := make([]string, maxAllowedHosts+1) + for i := range tooManyHosts { + tooManyHosts[i] = fmt.Sprintf("h%d.example.com", i) + } tests := []struct { name string input BrowserPoolsUpdateInput @@ -607,6 +611,31 @@ func TestBrowserPoolsUpdate_RejectsInvalidDurableInputs(t *testing.T) { input: BrowserPoolsUpdateInput{ProxyRoutes: []string{"a.example=proxy-1"}, ClearProxyRoutes: true}, wantErr: "cannot specify both --proxy-route and --clear-proxy-routes", }, + { + name: "conflicting allowed host modes", + input: BrowserPoolsUpdateInput{AllowedHosts: []string{"example.com"}, AllowedHostsProvided: true, ClearAllowedHosts: true}, + wantErr: "cannot specify both --allowed-host and --clear-allowed-hosts", + }, + { + name: "allowed hosts without the provided flag and clear", + input: BrowserPoolsUpdateInput{AllowedHosts: []string{"example.com"}, ClearAllowedHosts: true}, + wantErr: "cannot specify both --allowed-host and --clear-allowed-hosts", + }, + { + name: "allowed hosts with clear private hosts", + input: BrowserPoolsUpdateInput{AllowedHosts: []string{"example.com"}, AllowedHostsProvided: true, ClearPrivateHosts: true}, + wantErr: "cannot specify both --allowed-host and --clear-private-hosts", + }, + { + name: "only empty allowed hosts", + input: BrowserPoolsUpdateInput{AllowedHosts: []string{" ", ""}, AllowedHostsProvided: true}, + wantErr: "at least one --allowed-host entry is required; use --clear-allowed-hosts to remove the pool's allowlist", + }, + { + name: "too many allowed hosts", + input: BrowserPoolsUpdateInput{AllowedHosts: tooManyHosts, AllowedHostsProvided: true}, + wantErr: "too many --allowed-host entries: 101 (maximum 100)", + }, } for _, tt := range tests { @@ -737,6 +766,11 @@ func TestBrowserPoolsUpdate_PrivateHostModes(t *testing.T) { input: BrowserPoolsUpdateInput{PrivateHosts: []string{"10.0.0.0/8"}, ClearAllowedHosts: true}, wantJSON: `"network":{"private_hosts":["10.0.0.0/8"]}`, }, + { + name: "clear allowed hosts keeps proxy routes", + input: BrowserPoolsUpdateInput{ProxyRoutes: []string{"api.ipify.org=name:my-dc-proxy"}, ClearAllowedHosts: true}, + wantJSON: `"network":{"proxy_routes":[{"hosts":["api.ipify.org"],"proxy":{"name":"my-dc-proxy"}}]}`, + }, { name: "clear proxy routes keeps allowed hosts", input: BrowserPoolsUpdateInput{AllowedHosts: []string{"example.com"}, AllowedHostsProvided: true, ClearProxyRoutes: true}, @@ -888,6 +922,24 @@ func TestBrowserPoolsCreate_WithAllowedHosts(t *testing.T) { assert.Error(t, c.Create(context.Background(), BrowserPoolsCreateInput{Size: 1, AllowedHosts: tooMany})) } +func TestBrowserPoolsCreate_EmptyAllowedHostsRejected(t *testing.T) { + for name, in := range map[string]BrowserPoolsCreateInput{ + "no entries": {Size: 1, AllowedHosts: []string{}, AllowedHostsProvided: true}, + "blank entries with private host": {Size: 1, AllowedHosts: []string{" ", ""}, AllowedHostsProvided: true, PrivateHosts: []string{"10.0.0.0/8"}}, + "blank entries without the provided flag": {Size: 1, AllowedHosts: []string{" "}}, + } { + t.Run(name, func(t *testing.T) { + fake := &FakeBrowserPoolsService{ + NewFunc: func(ctx context.Context, body kernel.BrowserPoolNewParams, opts ...option.RequestOption) (*kernel.BrowserPool, error) { + t.Fatal("New should not be called for an empty allowlist") + return nil, nil + }, + } + assert.ErrorContains(t, (BrowserPoolsCmd{client: fake}).Create(context.Background(), in), "at least one --allowed-host entry is required") + }) + } +} + func TestBrowserPoolsGet_ShowsAllowedHosts(t *testing.T) { setupStdoutCapture(t) @@ -904,15 +956,3 @@ func TestBrowserPoolsGet_ShowsAllowedHosts(t *testing.T) { assert.Contains(t, out, "Allowed Hosts") assert.Contains(t, out, "example.com, *.example.com") } - -func TestBrowserPoolsUpdate_AllowedHostsValidation(t *testing.T) { - c := BrowserPoolsCmd{client: &FakeBrowserPoolsService{}} - for _, in := range []BrowserPoolsUpdateInput{ - {AllowedHosts: []string{"example.com"}, AllowedHostsProvided: true, ClearAllowedHosts: true}, - {AllowedHosts: []string{"example.com"}, AllowedHostsProvided: true, ClearPrivateHosts: true}, - {AllowedHosts: []string{" ", ""}, AllowedHostsProvided: true}, - } { - in.IDOrName = "pool-1" - assert.Error(t, c.Update(context.Background(), in)) - } -} diff --git a/cmd/browsers.go b/cmd/browsers.go index c27f4a90..47f2e98d 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -256,9 +256,14 @@ func buildNetworkParam(privateHosts []string) (kernel.BrowserNetworkConfigParam, const maxAllowedHosts = 100 // normalizeAllowedHosts trims --allowed-host values, drops empty ones, and -// enforces the API's entry cap. Entry syntax is validated by the API. -func normalizeAllowedHosts(hosts []string) ([]string, error) { +// enforces the API's entry cap. Entry syntax is validated by the API. A flag +// passed with no entries is an error ending in emptyHint, so it never falls +// back to unfiltered egress. +func normalizeAllowedHosts(hosts []string, provided bool, emptyHint string) ([]string, error) { out := normalizePrivateHosts(hosts) + if (provided || len(hosts) > 0) && len(out) == 0 { + return nil, fmt.Errorf("at least one --allowed-host entry is required; %s", emptyHint) + } if len(out) > maxAllowedHosts { return nil, fmt.Errorf("too many --allowed-host entries: %d (maximum %d)", len(out), maxAllowedHosts) } @@ -329,8 +334,8 @@ func formatProxyRoutes(network kernel.BrowserNetworkConfig) string { return strings.Join(routes, "; ") } -// formatAllowedHosts renders a session's egress allowlist for table output. A -// missing allowed_hosts list means egress is unfiltered. +// formatAllowedHosts renders a session or pool egress allowlist for table +// output. A missing allowed_hosts list means egress is unfiltered. func formatAllowedHosts(network kernel.BrowserNetworkConfig) string { if len(network.AllowedHosts) == 0 { return "-" @@ -483,37 +488,38 @@ func formatTags(tags kernel.Tags) string { // Inputs for each command type BrowsersCreateInput struct { - TimeoutSeconds int - Stealth BoolFlag - Headless BoolFlag - GPU BoolFlag - Memory string - VideoMemory string - InvocationID string - Kiosk BoolFlag - ProfileID string - ProfileName string - ProfileSaveChanges BoolFlag - ProxyID string - ProxyName string - ProxyMode string - Region string - PrivateHosts []string - AllowedHosts []string - ProxyRoutes []string - StartURL string - Extensions []string - Vaults []string - Viewport string - Telemetry string - TelemetryCdpExclude string - TelemetryExport string - TelemetryStorage string - ChromePolicy string - ChromePolicyFile string - Name string - Tags map[string]string - Output string + TimeoutSeconds int + Stealth BoolFlag + Headless BoolFlag + GPU BoolFlag + Memory string + VideoMemory string + InvocationID string + Kiosk BoolFlag + ProfileID string + ProfileName string + ProfileSaveChanges BoolFlag + ProxyID string + ProxyName string + ProxyMode string + Region string + PrivateHosts []string + AllowedHosts []string + AllowedHostsProvided bool + ProxyRoutes []string + StartURL string + Extensions []string + Vaults []string + Viewport string + Telemetry string + TelemetryCdpExclude string + TelemetryExport string + TelemetryStorage string + ChromePolicy string + ChromePolicyFile string + Name string + Tags map[string]string + Output string } type BrowsersDeleteInput struct { @@ -785,12 +791,14 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { if err != nil { return err } - network.ProxyRoutes = routes - allowedHosts, err := normalizeAllowedHosts(in.AllowedHosts) + // The SDK sends a non-nil empty slice as [], so leave empty lists off. + if len(routes) > 0 { + network.ProxyRoutes = routes + } + allowedHosts, err := normalizeAllowedHosts(in.AllowedHosts, in.AllowedHostsProvided, "omit --allowed-host for unfiltered egress") if err != nil { return err } - // An empty allowed_hosts list is invalid, so leave it off unless set. if len(allowedHosts) > 0 { network.AllowedHosts = allowedHosts } @@ -1126,16 +1134,13 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { // The API replaces the allowlist with the given entries, or removes it when // sent null. An empty list is rejected, so require at least one entry. - if in.AllowedHostsProvided && in.ClearAllowedHosts { + if (in.AllowedHostsProvided || len(in.AllowedHosts) > 0) && in.ClearAllowedHosts { return fmt.Errorf("cannot specify both --allowed-host and --clear-allowed-hosts") } - allowedHosts, err := normalizeAllowedHosts(in.AllowedHosts) + allowedHosts, err := normalizeAllowedHosts(in.AllowedHosts, in.AllowedHostsProvided, "use --clear-allowed-hosts to remove the allowlist") if err != nil { return err } - if in.AllowedHostsProvided && len(allowedHosts) == 0 { - return fmt.Errorf("at least one --allowed-host entry is required; use --clear-allowed-hosts to remove the allowlist") - } hasAllowedHostsChange := len(allowedHosts) > 0 || in.ClearAllowedHosts // Validate --save-changes is only used with a profile @@ -3576,37 +3581,38 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { } in := BrowsersCreateInput{ - TimeoutSeconds: timeout, - Stealth: BoolFlag{Set: cmd.Flags().Changed("stealth"), Value: stealthVal}, - Headless: BoolFlag{Set: cmd.Flags().Changed("headless"), Value: headlessVal}, - GPU: BoolFlag{Set: cmd.Flags().Changed("gpu"), Value: gpuVal}, - Memory: memory, - VideoMemory: videoMemory, - InvocationID: invocationID, - Kiosk: BoolFlag{Set: cmd.Flags().Changed("kiosk"), Value: kioskVal}, - ProfileID: profileID, - ProfileName: profileName, - ProfileSaveChanges: BoolFlag{Set: cmd.Flags().Changed("save-changes"), Value: saveChanges}, - ProxyID: proxyID, - ProxyName: proxyName, - ProxyMode: proxyMode, - Region: region, - PrivateHosts: privateHosts, - AllowedHosts: allowedHosts, - ProxyRoutes: proxyRoutes, - StartURL: startURL, - Extensions: extensions, - Vaults: vaults, - Viewport: viewport, - Telemetry: telemetry, - TelemetryCdpExclude: telemetryCdpExclude, - TelemetryExport: telemetryExport, - TelemetryStorage: telemetryStorage, - ChromePolicy: chromePolicy, - ChromePolicyFile: chromePolicyFile, - Name: name, - Tags: tags, - Output: output, + TimeoutSeconds: timeout, + Stealth: BoolFlag{Set: cmd.Flags().Changed("stealth"), Value: stealthVal}, + Headless: BoolFlag{Set: cmd.Flags().Changed("headless"), Value: headlessVal}, + GPU: BoolFlag{Set: cmd.Flags().Changed("gpu"), Value: gpuVal}, + Memory: memory, + VideoMemory: videoMemory, + InvocationID: invocationID, + Kiosk: BoolFlag{Set: cmd.Flags().Changed("kiosk"), Value: kioskVal}, + ProfileID: profileID, + ProfileName: profileName, + ProfileSaveChanges: BoolFlag{Set: cmd.Flags().Changed("save-changes"), Value: saveChanges}, + ProxyID: proxyID, + ProxyName: proxyName, + ProxyMode: proxyMode, + Region: region, + PrivateHosts: privateHosts, + AllowedHosts: allowedHosts, + AllowedHostsProvided: cmd.Flags().Changed("allowed-host"), + ProxyRoutes: proxyRoutes, + StartURL: startURL, + Extensions: extensions, + Vaults: vaults, + Viewport: viewport, + Telemetry: telemetry, + TelemetryCdpExclude: telemetryCdpExclude, + TelemetryExport: telemetryExport, + TelemetryStorage: telemetryStorage, + ChromePolicy: chromePolicy, + ChromePolicyFile: chromePolicyFile, + Name: name, + Tags: tags, + Output: output, } svc := client.Browsers diff --git a/cmd/browsers_telemetry_test.go b/cmd/browsers_telemetry_test.go index f81f35bb..0a639730 100644 --- a/cmd/browsers_telemetry_test.go +++ b/cmd/browsers_telemetry_test.go @@ -268,6 +268,10 @@ func TestTelemetryStream_TypesSentToServer(t *testing.T) { assert.NoError(t, err) assert.Equal(t, []string{"captcha_solve_started", "captcha_challenge_result"}, fakeTelemetry.LastStreamQuery.Type) + // The stream endpoint accepts comma-joined types, which is how the SDK sends them. + query, err := fakeTelemetry.LastStreamQuery.URLQuery() + assert.NoError(t, err) + assert.Equal(t, "captcha_solve_started,captcha_challenge_result", query.Get("type")) } func TestTelemetryStream_SeqZeroErrors(t *testing.T) { diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index cfab87d7..a3def713 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -592,6 +592,8 @@ func TestBrowsersCreate_WithPrivateHosts(t *testing.T) { raw, err := captured.MarshalJSON() require.NoError(t, err) assert.Contains(t, string(raw), `"private_hosts":["*.example.ts.net","100.64.0.0/10"]`) + assert.NotContains(t, string(raw), "allowed_hosts") + assert.NotContains(t, string(raw), "proxy_routes") // Blank entries from a trailing comma are dropped rather than sent through. require.NoError(t, (BrowsersCmd{browsers: fake}).Create(context.Background(), BrowsersCreateInput{ @@ -647,6 +649,59 @@ func TestBrowsersCreate_WithAllowedHosts(t *testing.T) { })) } +func TestBrowsersCreate_EmptyAllowedHostsRejected(t *testing.T) { + for name, in := range map[string]BrowsersCreateInput{ + "no entries": {AllowedHosts: []string{}, AllowedHostsProvided: true}, + "blank entries with private host": {AllowedHosts: []string{" ", ""}, AllowedHostsProvided: true, PrivateHosts: []string{"10.0.0.0/8"}}, + "blank entries without the provided flag": {AllowedHosts: []string{" "}}, + } { + t.Run(name, func(t *testing.T) { + fake := &FakeBrowsersService{ + NewFunc: func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error) { + t.Fatal("New should not be called for an empty allowlist") + return nil, nil + }, + } + assert.ErrorContains(t, (BrowsersCmd{browsers: fake}).Create(context.Background(), in), "at least one --allowed-host entry is required") + }) + } +} + +// An empty allowlist is only rejected when cobra reports that --allowed-host +// was passed, so exercise the flag wiring through each command's run function. +func TestAllowedHostCommands_EmptyFlagRejected(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + tests := []struct { + name string + run func(*cobra.Command, []string) error + args []string + wantErr string + }{ + {"browsers create", runBrowsersCreate, nil, "at least one --allowed-host entry is required; omit --allowed-host for unfiltered egress"}, + {"browser-pools create", runBrowserPoolsCreate, nil, "at least one --allowed-host entry is required; omit --allowed-host for unfiltered egress"}, + {"browsers update", runBrowsersUpdate, []string{"sess-1"}, "at least one --allowed-host entry is required; use --clear-allowed-hosts to remove the allowlist"}, + {"browser-pools update", runBrowserPoolsUpdate, []string{"pool-1"}, "at least one --allowed-host entry is required; use --clear-allowed-hosts to remove the pool's allowlist"}, + } + for _, tt := range tests { + for _, value := range []string{"", " , "} { + t.Run(fmt.Sprintf("%s/%q", tt.name, value), func(t *testing.T) { + setupStdoutCapture(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + })) + t.Cleanup(server.Close) + client := kernel.NewClient(option.WithBaseURL(server.URL), option.WithAPIKey("test")) + cmd := &cobra.Command{Use: "test"} + cmd.SetContext(context.WithValue(context.Background(), util.KernelClientKey, client)) + cmd.Flags().StringSlice("allowed-host", nil, "") + require.NoError(t, cmd.ParseFlags([]string{"--allowed-host=" + value})) + + require.EqualError(t, tt.run(cmd, tt.args), tt.wantErr) + }) + } + } +} + func TestBrowsersCreate_AllowedHostsRejectsPools(t *testing.T) { t.Setenv("KERNEL_PROJECT", "") for _, selector := range []string{"pool-id", "pool-name"} { @@ -3225,6 +3280,11 @@ func TestBrowsersUpdate_AllowedHostsValidation(t *testing.T) { in: BrowsersUpdateInput{Identifier: "s", AllowedHosts: []string{"example.com"}, AllowedHostsProvided: true, ClearAllowedHosts: true}, wantErr: "cannot specify both --allowed-host and --clear-allowed-hosts", }, + { + name: "entries without the provided flag and clear", + in: BrowsersUpdateInput{Identifier: "s", AllowedHosts: []string{"example.com"}, ClearAllowedHosts: true}, + wantErr: "cannot specify both --allowed-host and --clear-allowed-hosts", + }, { name: "only empty entries", in: BrowsersUpdateInput{Identifier: "s", AllowedHosts: []string{" ", ""}, AllowedHostsProvided: true}, From 474c82da60a720d5cab8fe4491a047cdbb332a18 Mon Sep 17 00:00:00 2001 From: ulziibay-kernel <253135130+ulziibay-kernel@users.noreply.github.com> Date: Sat, 10 Oct 2026 06:10:17 +0000 Subject: [PATCH 5/6] Re-trigger CI after security-workflows semgrep fix From 61d31ea3ad1991a23df260a4cba87eb90f5b4a68 Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:11:29 +0000 Subject: [PATCH 6/6] CLI: Update SDK to 9ac854e2d73931cce7e4879b8505b1e11cf1e27c and add confirm_transaction - Bump kernel-go-sdk to 9ac854e2d73931cce7e4879b8505b1e11cf1e27c - vaults items invoke confirm_transaction --params/--spec-file for ConfirmTransactionVaultItemOperationRequestParam (status, transaction_type, amount, currency, occurred_at), validated client-side - Document merchant_category / merchant_category_code on KernelCardSpec and include them in vault item output Tested: go test ./...; against the live API, kernel cards create with merchant_category fields reached server validation (rejected because the Kernel wallet was not enrolled, which needs a cardholder passkey); confirm_transaction param validation and the advertised-operation check. Test vault cleaned up. Co-Authored-By: Claude Opus 5.5 --- cmd/vaults.go | 2 ++ cmd/vaults_commands.go | 12 ++++++-- cmd/vaults_help.go | 2 ++ cmd/vaults_invoke_test.go | 36 ++++++++++++++++++++++++ cmd/vaults_operation_params.go | 51 ++++++++++++++++++++++++++++++++-- cmd/vaults_output.go | 1 + go.mod | 2 +- go.sum | 4 +-- 8 files changed, 103 insertions(+), 7 deletions(-) diff --git a/cmd/vaults.go b/cmd/vaults.go index 93f589b1..e0ce42e3 100644 --- a/cmd/vaults.go +++ b/cmd/vaults.go @@ -309,6 +309,8 @@ func (c VaultsCmd) Invoke(ctx context.Context, vault, key, operation string, par return fmt.Errorf("prepare_checkout requires an AgentCard card") } request.OfPrepareCheckout = &kernel.PrepareCheckoutVaultItemOperationRequestParam{Type: "prepare_checkout", Checkout: *params.Checkout} + } else if operation == "confirm_transaction" { + request.OfConfirmTransaction = params.ConfirmTransaction } else if operation == "collect" { request.OfCollect = &kernel.CollectVaultItemOperationRequestParam{Type: "collect"} } else { diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 3d7922d7..af80a73f 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -182,6 +182,13 @@ Each vault has its own key; it is created on first request and may be cached.`, Long: `Retrieve the item and invoke only an operation listed in available_operations. collect returns a time-scoped URL for the full credential form without clearing values. authorize sends {"type":"authorize"} for payment authorization. +confirm_transaction (Kernel Visa cards when advertised) reports a real merchant outcome with +--params or --spec-file: status (APPROVED, DECLINED, PENDING, ERROR, CANCELLED), transaction_type +(PURCHASE, AUTHORIZATION, CAPTURE, REFUND, REVERSAL, VERIFICATION, CHARGEBACK, FRAUD), amount +(actual minor units, no greater than the approved limit), currency (approved purchase currency), +and occurred_at (ISO 8601 time the outcome was observed). Never infer success from filling +checkout or receiving a credential. Unknown outcomes require manual reconciliation, not retry. +Each purchase accepts one report. Read the operation description and follow any approval requirements before invoking. fill requires --params JSON or --spec-file with browser_id (session ID, not name) and 1-32 ordered fields (field, selector). Do not include type, values, or frame IDs. @@ -259,6 +266,7 @@ JSON kernel vaults items invoke user-vault github 1pw_access_request_status --params '{"timeout_seconds":60}' kernel vaults items invoke user-vault github 1pw_fill --params '{"browser_id":"","page_url":"https://github.com/login"}' kernel vaults items invoke user-vault github 1pw_fill --params '{"browser_id":"","page_url":"https://github.com/login","entry_id":""}' + kernel vaults items invoke checkout visa-order confirm_transaction --params '{"status":"APPROVED","transaction_type":"PURCHASE","amount":1234,"currency":"USD","occurred_at":"2026-10-10T12:00:00Z"}' kernel vaults items invoke checkout order-1 fill --params '{"browser_id":"browser-session-id","page_url":"https://shop.example/checkout","fields":[{"field":"number","selector":"#card-number"}]}' -o json`, RunE: func(cmd *cobra.Command, args []string) error { open, _ := cmd.Flags().GetBool("open") @@ -269,7 +277,7 @@ JSON } if cmd.Flags().Changed("spec-file") { if !vaultOperationTakesParams(args[2]) { - return fmt.Errorf("--spec-file is only supported for fill, webmcp_invoke, prepare_checkout, and 1Password operations with parameters") + return fmt.Errorf("--spec-file is only supported for fill, webmcp_invoke, prepare_checkout, confirm_transaction, and 1Password operations with parameters") } data, err := readVaultJSONFile(cmd, "spec-file") if err != nil { @@ -283,7 +291,7 @@ JSON } return getVaultsHandler(cmd).Invoke(cmd.Context(), args[0], args[1], args[2], params, vaultOutput(cmd), open) }} - invoke.Flags().String("params", "", "Operation parameters JSON for fill, webmcp_invoke, prepare_checkout, or 1pw_* (maximum 128 KiB); omit type and credential values; 1pw_update_access_token requires --spec-file") + invoke.Flags().String("params", "", "Operation parameters JSON for fill, webmcp_invoke, prepare_checkout, confirm_transaction, or 1pw_* (maximum 128 KiB); omit type and credential values; 1pw_update_access_token requires --spec-file") invoke.Flags().String("spec-file", "", "Operation parameters JSON file (use '-' for stdin; maximum 128 KiB)") invoke.MarkFlagsMutuallyExclusive("params", "spec-file") invoke.Flags().Bool("open", false, "Open a returned HTTPS action URL in your browser") diff --git a/cmd/vaults_help.go b/cmd/vaults_help.go index 61231032..7eca1ba4 100644 --- a/cmd/vaults_help.go +++ b/cmd/vaults_help.go @@ -96,6 +96,8 @@ type KernelCardSpec = { merchant_name: string; // 1..255 characters merchant_url: string; // HTTPS merchant checkout URL; fill is locked to its origin merchant_country?: string; // ISO 3166-1 alpha-2; required for Visa cards + merchant_category?: string; // actual merchant category when known; omit rather than invent + merchant_category_code?: string; // actual MCC when known; omit rather than invent; 0000 is rejected }; type LinkLineItem = { diff --git a/cmd/vaults_invoke_test.go b/cmd/vaults_invoke_test.go index 82adf78a..d5b43176 100644 --- a/cmd/vaults_invoke_test.go +++ b/cmd/vaults_invoke_test.go @@ -184,3 +184,39 @@ func TestVaultGetOperationHints(t *testing.T) { } } } + +func TestVaultInvokeConfirmTransactionSendsObservedOutcome(t *testing.T) { + t.Setenv("KERNEL_PROJECT", "") + fixture := strings.ReplaceAll(requestedCardFixture, `[{"type":"authorize","description":"Use only after explicit user approval."}]`, `[{"type":"confirm_transaction","description":"Report the observed merchant outcome."}]`) + calls := 0 + client := vaultTestClient(t, func(w http.ResponseWriter, r *http.Request) { + calls++ + if calls == 2 { + assert.Equal(t, http.MethodPost, r.Method) + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"type":"confirm_transaction","status":"APPROVED","transaction_type":"PURCHASE","amount":1234,"currency":"USD","occurred_at":"2026-10-10T12:00:00Z"}`, string(body)) + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, fixture) + }) + _, _, err := executeVaultCommand(t, client, "vaults", "items", "invoke", "checkout", "order-1", "confirm_transaction", + "--params", `{"status":"APPROVED","transaction_type":"PURCHASE","amount":1234,"currency":"USD","occurred_at":"2026-10-10T12:00:00Z"}`, "-o", "json") + require.NoError(t, err) + assert.Equal(t, 2, calls) +} + +func TestParseVaultConfirmTransactionParamsValidates(t *testing.T) { + _, err := parseVaultOperationParams("confirm_transaction", "", false, false) + require.ErrorContains(t, err, "confirm_transaction requires --params") + for raw, message := range map[string]string{ + `{"status":"OK","transaction_type":"PURCHASE","amount":1,"currency":"USD","occurred_at":"2026-10-10T12:00:00Z"}`: "status must be", + `{"status":"APPROVED","transaction_type":"SALE","amount":1,"currency":"USD","occurred_at":"2026-10-10T12:00:00Z"}`: "transaction_type must be", + `{"status":"APPROVED","transaction_type":"PURCHASE","amount":1.5,"currency":"USD","occurred_at":"2026-10-10T12:00:00Z"}`: "amount must be", + `{"status":"APPROVED","transaction_type":"PURCHASE","amount":1,"currency":"USD","occurred_at":"yesterday"}`: "occurred_at must be", + `{"status":"APPROVED","transaction_type":"PURCHASE","amount":1,"currency":"USD","occurred_at":"2026-10-10T12:00:00Z","extra":1}`: "supported, non-duplicate", + } { + _, err := parseVaultOperationParams("confirm_transaction", raw, true, false) + require.ErrorContains(t, err, message, raw) + } +} diff --git a/cmd/vaults_operation_params.go b/cmd/vaults_operation_params.go index edf4d189..1b8b379d 100644 --- a/cmd/vaults_operation_params.go +++ b/cmd/vaults_operation_params.go @@ -7,6 +7,7 @@ import ( "net/url" "slices" "strings" + "time" kernel "github.com/kernel/kernel-go-sdk" ) @@ -15,6 +16,8 @@ type vaultOperationParams struct { Fill *vaultFillParams WebMCP *kernel.WebmcpInvokeVaultItemOperationRequestParam Checkout *kernel.VaultCheckoutContextParam + // ConfirmTransaction reports an observed Kernel Visa merchant outcome. + ConfirmTransaction *kernel.ConfirmTransactionVaultItemOperationRequestParam // OnePassword is a complete 1pw_* request body; Invoke supplies the vault. OnePassword *kernel.VaultItemPerformOperationParams } @@ -25,7 +28,7 @@ func isOnePasswordOperation(operation string) bool { // vaultOperationTakesParams reports whether an operation accepts --params or --spec-file. func vaultOperationTakesParams(operation string) bool { - return operation == "fill" || operation == "webmcp_invoke" || operation == "prepare_checkout" || (isOnePasswordOperation(operation) && operation != "1pw_recover") + return operation == "fill" || operation == "webmcp_invoke" || operation == "prepare_checkout" || operation == "confirm_transaction" || (isOnePasswordOperation(operation) && operation != "1pw_recover") } type vaultFillParams struct { @@ -121,6 +124,16 @@ func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) ( } return &vaultOperationParams{Checkout: checkout}, nil } + if operation == "confirm_transaction" { + if !paramsSet { + return nil, fmt.Errorf("confirm_transaction requires --params or --spec-file with status, transaction_type, amount, currency, and occurred_at") + } + confirm, err := parseVaultConfirmTransactionParams(raw) + if err != nil { + return nil, err + } + return &vaultOperationParams{ConfirmTransaction: confirm}, nil + } if operation == "webmcp_invoke" { if !paramsSet { return nil, fmt.Errorf("webmcp_invoke requires --params or --spec-file with browser_id, tool_ref, page_url, input, and bindings; or use items webmcp invoke") @@ -133,7 +146,7 @@ func parseVaultOperationParams(operation, raw string, paramsSet, openSet bool) ( } if operation != "fill" { if paramsSet { - return nil, fmt.Errorf("--params is only supported for fill, webmcp_invoke, prepare_checkout, and 1Password operations; authorize takes no parameters") + return nil, fmt.Errorf("--params is only supported for fill, webmcp_invoke, prepare_checkout, confirm_transaction, and 1Password operations; authorize takes no parameters") } return nil, nil } @@ -197,6 +210,40 @@ func parseVaultFillParams(raw string) (*vaultFillParams, error) { return ¶ms, nil } +func parseVaultConfirmTransactionParams(raw string) (*kernel.ConfirmTransactionVaultItemOperationRequestParam, error) { + object, err := vaultParamsObject(raw, "status transaction_type amount currency occurred_at") + if err != nil { + return nil, err + } + request := kernel.ConfirmTransactionVaultItemOperationRequestParam{Type: kernel.ConfirmTransactionVaultItemOperationRequestTypeConfirmTransaction} + var status string + if json.Unmarshal(object["status"], &status) != nil || !slices.Contains([]string{"APPROVED", "DECLINED", "PENDING", "ERROR", "CANCELLED"}, status) { + return nil, fmt.Errorf("status must be APPROVED, DECLINED, PENDING, ERROR, or CANCELLED") + } + request.Status = kernel.ConfirmTransactionVaultItemOperationRequestStatus(status) + var transactionType string + if json.Unmarshal(object["transaction_type"], &transactionType) != nil || !slices.Contains([]string{"PURCHASE", "AUTHORIZATION", "CAPTURE", "REFUND", "REVERSAL", "VERIFICATION", "CHARGEBACK", "FRAUD"}, transactionType) { + return nil, fmt.Errorf("transaction_type must be PURCHASE, AUTHORIZATION, CAPTURE, REFUND, REVERSAL, VERIFICATION, CHARGEBACK, or FRAUD") + } + request.TransactionType = kernel.ConfirmTransactionVaultItemOperationRequestTransactionType(transactionType) + var amount *int64 + if json.Unmarshal(object["amount"], &amount) != nil || amount == nil || *amount < 0 { + return nil, fmt.Errorf("amount must be a non-negative integer in minor units") + } + request.Amount = *amount + if json.Unmarshal(object["currency"], &request.Currency) != nil || strings.TrimSpace(request.Currency) == "" { + return nil, fmt.Errorf("currency must be the approved purchase currency") + } + var occurredAt string + if json.Unmarshal(object["occurred_at"], &occurredAt) != nil { + return nil, fmt.Errorf("occurred_at must be an ISO 8601 timestamp such as 2026-10-10T12:00:00Z") + } + if request.OccurredAt, err = time.Parse(time.RFC3339, occurredAt); err != nil { + return nil, fmt.Errorf("occurred_at must be an ISO 8601 timestamp such as 2026-10-10T12:00:00Z") + } + return &request, nil +} + func parseOnePasswordOperationParams(operation, raw string) (*kernel.VaultItemPerformOperationParams, error) { allowed := map[string]string{ "1pw_create_access_request": "goal reason keywords", diff --git a/cmd/vaults_output.go b/cmd/vaults_output.go index 50c10877..1ddc0e88 100644 --- a/cmd/vaults_output.go +++ b/cmd/vaults_output.go @@ -45,6 +45,7 @@ var vaultItemFields = vaultOutputFields{ "spec": { "provider": nil, "wallet": nil, "user_id": nil, "payment_method_id": nil, "card_id": nil, "checkout_origin": nil, "amount": nil, "currency": nil, "merchant": nil, "merchant_name": nil, "merchant_url": nil, "merchant_country": nil, + "merchant_category": nil, "merchant_category_code": nil, "context": nil, "expires_at": nil, "description": nil, "account": nil, "connection_id": nil, "requests": onePasswordRequestFields, "fields": vaultFieldsOf("name label type required sensitive"), diff --git a/go.mod b/go.mod index 6ad5c893..0b6561f6 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.123.0 + github.com/kernel/kernel-go-sdk v0.123.1-0.20261010230324-9ac854e2d739 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 e4e9f196..51a906b3 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,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.123.0 h1:tSS3SAvFYNIilYEsGzTZdSZMqzvhz/JlEzzzpgzxvik= -github.com/kernel/kernel-go-sdk v0.123.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.123.1-0.20261010230324-9ac854e2d739 h1:mu6eabjmB155qagPE7o7fEhK1NQvZIZOyrDf7Wq4Nsg= +github.com/kernel/kernel-go-sdk v0.123.1-0.20261010230324-9ac854e2d739/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= 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=