Skip to content

Commit ba58e9a

Browse files
authored
Add --timezone and --locale to browsers create and update (#302)
## Summary Adds `--timezone` and `--locale` to `kernel browsers create` and `kernel browsers update`, plus `--clear-timezone` and `--clear-locale` to `update`, so a session's timezone and locale can be set from the CLI. Bumps `kernel-go-sdk` from v0.126.0 to v0.129.0, which adds the `location` request and response fields. - **create:** `--timezone Europe/Berlin --locale de-DE` sets either override. An omitted flag uses the browser region's default. - **update:** `--timezone`/`--locale` set an override. `--clear-timezone`/`--clear-locale` send `null`, which clears it so the browser goes back to its region's default, like `--clear-name` and the other clear flags. An empty value is rejected with a pointer to the clear flag, and set plus clear is an error. An omitted flag leaves the field unchanged. Any one of the four counts as an update. - **output:** `create`, `get`, and `update` show the effective timezone and locale with their source, e.g. `Europe/Berlin (override)` or `America/New_York (default)`. `-o json` already returned `location`. The flags aren't added to pool acquire (`browsers create --pool-id`), which doesn't accept a location; passing them there gets the existing "flags will be ignored" warning. ## Validation - `make test` (`go vet ./...`, `go test ./...`). New tests cover the create and update params, `null` for the clear flags, the empty-value and set-plus-clear errors (without calling the API), omitted flags not being sent, `--clear-timezone --locale de-DE` reaching the API through the cobra command, and the table output. - `golangci-lint run ./cmd/`: no new findings. - Ran the built binary against production on an organization with location enabled: create with `--timezone Europe/Berlin --locale de-DE`, then `update --clear-timezone --locale ja-JP` (timezone back to `America/New_York (default)`, locale `ja-JP (override)`), then `update --clear-locale` (both defaults). `update --timezone=""` and `update --locale de-DE --clear-locale` were rejected locally. `get` showed the same values. The browser was deleted afterwards. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > CLI and SDK wiring for an existing browser location API; no auth or infrastructure changes, with solid test coverage. > > **Overview** > Adds CLI support for browser session **timezone** (IANA) and **locale** (BCP 47) via new flags on `kernel browsers create` and `kernel browsers update`, with `--clear-timezone` / `--clear-locale` on update to reset overrides to the region default. > > Create sends optional `location` fields on the API; update sets values or sends `null` to clear, with the usual validation (non-empty set values, no mixing set and clear). **create**, **get**, and **update** table output now shows effective timezone/locale and whether each comes from an override or the region default. > > Bumps **kernel-go-sdk** from v0.126.0 to v0.129.0 for the `location` request/response types. README and tests cover flag wiring, PATCH payloads, errors, and display. Pool acquire (`create --pool-id`) does not accept location; those flags still trigger the existing “ignored with pool” behavior. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit de106a4. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: tnsardesai <18272584+tnsardesai@users.noreply.github.com>
1 parent b8b1ce6 commit ba58e9a

5 files changed

Lines changed: 264 additions & 5 deletions

File tree

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,8 @@ kernel search contents srch_01jsearchresult --limit 3 --content-source browser
271271
- `--allowed-host <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.
272272
- `--proxy-route '<host>[,<host>...]=<proxy>'` - Route matching browser requests through a selected proxy (repeatable, max 10 routes with 1–50 hosts each). Example: `--proxy-route 'api.ipify.org,*.ipify.org=name:my-dc-proxy'`. The proxy is an ID by default; use `id:<id>` or `name:<name>` explicitly. Exact hostnames beat wildcards; longer wildcard suffixes beat shorter ones. `*.example.com` matches subdomains, not `example.com`. Matching ignores case and ports. Unmatched hosts use `--proxy-*` or default egress. 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.
273273
- `--start-url <url>` - Initial page to open on launch
274+
- `--timezone <iana>` - Session timezone, e.g. `Europe/Berlin`. Defaults to the browser region's timezone
275+
- `--locale <bcp47>` - Session locale used for languages and date and number formatting, e.g. `de-DE`. Defaults to the browser region's locale
274276
- `--proxy-id <id>` / `--proxy-name <name>` - Use that proxy for the session regardless of stealth (mutually exclusive with each other and with `--proxy-mode`)
275277
- `--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.
276278
- `--name <name>` - Optional unique name for the session (used to find it later by name; can be changed with `browsers update --name`)
@@ -297,6 +299,10 @@ kernel search contents srch_01jsearchresult --limit 3 --content-source browser
297299
- `--clear-name` - Clear the session name
298300
- `--tag <KEY=VALUE>` - Set a tag, repeatable; up to 50 pairs. Replaces the entire tag set (not merged); mutually exclusive with `--clear-tags`
299301
- `--clear-tags` - Remove all tags from the session
302+
- `--timezone <iana>` - Set the session timezone, e.g. `America/Los_Angeles`. Applies without restarting the browser; mutually exclusive with `--clear-timezone`
303+
- `--clear-timezone` - Clear the timezone override and use the browser region's default
304+
- `--locale <bcp47>` - Set the session locale, e.g. `de-DE`. Applies without restarting the browser; mutually exclusive with `--clear-locale`
305+
- `--clear-locale` - Clear the locale override and use the browser region's default
300306
- `--telemetry=all` - Enable telemetry for all categories
301307
- `--telemetry=off` - Disable telemetry
302308
- `--telemetry=<list>` - Per-category config, e.g. `--telemetry=network=on,page=off`

‎cmd/browsers.go‎

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,28 @@ func formatPrivateHosts(network kernel.BrowserNetworkConfig) string {
356356
return strings.Join(network.PrivateHosts, ", ")
357357
}
358358

359+
// browserLocationRows shows the effective timezone and locale with whether each
360+
// is the session's override or the browser region's default.
361+
func browserLocationRows(location kernel.BrowserLocation) [][]string {
362+
if location.Timezone == "" && location.Locale == "" {
363+
return nil
364+
}
365+
return [][]string{
366+
{"Timezone", formatBrowserLocationField(location.Timezone, location.TimezoneSource)},
367+
{"Locale", formatBrowserLocationField(location.Locale, location.LocaleSource)},
368+
}
369+
}
370+
371+
func formatBrowserLocationField(value string, source kernel.BrowserLocationSource) string {
372+
if value == "" {
373+
return "-"
374+
}
375+
if source == "" {
376+
return value
377+
}
378+
return fmt.Sprintf("%s (%s)", value, source)
379+
}
380+
359381
// formatVaultReferences renders the vaults linked to a session for table output,
360382
// preferring each vault's name and falling back to its ID. It returns an empty
361383
// string when no vaults are linked, so the row can be omitted entirely.
@@ -508,6 +530,8 @@ type BrowsersCreateInput struct {
508530
AllowedHostsProvided bool
509531
ProxyRoutes []string
510532
StartURL string
533+
Timezone string
534+
Locale string
511535
Extensions []string
512536
Vaults []string
513537
Viewport string
@@ -558,6 +582,14 @@ type BrowsersUpdateInput struct {
558582
TagsProvided bool
559583
ClearTags bool
560584
StartURL string
585+
// Timezone and Locale set the session's location overrides when SetTimezone
586+
// and SetLocale are set; ClearTimezone and ClearLocale remove them.
587+
Timezone string
588+
SetTimezone bool
589+
ClearTimezone bool
590+
Locale string
591+
SetLocale bool
592+
ClearLocale bool
561593
// AllowedHosts replaces the session's egress allowlist when
562594
// AllowedHostsProvided is set; ClearAllowedHosts removes it.
563595
AllowedHosts []string
@@ -774,6 +806,12 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error {
774806
if in.StartURL != "" {
775807
params.StartURL = kernel.Opt(in.StartURL)
776808
}
809+
if in.Timezone != "" {
810+
params.Location.Timezone = kernel.Opt(in.Timezone)
811+
}
812+
if in.Locale != "" {
813+
params.Location.Locale = kernel.Opt(in.Locale)
814+
}
777815

778816
region, err := parseRegionFlag(in.Region)
779817
if err != nil {
@@ -881,6 +919,7 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error {
881919

882920
tableData := buildBrowserTableData(browser.SessionID, browser.CdpWsURL, browser.BrowserLiveViewURL, browser.Profile, browser.ProfileSaveChanges, browser.StartURL, browser.Name, browser.Tags)
883921
tableData = append(tableData, []string{"Private Hosts", formatPrivateHosts(browser.Network)}, []string{"Allowed Hosts", formatAllowedHosts(browser.Network)}, []string{"Proxy Routes", formatProxyRoutes(browser.Network)})
922+
tableData = append(tableData, browserLocationRows(browser.Location)...)
884923
PrintTableNoPad(tableData, true)
885924
if len(browser.Vaults) > 0 {
886925
rows := pterm.TableData{{"Attached vault ID", "Name"}}
@@ -1006,6 +1045,7 @@ func (b BrowsersCmd) Get(ctx context.Context, in BrowsersGetInput) error {
10061045
// Append additional detailed fields
10071046
tableData = append(tableData, []string{"Created At", util.FormatLocal(browser.CreatedAt)})
10081047
tableData = append(tableData, []string{"Region", util.OrDash(string(browser.Region))})
1048+
tableData = append(tableData, browserLocationRows(browser.Location)...)
10091049
tableData = append(tableData, []string{"Timeout (seconds)", fmt.Sprintf("%d", browser.TimeoutSeconds)})
10101050
tableData = append(tableData, []string{"Headless", fmt.Sprintf("%t", browser.Headless)})
10111051
tableData = append(tableData, []string{"Stealth", fmt.Sprintf("%t", browser.Stealth)})
@@ -1124,13 +1164,27 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error {
11241164
return fmt.Errorf("--name requires a non-empty value; use --clear-name to clear the name")
11251165
}
11261166

1167+
if in.SetTimezone && in.ClearTimezone {
1168+
return fmt.Errorf("cannot specify both --timezone and --clear-timezone")
1169+
}
1170+
if in.SetTimezone && in.Timezone == "" {
1171+
return fmt.Errorf("--timezone requires a non-empty value; use --clear-timezone to go back to the browser region's default")
1172+
}
1173+
if in.SetLocale && in.ClearLocale {
1174+
return fmt.Errorf("cannot specify both --locale and --clear-locale")
1175+
}
1176+
if in.SetLocale && in.Locale == "" {
1177+
return fmt.Errorf("--locale requires a non-empty value; use --clear-locale to go back to the browser region's default")
1178+
}
1179+
11271180
hasProxyChange := proxySel.set()
11281181
hasProfileChange := in.ProfileID != "" || in.ProfileName != ""
11291182
hasViewportChange := in.Viewport != ""
11301183
// By this point a set name is guaranteed non-empty (the guard above rejects --name "").
11311184
hasNameChange := in.SetName || in.ClearName
11321185
hasTagsChange := len(in.Tags) > 0 || in.ClearTags
11331186
hasStartURLChange := in.StartURL != ""
1187+
hasLocationChange := in.SetTimezone || in.ClearTimezone || in.SetLocale || in.ClearLocale
11341188

11351189
// The API replaces the allowlist with the given entries, or removes it when
11361190
// sent null. An empty list is rejected, so require at least one entry.
@@ -1154,8 +1208,8 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error {
11541208
}
11551209

11561210
// Validate that at least one update option is provided
1157-
if !hasProxyChange && !hasProfileChange && !hasViewportChange && in.Telemetry == "" && in.TelemetryCdpExclude == "" && !hasNameChange && !hasTagsChange && !hasStartURLChange && !hasAllowedHostsChange {
1158-
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")
1211+
if !hasProxyChange && !hasProfileChange && !hasViewportChange && in.Telemetry == "" && in.TelemetryCdpExclude == "" && !hasNameChange && !hasTagsChange && !hasStartURLChange && !hasLocationChange && !hasAllowedHostsChange {
1212+
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")
11591213
}
11601214

11611215
params := kernel.BrowserUpdateParams{}
@@ -1181,6 +1235,19 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error {
11811235
params.StartURL = kernel.String(in.StartURL)
11821236
}
11831237

1238+
// Handle location changes. Null clears an override so the browser uses its
1239+
// region's default.
1240+
if in.ClearTimezone {
1241+
params.Location.Timezone = param.Null[string]()
1242+
} else if in.SetTimezone {
1243+
params.Location.Timezone = kernel.Opt(in.Timezone)
1244+
}
1245+
if in.ClearLocale {
1246+
params.Location.Locale = param.Null[string]()
1247+
} else if in.SetLocale {
1248+
params.Location.Locale = kernel.Opt(in.Locale)
1249+
}
1250+
11841251
// Handle egress allowlist changes. Null removes the allowlist.
11851252
if in.ClearAllowedHosts {
11861253
params.Network.AllowedHosts = param.NullSlice[[]string]()
@@ -1265,6 +1332,10 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error {
12651332
if hasStartURLChange {
12661333
pterm.Info.Printf("Start URL: %s\n", util.OrDash(browser.StartURL))
12671334
}
1335+
if hasLocationChange {
1336+
pterm.Info.Printf("Timezone: %s\n", formatBrowserLocationField(browser.Location.Timezone, browser.Location.TimezoneSource))
1337+
pterm.Info.Printf("Locale: %s\n", formatBrowserLocationField(browser.Location.Locale, browser.Location.LocaleSource))
1338+
}
12681339
if hasAllowedHostsChange {
12691340
pterm.Info.Printf("Allowed Hosts: %s\n", formatAllowedHosts(browser.Network))
12701341
}
@@ -2937,13 +3008,17 @@ Supported operations:
29373008
- Rename or clear the session name (--name or --clear-name)
29383009
- Replace or clear the session tags (--tag or --clear-tags)
29393010
- Navigate the session to a URL (--start-url)
3011+
- Set or clear the session timezone and locale (--timezone, --locale, --clear-timezone, --clear-locale)
29403012
- Replace or remove the egress allowlist (--allowed-host or --clear-allowed-hosts)
29413013
29423014
Notes:
29433015
- Profiles can only be loaded into sessions that don't already have a profile.
29443016
- --start-url navigation is best-effort: the update succeeds even if the page fails to load.
29453017
- --start-url combined with --profile-id/--profile-name overrides the profile's restored tabs.
29463018
- --tag replaces the entire tag set (it is not merged with existing tags).
3019+
- --timezone and --locale apply without restarting the browser. --clear-timezone and
3020+
--clear-locale go back to the browser region's default. Omit a flag to leave that field unchanged.
3021+
--start-url in the same update opens once the new location applies.
29473022
- --allowed-host replaces an existing allowlist only; it cannot add one to a browser created
29483023
without one. Omit to leave the list unchanged; an empty list is invalid.
29493024
- --clear-allowed-hosts removes the allowlist and returns to unfiltered egress. Once removed,
@@ -3006,6 +3081,10 @@ func init() {
30063081
browsersUpdateCmd.Flags().Bool("clear-tags", false, "Remove all tags from the browser session")
30073082
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")
30083083
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")
3084+
browsersUpdateCmd.Flags().String("timezone", "", "Set the session timezone (IANA, e.g. America/Los_Angeles) (mutually exclusive with --clear-timezone)")
3085+
browsersUpdateCmd.Flags().Bool("clear-timezone", false, "Clear the session timezone override and use the browser region's default")
3086+
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)")
3087+
browsersUpdateCmd.Flags().Bool("clear-locale", false, "Clear the session locale override and use the browser region's default")
30093088
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")
30103089

30113090
browsersCmd.AddCommand(browsersListCmd)
@@ -3302,6 +3381,8 @@ unrestricted code execution inside the browser VM and is not sandboxed.`,
33023381
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")
33033382
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")
33043383
browsersCreateCmd.Flags().String("start-url", "", "Initial page to open on launch")
3384+
browsersCreateCmd.Flags().String("timezone", "", "Session timezone (IANA, e.g. Europe/Berlin); defaults to the browser region's timezone")
3385+
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")
33053386
browsersCreateCmd.Flags().StringSlice("extension", []string{}, "Extension IDs or names to load (repeatable; may be passed multiple times or comma-separated)")
33063387
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")
33073388
browsersCreateCmd.Flags().Bool("viewport-interactive", false, "Interactively select viewport size from list")
@@ -3443,6 +3524,8 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error {
34433524
allowedHosts, _ := cmd.Flags().GetStringSlice("allowed-host")
34443525
proxyRoutes, _ := cmd.Flags().GetStringArray("proxy-route")
34453526
startURL, _ := cmd.Flags().GetString("start-url")
3527+
timezone, _ := cmd.Flags().GetString("timezone")
3528+
locale, _ := cmd.Flags().GetString("locale")
34463529
extensions, _ := cmd.Flags().GetStringSlice("extension")
34473530
vaults, _ := cmd.Flags().GetStringArray("vault")
34483531
viewport, _ := cmd.Flags().GetString("viewport")
@@ -3601,6 +3684,8 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error {
36013684
AllowedHostsProvided: cmd.Flags().Changed("allowed-host"),
36023685
ProxyRoutes: proxyRoutes,
36033686
StartURL: startURL,
3687+
Timezone: timezone,
3688+
Locale: locale,
36043689
Extensions: extensions,
36053690
Vaults: vaults,
36063691
Viewport: viewport,
@@ -3680,6 +3765,10 @@ func runBrowsersUpdate(cmd *cobra.Command, args []string) error {
36803765
tags, tagsProvided := tagsFromFlag(cmd, "tag")
36813766
clearTags, _ := cmd.Flags().GetBool("clear-tags")
36823767
startURL, _ := cmd.Flags().GetString("start-url")
3768+
timezone, _ := cmd.Flags().GetString("timezone")
3769+
clearTimezone, _ := cmd.Flags().GetBool("clear-timezone")
3770+
locale, _ := cmd.Flags().GetString("locale")
3771+
clearLocale, _ := cmd.Flags().GetBool("clear-locale")
36833772
allowedHosts, _ := cmd.Flags().GetStringSlice("allowed-host")
36843773
clearAllowedHosts, _ := cmd.Flags().GetBool("clear-allowed-hosts")
36853774

@@ -3706,6 +3795,12 @@ func runBrowsersUpdate(cmd *cobra.Command, args []string) error {
37063795
TagsProvided: tagsProvided,
37073796
ClearTags: clearTags,
37083797
StartURL: startURL,
3798+
Timezone: timezone,
3799+
SetTimezone: cmd.Flags().Changed("timezone"),
3800+
ClearTimezone: clearTimezone,
3801+
Locale: locale,
3802+
SetLocale: cmd.Flags().Changed("locale"),
3803+
ClearLocale: clearLocale,
37093804
AllowedHosts: allowedHosts,
37103805
AllowedHostsProvided: cmd.Flags().Changed("allowed-host"),
37113806
ClearAllowedHosts: clearAllowedHosts,

0 commit comments

Comments
 (0)