From 288e6d6b9404ca679beebab8ac345905f276992c Mon Sep 17 00:00:00 2001 From: tnsardesai <18272584+tnsardesai@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:39:48 +0000 Subject: [PATCH 1/3] Add --timezone and --locale to browsers create and update Bump the Go SDK to v0.129.0 for the browser location fields. On update, an empty value (--timezone="") clears that override so the browser goes back to its region's default. browsers create, get, and update show the effective timezone and locale with whether each is an override or the region default. --- README.md | 4 ++ cmd/browsers.go | 81 ++++++++++++++++++++++++++- cmd/browsers_test.go | 130 +++++++++++++++++++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 +- 5 files changed, 216 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 973a5c91..35812b0c 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,8 @@ kernel search contents srch_01jsearchresult --limit 3 --content-source browser - `--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 + - `--timezone ` - Session timezone, e.g. `Europe/Berlin`. Defaults to the browser region's timezone + - `--locale ` - Session locale used for languages and date and number formatting, e.g. `de-DE`. Defaults to the browser region's locale - `--proxy-id ` / `--proxy-name ` - Use that proxy for the session regardless of stealth (mutually exclusive with each other and with `--proxy-mode`) - `--proxy-mode direct|default` - Egress mode instead of a selected proxy: `direct` for no proxy regardless of stealth, `default` for the stealth-derived default (Kernel's stealth proxy with `--stealth`, direct egress otherwise). Omit all proxy flags to get the default. - `--name ` - Optional unique name for the session (used to find it later by name; can be changed with `browsers update --name`) @@ -297,6 +299,8 @@ kernel search contents srch_01jsearchresult --limit 3 --content-source browser - `--clear-name` - Clear the session name - `--tag ` - Set a tag, repeatable; up to 50 pairs. Replaces the entire tag set (not merged); mutually exclusive with `--clear-tags` - `--clear-tags` - Remove all tags from the session + - `--timezone ` - Set the session timezone, e.g. `America/Los_Angeles`. `--timezone=""` goes back to the browser region's default; omit to leave it unchanged. Applies without restarting the browser + - `--locale ` - Set the session locale, e.g. `de-DE`. `--locale=""` goes back to the browser region's default; omit to leave it unchanged. Applies without restarting the browser - `--telemetry=all` - Enable telemetry for all categories - `--telemetry=off` - Disable telemetry - `--telemetry=` - Per-category config, e.g. `--telemetry=network=on,page=off` diff --git a/cmd/browsers.go b/cmd/browsers.go index 47f2e98d..7fb46592 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -356,6 +356,37 @@ func formatPrivateHosts(network kernel.BrowserNetworkConfig) string { return strings.Join(network.PrivateHosts, ", ") } +// browserLocationRows shows the effective timezone and locale with whether each +// is the session's override or the browser region's default. +func browserLocationRows(location kernel.BrowserLocation) [][]string { + if location.Timezone == "" && location.Locale == "" { + return nil + } + return [][]string{ + {"Timezone", formatBrowserLocationField(location.Timezone, location.TimezoneSource)}, + {"Locale", formatBrowserLocationField(location.Locale, location.LocaleSource)}, + } +} + +func formatBrowserLocationField(value string, source kernel.BrowserLocationSource) string { + if value == "" { + return "-" + } + if source == "" { + return value + } + return fmt.Sprintf("%s (%s)", value, source) +} + +// locationUpdateValue sets a location override, or sends null for an empty value +// so the browser goes back to its region's default. +func locationUpdateValue(value string) param.Opt[string] { + if value == "" { + return param.Null[string]() + } + return kernel.Opt(value) +} + // formatVaultReferences renders the vaults linked to a session for table output, // preferring each vault's name and falling back to its ID. It returns an empty // string when no vaults are linked, so the row can be omitted entirely. @@ -508,6 +539,8 @@ type BrowsersCreateInput struct { AllowedHostsProvided bool ProxyRoutes []string StartURL string + Timezone string + Locale string Extensions []string Vaults []string Viewport string @@ -558,6 +591,12 @@ type BrowsersUpdateInput struct { TagsProvided bool ClearTags bool StartURL string + // Timezone and Locale set the session's location overrides when their + // Provided flag is set; an empty value clears that override. + Timezone string + TimezoneProvided bool + Locale string + LocaleProvided bool // AllowedHosts replaces the session's egress allowlist when // AllowedHostsProvided is set; ClearAllowedHosts removes it. AllowedHosts []string @@ -774,6 +813,12 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { if in.StartURL != "" { params.StartURL = kernel.Opt(in.StartURL) } + if in.Timezone != "" { + params.Location.Timezone = kernel.Opt(in.Timezone) + } + if in.Locale != "" { + params.Location.Locale = kernel.Opt(in.Locale) + } region, err := parseRegionFlag(in.Region) if err != nil { @@ -881,6 +926,7 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { tableData := buildBrowserTableData(browser.SessionID, browser.CdpWsURL, browser.BrowserLiveViewURL, browser.Profile, browser.ProfileSaveChanges, browser.StartURL, browser.Name, browser.Tags) tableData = append(tableData, []string{"Private Hosts", formatPrivateHosts(browser.Network)}, []string{"Allowed Hosts", formatAllowedHosts(browser.Network)}, []string{"Proxy Routes", formatProxyRoutes(browser.Network)}) + tableData = append(tableData, browserLocationRows(browser.Location)...) PrintTableNoPad(tableData, true) if len(browser.Vaults) > 0 { rows := pterm.TableData{{"Attached vault ID", "Name"}} @@ -1006,6 +1052,7 @@ func (b BrowsersCmd) Get(ctx context.Context, in BrowsersGetInput) error { // Append additional detailed fields tableData = append(tableData, []string{"Created At", util.FormatLocal(browser.CreatedAt)}) tableData = append(tableData, []string{"Region", util.OrDash(string(browser.Region))}) + tableData = append(tableData, browserLocationRows(browser.Location)...) tableData = append(tableData, []string{"Timeout (seconds)", fmt.Sprintf("%d", browser.TimeoutSeconds)}) tableData = append(tableData, []string{"Headless", fmt.Sprintf("%t", browser.Headless)}) tableData = append(tableData, []string{"Stealth", fmt.Sprintf("%t", browser.Stealth)}) @@ -1131,6 +1178,7 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { hasNameChange := in.SetName || in.ClearName hasTagsChange := len(in.Tags) > 0 || in.ClearTags hasStartURLChange := in.StartURL != "" + hasLocationChange := in.TimezoneProvided || in.LocaleProvided // 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. @@ -1154,8 +1202,8 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { } // Validate that at least one update option is provided - if !hasProxyChange && !hasProfileChange && !hasViewportChange && in.Telemetry == "" && in.TelemetryCdpExclude == "" && !hasNameChange && !hasTagsChange && !hasStartURLChange && !hasAllowedHostsChange { - return fmt.Errorf("must specify at least one of: --proxy-id, --proxy-name, --proxy-mode, --clear-proxy, --disable-default-proxy, --profile-id, --profile-name, --viewport, --telemetry, --telemetry-cdp-exclude, --name, --clear-name, --tag, --clear-tags, --start-url, --allowed-host, or --clear-allowed-hosts") + if !hasProxyChange && !hasProfileChange && !hasViewportChange && in.Telemetry == "" && in.TelemetryCdpExclude == "" && !hasNameChange && !hasTagsChange && !hasStartURLChange && !hasLocationChange && !hasAllowedHostsChange { + return fmt.Errorf("must specify at least one of: --proxy-id, --proxy-name, --proxy-mode, --clear-proxy, --disable-default-proxy, --profile-id, --profile-name, --viewport, --telemetry, --telemetry-cdp-exclude, --name, --clear-name, --tag, --clear-tags, --start-url, --timezone, --locale, --allowed-host, or --clear-allowed-hosts") } params := kernel.BrowserUpdateParams{} @@ -1181,6 +1229,13 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { params.StartURL = kernel.String(in.StartURL) } + if in.TimezoneProvided { + params.Location.Timezone = locationUpdateValue(in.Timezone) + } + if in.LocaleProvided { + params.Location.Locale = locationUpdateValue(in.Locale) + } + // Handle egress allowlist changes. Null removes the allowlist. if in.ClearAllowedHosts { params.Network.AllowedHosts = param.NullSlice[[]string]() @@ -1265,6 +1320,10 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { if hasStartURLChange { pterm.Info.Printf("Start URL: %s\n", util.OrDash(browser.StartURL)) } + if hasLocationChange { + pterm.Info.Printf("Timezone: %s\n", formatBrowserLocationField(browser.Location.Timezone, browser.Location.TimezoneSource)) + pterm.Info.Printf("Locale: %s\n", formatBrowserLocationField(browser.Location.Locale, browser.Location.LocaleSource)) + } if hasAllowedHostsChange { pterm.Info.Printf("Allowed Hosts: %s\n", formatAllowedHosts(browser.Network)) } @@ -2937,6 +2996,7 @@ Supported operations: - Rename or clear the session name (--name or --clear-name) - Replace or clear the session tags (--tag or --clear-tags) - Navigate the session to a URL (--start-url) + - Set the session timezone and locale (--timezone, --locale) - Replace or remove the egress allowlist (--allowed-host or --clear-allowed-hosts) Notes: @@ -2944,6 +3004,9 @@ Notes: - --start-url navigation is best-effort: the update succeeds even if the page fails to load. - --start-url combined with --profile-id/--profile-name overrides the profile's restored tabs. - --tag replaces the entire tag set (it is not merged with existing tags). + - --timezone and --locale apply without restarting the browser. Pass an empty value + (--timezone="") to go back to the browser region's default; omit a flag to leave it unchanged. + --start-url in the same update opens once the new location applies. - --allowed-host replaces an existing allowlist only; it cannot add one to a browser created without one. Omit to leave the list unchanged; an empty list is invalid. - --clear-allowed-hosts removes the allowlist and returns to unfiltered egress. Once removed, @@ -3006,6 +3069,8 @@ func init() { 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; 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("timezone", "", "Set the session timezone (IANA, e.g. America/Los_Angeles); --timezone=\"\" goes back to the browser region's default") + browsersUpdateCmd.Flags().String("locale", "", "Set the session locale used for languages and date and number formatting (BCP 47, e.g. de-DE); --locale=\"\" goes back to the browser region's default") 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") browsersCmd.AddCommand(browsersListCmd) @@ -3302,6 +3367,8 @@ unrestricted code execution inside the browser VM and is not sandboxed.`, browsersCreateCmd.Flags().StringSlice("allowed-host", nil, "Set an egress allowlist at creation: the only destinations the browser may reach through Kernel-managed egress (repeat or comma-separated, max 100); anything else is refused with a 403 (network_policy_denied). Filters Kernel-managed egress only, not all browser VM traffic. Accepts exact hostnames ('example.com'), a leading wildcard matching subdomains only ('*.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 with 'browsers update'; it cannot be added later if omitted at creation or removed") browsersCreateCmd.Flags().StringArray("proxy-route", nil, "Route HOST[,HOST...]=PROXY through a proxy (repeatable, max 10 routes and 50 hosts per route). PROXY is an ID by default; use id:ID or name:NAME explicitly. Exact hosts beat wildcards (longer suffixes win); *.example.com excludes example.com. Unmatched hosts use --proxy-* or default egress. Routes also apply to start_url and other setup traffic. Create-only") browsersCreateCmd.Flags().String("start-url", "", "Initial page to open on launch") + browsersCreateCmd.Flags().String("timezone", "", "Session timezone (IANA, e.g. Europe/Berlin); defaults to the browser region's timezone") + browsersCreateCmd.Flags().String("locale", "", "Session locale used for languages and date and number formatting (BCP 47, e.g. de-DE); defaults to the browser region's locale") browsersCreateCmd.Flags().StringSlice("extension", []string{}, "Extension IDs or names to load (repeatable; may be passed multiple times or comma-separated)") browsersCreateCmd.Flags().String("viewport", "", "Browser viewport size (e.g., 1920x1080@25). Supported: 2560x1440@10, 1920x1080@25, 1920x1200@25, 1440x900@25, 1024x768@60, 1200x800@60, 1280x800@60") browsersCreateCmd.Flags().Bool("viewport-interactive", false, "Interactively select viewport size from list") @@ -3443,6 +3510,8 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { allowedHosts, _ := cmd.Flags().GetStringSlice("allowed-host") proxyRoutes, _ := cmd.Flags().GetStringArray("proxy-route") startURL, _ := cmd.Flags().GetString("start-url") + timezone, _ := cmd.Flags().GetString("timezone") + locale, _ := cmd.Flags().GetString("locale") extensions, _ := cmd.Flags().GetStringSlice("extension") vaults, _ := cmd.Flags().GetStringArray("vault") viewport, _ := cmd.Flags().GetString("viewport") @@ -3601,6 +3670,8 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { AllowedHostsProvided: cmd.Flags().Changed("allowed-host"), ProxyRoutes: proxyRoutes, StartURL: startURL, + Timezone: timezone, + Locale: locale, Extensions: extensions, Vaults: vaults, Viewport: viewport, @@ -3680,6 +3751,8 @@ func runBrowsersUpdate(cmd *cobra.Command, args []string) error { tags, tagsProvided := tagsFromFlag(cmd, "tag") clearTags, _ := cmd.Flags().GetBool("clear-tags") startURL, _ := cmd.Flags().GetString("start-url") + timezone, _ := cmd.Flags().GetString("timezone") + locale, _ := cmd.Flags().GetString("locale") allowedHosts, _ := cmd.Flags().GetStringSlice("allowed-host") clearAllowedHosts, _ := cmd.Flags().GetBool("clear-allowed-hosts") @@ -3706,6 +3779,10 @@ func runBrowsersUpdate(cmd *cobra.Command, args []string) error { TagsProvided: tagsProvided, ClearTags: clearTags, StartURL: startURL, + Timezone: timezone, + TimezoneProvided: cmd.Flags().Changed("timezone"), + Locale: locale, + LocaleProvided: cmd.Flags().Changed("locale"), AllowedHosts: allowedHosts, AllowedHostsProvided: cmd.Flags().Changed("allowed-host"), ClearAllowedHosts: clearAllowedHosts, diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index a3def713..7d9eccec 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -2527,6 +2527,136 @@ func TestBrowsersCreate_RejectsStartURLFlagToken(t *testing.T) { assert.False(t, called) } +func TestBrowsersCreate_WithLocation(t *testing.T) { + setupStdoutCapture(t) + var captured kernel.BrowserNewParams + fake := &FakeBrowsersService{NewFunc: func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error) { + captured = body + var resp kernel.BrowserNewResponse + require.NoError(t, json.Unmarshal([]byte(`{"session_id":"session123","cdp_ws_url":"ws://example","location":{"timezone":"Europe/Berlin","locale":"de-DE","timezone_source":"override","locale_source":"override"}}`), &resp)) + return &resp, nil + }} + b := BrowsersCmd{browsers: fake} + + require.NoError(t, b.Create(context.Background(), BrowsersCreateInput{Timezone: "Europe/Berlin", Locale: "de-DE"})) + + raw, err := json.Marshal(captured) + require.NoError(t, err) + assert.Contains(t, string(raw), `"location":{"locale":"de-DE","timezone":"Europe/Berlin"}`) + assert.Contains(t, outBuf.String(), "Europe/Berlin (override)") + assert.Contains(t, outBuf.String(), "de-DE (override)") +} + +func TestBrowsersCreate_WithoutLocation_NotSent(t *testing.T) { + setupStdoutCapture(t) + var captured kernel.BrowserNewParams + fake := &FakeBrowsersService{NewFunc: func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error) { + captured = body + return &kernel.BrowserNewResponse{SessionID: "session123", CdpWsURL: "ws://example"}, nil + }} + b := BrowsersCmd{browsers: fake} + + require.NoError(t, b.Create(context.Background(), BrowsersCreateInput{})) + + raw, err := json.Marshal(captured) + require.NoError(t, err) + assert.NotContains(t, string(raw), `"location"`) + assert.NotContains(t, outBuf.String(), "Timezone") +} + +// An empty value clears that override; an omitted flag leaves it unchanged. +func TestBrowsersUpdate_Location(t *testing.T) { + tests := []struct { + name string + in BrowsersUpdateInput + want string + }{ + {"sets timezone only", BrowsersUpdateInput{Timezone: "America/Los_Angeles", TimezoneProvided: true}, `{"timezone":"America/Los_Angeles"}`}, + {"sets locale only", BrowsersUpdateInput{Locale: "ja-JP", LocaleProvided: true}, `{"locale":"ja-JP"}`}, + {"empty timezone clears it", BrowsersUpdateInput{TimezoneProvided: true}, `{"timezone":null}`}, + {"empty both clears both", BrowsersUpdateInput{TimezoneProvided: true, LocaleProvided: true}, `{"locale":null,"timezone":null}`}, + {"clears one and sets the other", BrowsersUpdateInput{TimezoneProvided: true, Locale: "fr-FR", LocaleProvided: true}, `{"locale":"fr-FR","timezone":null}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setupStdoutCapture(t) + fake, captured := captureUpdateParams(t) + b := BrowsersCmd{browsers: fake} + + tt.in.Identifier = "session123" + require.NoError(t, b.Update(context.Background(), tt.in)) + + raw, err := json.Marshal(*captured) + require.NoError(t, err) + assert.Contains(t, string(raw), `"location":`+tt.want) + }) + } +} + +func TestBrowsersUpdate_OmitLocation_NotSent(t *testing.T) { + setupStdoutCapture(t) + fake, captured := captureUpdateParams(t) + b := BrowsersCmd{browsers: fake} + + require.NoError(t, b.Update(context.Background(), BrowsersUpdateInput{Identifier: "session123", Name: "renamed", SetName: true})) + + raw, err := json.Marshal(*captured) + require.NoError(t, err) + assert.NotContains(t, string(raw), `"location"`) +} + +// --timezone="" must count as passed, so exercise the flag wiring through the +// run function and check what reaches the API. +func TestBrowsersUpdateCommand_EmptyLocationFlagClearsOverride(t *testing.T) { + setupStdoutCapture(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPatch, r.Method) + assert.Equal(t, "/browsers/sess-1", r.URL.Path) + payload, _ := io.ReadAll(r.Body) + assert.JSONEq(t, `{"location":{"timezone":null,"locale":"de-DE"}}`, string(payload)) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"session_id":"sess-1","location":{"timezone":"America/New_York","locale":"de-DE","timezone_source":"default","locale_source":"override"}}`) + })) + t.Cleanup(server.Close) + client := kernel.NewClient(option.WithBaseURL(server.URL), option.WithAPIKey("test")) + cmd := &cobra.Command{Use: "update"} + cmd.SetContext(context.WithValue(context.Background(), util.KernelClientKey, client)) + cmd.Flags().String("timezone", "", "") + cmd.Flags().String("locale", "", "") + addJSONOutputFlag(cmd) + require.NoError(t, cmd.ParseFlags([]string{"--timezone=", "--locale", "de-DE"})) + + require.NoError(t, runBrowsersUpdate(cmd, []string{"sess-1"})) + assert.Contains(t, outBuf.String(), "Timezone: America/New_York (default)") + assert.Contains(t, outBuf.String(), "Locale: de-DE (override)") +} + +func TestLocationFlagsRegistered(t *testing.T) { + for _, path := range [][]string{{"browsers", "create"}, {"browsers", "update"}} { + cmd, _, err := rootCmd.Find(path) + require.NoError(t, err) + assert.NotNil(t, cmd.Flags().Lookup("timezone")) + assert.NotNil(t, cmd.Flags().Lookup("locale")) + } +} + +func TestBrowsersGet_ShowsLocation(t *testing.T) { + setupStdoutCapture(t) + fake := &FakeBrowsersService{ + GetFunc: func(_ context.Context, _ string, _ kernel.BrowserGetParams, _ ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + var resp kernel.BrowserGetResponse + err := json.Unmarshal([]byte(`{"session_id":"sess-1","location":{"timezone":"Europe/Berlin","locale":"en-US","timezone_source":"override","locale_source":"default"}}`), &resp) + require.NoError(t, err) + return &resp, nil + }, + } + b := BrowsersCmd{browsers: fake} + + require.NoError(t, b.Get(context.Background(), BrowsersGetInput{Identifier: "sess-1"})) + assert.Contains(t, outBuf.String(), "Europe/Berlin (override)") + assert.Contains(t, outBuf.String(), "en-US (default)") +} + func TestBrowsersCreate_WithTelemetry(t *testing.T) { setupStdoutCapture(t) var captured kernel.BrowserNewParams diff --git a/go.mod b/go.mod index 899b3c29..9e8741d2 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.126.0 + github.com/kernel/kernel-go-sdk v0.129.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 a8ea4ec3..0269be1b 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.126.0 h1:uv3jdi4DooDGwZ9oF8WrS0YcakKv3Op/k6B8AlgUgXM= -github.com/kernel/kernel-go-sdk v0.126.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.129.0 h1:kU67s3LnMm1g9gbXZsA9FsAeeUYo04yHeqLzqGCjGOQ= +github.com/kernel/kernel-go-sdk v0.129.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 a8222c71c4cde171bf84dd43449aaed85a1b1187 Mon Sep 17 00:00:00 2001 From: tnsardesai <18272584+tnsardesai@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:18:36 +0000 Subject: [PATCH 2/3] Clear browser location overrides with --clear-timezone and --clear-locale Match --clear-name and the other update flags: --clear-timezone and --clear-locale send null so the browser goes back to its region's default, and an empty --timezone or --locale is rejected with a pointer to them. --- README.md | 6 ++-- cmd/browsers.go | 74 +++++++++++++++++++++++++++----------------- cmd/browsers_test.go | 48 ++++++++++++++++++++++------ 3 files changed, 88 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 35812b0c..2a407744 100644 --- a/README.md +++ b/README.md @@ -299,8 +299,10 @@ kernel search contents srch_01jsearchresult --limit 3 --content-source browser - `--clear-name` - Clear the session name - `--tag ` - Set a tag, repeatable; up to 50 pairs. Replaces the entire tag set (not merged); mutually exclusive with `--clear-tags` - `--clear-tags` - Remove all tags from the session - - `--timezone ` - Set the session timezone, e.g. `America/Los_Angeles`. `--timezone=""` goes back to the browser region's default; omit to leave it unchanged. Applies without restarting the browser - - `--locale ` - Set the session locale, e.g. `de-DE`. `--locale=""` goes back to the browser region's default; omit to leave it unchanged. Applies without restarting the browser + - `--timezone ` - Set the session timezone, e.g. `America/Los_Angeles`. Applies without restarting the browser; mutually exclusive with `--clear-timezone` + - `--clear-timezone` - Clear the timezone override and use the browser region's default + - `--locale ` - Set the session locale, e.g. `de-DE`. Applies without restarting the browser; mutually exclusive with `--clear-locale` + - `--clear-locale` - Clear the locale override and use the browser region's default - `--telemetry=all` - Enable telemetry for all categories - `--telemetry=off` - Disable telemetry - `--telemetry=` - Per-category config, e.g. `--telemetry=network=on,page=off` diff --git a/cmd/browsers.go b/cmd/browsers.go index 7fb46592..7bcbebe5 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -378,15 +378,6 @@ func formatBrowserLocationField(value string, source kernel.BrowserLocationSourc return fmt.Sprintf("%s (%s)", value, source) } -// locationUpdateValue sets a location override, or sends null for an empty value -// so the browser goes back to its region's default. -func locationUpdateValue(value string) param.Opt[string] { - if value == "" { - return param.Null[string]() - } - return kernel.Opt(value) -} - // formatVaultReferences renders the vaults linked to a session for table output, // preferring each vault's name and falling back to its ID. It returns an empty // string when no vaults are linked, so the row can be omitted entirely. @@ -591,12 +582,14 @@ type BrowsersUpdateInput struct { TagsProvided bool ClearTags bool StartURL string - // Timezone and Locale set the session's location overrides when their - // Provided flag is set; an empty value clears that override. - Timezone string - TimezoneProvided bool - Locale string - LocaleProvided bool + // Timezone and Locale set the session's location overrides when SetTimezone + // and SetLocale are set; ClearTimezone and ClearLocale remove them. + Timezone string + SetTimezone bool + ClearTimezone bool + Locale string + SetLocale bool + ClearLocale bool // AllowedHosts replaces the session's egress allowlist when // AllowedHostsProvided is set; ClearAllowedHosts removes it. AllowedHosts []string @@ -1171,6 +1164,19 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { return fmt.Errorf("--name requires a non-empty value; use --clear-name to clear the name") } + if in.SetTimezone && in.ClearTimezone { + return fmt.Errorf("cannot specify both --timezone and --clear-timezone") + } + if in.SetTimezone && in.Timezone == "" { + return fmt.Errorf("--timezone requires a non-empty value; use --clear-timezone to go back to the browser region's default") + } + if in.SetLocale && in.ClearLocale { + return fmt.Errorf("cannot specify both --locale and --clear-locale") + } + if in.SetLocale && in.Locale == "" { + return fmt.Errorf("--locale requires a non-empty value; use --clear-locale to go back to the browser region's default") + } + hasProxyChange := proxySel.set() hasProfileChange := in.ProfileID != "" || in.ProfileName != "" hasViewportChange := in.Viewport != "" @@ -1178,7 +1184,7 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { hasNameChange := in.SetName || in.ClearName hasTagsChange := len(in.Tags) > 0 || in.ClearTags hasStartURLChange := in.StartURL != "" - hasLocationChange := in.TimezoneProvided || in.LocaleProvided + hasLocationChange := in.SetTimezone || in.ClearTimezone || in.SetLocale || in.ClearLocale // 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. @@ -1203,7 +1209,7 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { // Validate that at least one update option is provided if !hasProxyChange && !hasProfileChange && !hasViewportChange && in.Telemetry == "" && in.TelemetryCdpExclude == "" && !hasNameChange && !hasTagsChange && !hasStartURLChange && !hasLocationChange && !hasAllowedHostsChange { - return fmt.Errorf("must specify at least one of: --proxy-id, --proxy-name, --proxy-mode, --clear-proxy, --disable-default-proxy, --profile-id, --profile-name, --viewport, --telemetry, --telemetry-cdp-exclude, --name, --clear-name, --tag, --clear-tags, --start-url, --timezone, --locale, --allowed-host, or --clear-allowed-hosts") + return fmt.Errorf("must specify at least one of: --proxy-id, --proxy-name, --proxy-mode, --clear-proxy, --disable-default-proxy, --profile-id, --profile-name, --viewport, --telemetry, --telemetry-cdp-exclude, --name, --clear-name, --tag, --clear-tags, --start-url, --timezone, --clear-timezone, --locale, --clear-locale, --allowed-host, or --clear-allowed-hosts") } params := kernel.BrowserUpdateParams{} @@ -1229,11 +1235,17 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { params.StartURL = kernel.String(in.StartURL) } - if in.TimezoneProvided { - params.Location.Timezone = locationUpdateValue(in.Timezone) + // Handle location changes. Null clears an override so the browser uses its + // region's default. + if in.ClearTimezone { + params.Location.Timezone = param.Null[string]() + } else if in.SetTimezone { + params.Location.Timezone = kernel.Opt(in.Timezone) } - if in.LocaleProvided { - params.Location.Locale = locationUpdateValue(in.Locale) + if in.ClearLocale { + params.Location.Locale = param.Null[string]() + } else if in.SetLocale { + params.Location.Locale = kernel.Opt(in.Locale) } // Handle egress allowlist changes. Null removes the allowlist. @@ -2996,7 +3008,7 @@ Supported operations: - Rename or clear the session name (--name or --clear-name) - Replace or clear the session tags (--tag or --clear-tags) - Navigate the session to a URL (--start-url) - - Set the session timezone and locale (--timezone, --locale) + - Set or clear the session timezone and locale (--timezone, --locale, --clear-timezone, --clear-locale) - Replace or remove the egress allowlist (--allowed-host or --clear-allowed-hosts) Notes: @@ -3004,8 +3016,8 @@ Notes: - --start-url navigation is best-effort: the update succeeds even if the page fails to load. - --start-url combined with --profile-id/--profile-name overrides the profile's restored tabs. - --tag replaces the entire tag set (it is not merged with existing tags). - - --timezone and --locale apply without restarting the browser. Pass an empty value - (--timezone="") to go back to the browser region's default; omit a flag to leave it unchanged. + - --timezone and --locale apply without restarting the browser. --clear-timezone and + --clear-locale go back to the browser region's default; omit them to leave a field unchanged. --start-url in the same update opens once the new location applies. - --allowed-host replaces an existing allowlist only; it cannot add one to a browser created without one. Omit to leave the list unchanged; an empty list is invalid. @@ -3069,8 +3081,10 @@ func init() { 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; 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("timezone", "", "Set the session timezone (IANA, e.g. America/Los_Angeles); --timezone=\"\" goes back to the browser region's default") - browsersUpdateCmd.Flags().String("locale", "", "Set the session locale used for languages and date and number formatting (BCP 47, e.g. de-DE); --locale=\"\" goes back to the browser region's default") + browsersUpdateCmd.Flags().String("timezone", "", "Set the session timezone (IANA, e.g. America/Los_Angeles) (mutually exclusive with --clear-timezone)") + browsersUpdateCmd.Flags().Bool("clear-timezone", false, "Clear the session timezone override and use the browser region's default") + browsersUpdateCmd.Flags().String("locale", "", "Set the session locale used for languages and date and number formatting (BCP 47, e.g. de-DE) (mutually exclusive with --clear-locale)") + browsersUpdateCmd.Flags().Bool("clear-locale", false, "Clear the session locale override and use the browser region's default") 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") browsersCmd.AddCommand(browsersListCmd) @@ -3752,7 +3766,9 @@ func runBrowsersUpdate(cmd *cobra.Command, args []string) error { clearTags, _ := cmd.Flags().GetBool("clear-tags") startURL, _ := cmd.Flags().GetString("start-url") timezone, _ := cmd.Flags().GetString("timezone") + clearTimezone, _ := cmd.Flags().GetBool("clear-timezone") locale, _ := cmd.Flags().GetString("locale") + clearLocale, _ := cmd.Flags().GetBool("clear-locale") allowedHosts, _ := cmd.Flags().GetStringSlice("allowed-host") clearAllowedHosts, _ := cmd.Flags().GetBool("clear-allowed-hosts") @@ -3780,9 +3796,11 @@ func runBrowsersUpdate(cmd *cobra.Command, args []string) error { ClearTags: clearTags, StartURL: startURL, Timezone: timezone, - TimezoneProvided: cmd.Flags().Changed("timezone"), + SetTimezone: cmd.Flags().Changed("timezone"), + ClearTimezone: clearTimezone, Locale: locale, - LocaleProvided: cmd.Flags().Changed("locale"), + SetLocale: cmd.Flags().Changed("locale"), + ClearLocale: clearLocale, AllowedHosts: allowedHosts, AllowedHostsProvided: cmd.Flags().Changed("allowed-host"), ClearAllowedHosts: clearAllowedHosts, diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index 7d9eccec..f3f3ef4d 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -2564,18 +2564,18 @@ func TestBrowsersCreate_WithoutLocation_NotSent(t *testing.T) { assert.NotContains(t, outBuf.String(), "Timezone") } -// An empty value clears that override; an omitted flag leaves it unchanged. +// A clear flag sends null for that override; an omitted flag leaves it unchanged. func TestBrowsersUpdate_Location(t *testing.T) { tests := []struct { name string in BrowsersUpdateInput want string }{ - {"sets timezone only", BrowsersUpdateInput{Timezone: "America/Los_Angeles", TimezoneProvided: true}, `{"timezone":"America/Los_Angeles"}`}, - {"sets locale only", BrowsersUpdateInput{Locale: "ja-JP", LocaleProvided: true}, `{"locale":"ja-JP"}`}, - {"empty timezone clears it", BrowsersUpdateInput{TimezoneProvided: true}, `{"timezone":null}`}, - {"empty both clears both", BrowsersUpdateInput{TimezoneProvided: true, LocaleProvided: true}, `{"locale":null,"timezone":null}`}, - {"clears one and sets the other", BrowsersUpdateInput{TimezoneProvided: true, Locale: "fr-FR", LocaleProvided: true}, `{"locale":"fr-FR","timezone":null}`}, + {"sets timezone only", BrowsersUpdateInput{Timezone: "America/Los_Angeles", SetTimezone: true}, `{"timezone":"America/Los_Angeles"}`}, + {"sets locale only", BrowsersUpdateInput{Locale: "ja-JP", SetLocale: true}, `{"locale":"ja-JP"}`}, + {"clears timezone", BrowsersUpdateInput{ClearTimezone: true}, `{"timezone":null}`}, + {"clears both", BrowsersUpdateInput{ClearTimezone: true, ClearLocale: true}, `{"locale":null,"timezone":null}`}, + {"clears one and sets the other", BrowsersUpdateInput{ClearTimezone: true, Locale: "fr-FR", SetLocale: true}, `{"locale":"fr-FR","timezone":null}`}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -2593,6 +2593,29 @@ func TestBrowsersUpdate_Location(t *testing.T) { } } +func TestBrowsersUpdate_LocationFlagErrors(t *testing.T) { + tests := []struct { + name string + in BrowsersUpdateInput + wantErr string + }{ + {"empty timezone", BrowsersUpdateInput{SetTimezone: true}, "--timezone requires a non-empty value; use --clear-timezone to go back to the browser region's default"}, + {"empty locale", BrowsersUpdateInput{SetLocale: true}, "--locale requires a non-empty value; use --clear-locale to go back to the browser region's default"}, + {"timezone and clear", BrowsersUpdateInput{Timezone: "Europe/Berlin", SetTimezone: true, ClearTimezone: true}, "cannot specify both --timezone and --clear-timezone"}, + {"locale and clear", BrowsersUpdateInput{Locale: "de-DE", SetLocale: true, ClearLocale: true}, "cannot specify both --locale and --clear-locale"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fake := &FakeBrowsersService{UpdateFunc: func(ctx context.Context, id string, body kernel.BrowserUpdateParams, opts ...option.RequestOption) (*kernel.BrowserUpdateResponse, error) { + t.Fatal("Update should not be called") + return nil, nil + }} + tt.in.Identifier = "session123" + assert.EqualError(t, (BrowsersCmd{browsers: fake}).Update(context.Background(), tt.in), tt.wantErr) + }) + } +} + func TestBrowsersUpdate_OmitLocation_NotSent(t *testing.T) { setupStdoutCapture(t) fake, captured := captureUpdateParams(t) @@ -2605,9 +2628,8 @@ func TestBrowsersUpdate_OmitLocation_NotSent(t *testing.T) { assert.NotContains(t, string(raw), `"location"`) } -// --timezone="" must count as passed, so exercise the flag wiring through the -// run function and check what reaches the API. -func TestBrowsersUpdateCommand_EmptyLocationFlagClearsOverride(t *testing.T) { +// Exercise the flag wiring through the run function and check what reaches the API. +func TestBrowsersUpdateCommand_ClearTimezoneAndSetLocale(t *testing.T) { setupStdoutCapture(t) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, http.MethodPatch, r.Method) @@ -2622,9 +2644,11 @@ func TestBrowsersUpdateCommand_EmptyLocationFlagClearsOverride(t *testing.T) { cmd := &cobra.Command{Use: "update"} cmd.SetContext(context.WithValue(context.Background(), util.KernelClientKey, client)) cmd.Flags().String("timezone", "", "") + cmd.Flags().Bool("clear-timezone", false, "") cmd.Flags().String("locale", "", "") + cmd.Flags().Bool("clear-locale", false, "") addJSONOutputFlag(cmd) - require.NoError(t, cmd.ParseFlags([]string{"--timezone=", "--locale", "de-DE"})) + require.NoError(t, cmd.ParseFlags([]string{"--clear-timezone", "--locale", "de-DE"})) require.NoError(t, runBrowsersUpdate(cmd, []string{"sess-1"})) assert.Contains(t, outBuf.String(), "Timezone: America/New_York (default)") @@ -2638,6 +2662,10 @@ func TestLocationFlagsRegistered(t *testing.T) { assert.NotNil(t, cmd.Flags().Lookup("timezone")) assert.NotNil(t, cmd.Flags().Lookup("locale")) } + update, _, err := rootCmd.Find([]string{"browsers", "update"}) + require.NoError(t, err) + assert.NotNil(t, update.Flags().Lookup("clear-timezone")) + assert.NotNil(t, update.Flags().Lookup("clear-locale")) } func TestBrowsersGet_ShowsLocation(t *testing.T) { From de106a4acbade7257732b0d23bc8be3081d83018 Mon Sep 17 00:00:00 2001 From: tnsardesai <18272584+tnsardesai@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:34:40 +0000 Subject: [PATCH 3/3] Clarify that omitting a location flag leaves that field unchanged --- cmd/browsers.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/browsers.go b/cmd/browsers.go index 7bcbebe5..97da389f 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -3017,7 +3017,7 @@ Notes: - --start-url combined with --profile-id/--profile-name overrides the profile's restored tabs. - --tag replaces the entire tag set (it is not merged with existing tags). - --timezone and --locale apply without restarting the browser. --clear-timezone and - --clear-locale go back to the browser region's default; omit them to leave a field unchanged. + --clear-locale go back to the browser region's default. Omit a flag to leave that field unchanged. --start-url in the same update opens once the new location applies. - --allowed-host replaces an existing allowlist only; it cannot add one to a browser created without one. Omit to leave the list unchanged; an empty list is invalid.