diff --git a/README.md b/README.md index cf46c8ed..12c544a9 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`, `--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 @@ -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/browser_pools.go b/cmd/browser_pools.go index 98959922..e8881004 100644 --- a/cmd/browser_pools.go +++ b/cmd/browser_pools.go @@ -158,6 +158,8 @@ type BrowserPoolsCreateInput struct { Region string PrivateHosts []string ProxyRoutes []string + AllowedHosts []string + AllowedHostsProvided bool StartURL string Extensions []string Viewport string @@ -248,7 +250,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, in.AllowedHostsProvided, "omit --allowed-host for unfiltered egress") + 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 +352,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 +384,9 @@ type BrowserPoolsUpdateInput struct { ClearPrivateHosts bool ProxyRoutes []string ClearProxyRoutes bool + AllowedHosts []string + AllowedHostsProvided bool + ClearAllowedHosts bool Viewport string ChromePolicy string ChromePolicyFile string @@ -409,6 +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") } + allowedHostsSet := in.AllowedHostsProvided || len(in.AllowedHosts) > 0 + if allowedHostsSet && in.ClearAllowedHosts { + return fmt.Errorf("cannot specify both --allowed-host and --clear-allowed-hosts") + } + if allowedHostsSet && in.ClearPrivateHosts { + return fmt.Errorf("cannot specify both --allowed-host and --clear-private-hosts") + } return nil } @@ -422,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{} @@ -515,7 +539,10 @@ 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 { + 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 +557,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 +877,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)") @@ -870,10 +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("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)") @@ -883,6 +922,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 +992,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 +1018,8 @@ func runBrowserPoolsCreate(cmd *cobra.Command, args []string) error { Region: region, PrivateHosts: privateHosts, ProxyRoutes: proxyRoutes, + AllowedHosts: allowedHosts, + AllowedHostsProvided: cmd.Flags().Changed("allowed-host"), 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..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 { @@ -722,6 +751,31 @@ 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 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}, + wantJSON: `"network":{"allowed_hosts":["example.com"],"proxy_routes":[]}`, + }, } for _, tt := range tests { @@ -839,3 +893,66 @@ 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 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) + + 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") +} diff --git a/cmd/browsers.go b/cmd/browsers.go index e47632ac..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,17 @@ 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 } - network.AllowedHosts = allowedHosts + if len(allowedHosts) > 0 { + network.AllowedHosts = allowedHosts + } if len(network.PrivateHosts) > 0 || len(network.ProxyRoutes) > 0 || len(network.AllowedHosts) > 0 { params.Network = network } @@ -1123,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 @@ -2943,9 +2951,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 +3004,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") @@ -3338,7 +3347,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") @@ -3465,7 +3474,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 != "" { @@ -3572,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.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..0a639730 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,28 @@ 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) + // 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) { b := BrowsersCmd{browsers: &FakeBrowsersService{}, telemetry: &FakeBrowserTelemetryService{}} 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}, 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 30fe7453..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.122.1-0.20261009181557-f737b63ffd16 + 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 620be3c1..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.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.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=