From 441d8229a98e9420b1eb6ae094d495f7b2552ceb Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Thu, 12 Nov 2026 02:37:07 +0000 Subject: [PATCH 1/2] Update Go SDK to v0.147.0 and CLI coverage --- cmd/auth_connections.go | 36 ++++++++++++++++- cmd/auth_connections_test.go | 76 +++++++++++++++++++++++++++++++++++- cmd/browser_pools.go | 27 ++++++++++++- cmd/browser_pools_test.go | 68 ++++++++++++++++++++++++++++++-- cmd/browsers.go | 62 ++++++++++++++++++++++++----- cmd/browsers_test.go | 34 ++++++++++++++++ cmd/invoke.go | 18 ++++++--- go.mod | 2 +- go.sum | 4 +- 9 files changed, 301 insertions(+), 26 deletions(-) diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index ec8a8aab..221abe53 100644 --- a/cmd/auth_connections.go +++ b/cmd/auth_connections.go @@ -12,6 +12,7 @@ import ( "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" "github.com/kernel/kernel-go-sdk/packages/pagination" + "github.com/kernel/kernel-go-sdk/packages/param" "github.com/kernel/kernel-go-sdk/packages/ssestream" "github.com/pterm/pterm" "github.com/samber/lo" @@ -50,6 +51,7 @@ type AuthConnectionCreateInput struct { ProxyName string ProxyMode string Region string + Locale string Stealth BoolFlag SaveCredentials bool NoSaveCredentials bool @@ -89,6 +91,9 @@ type AuthConnectionUpdateInput struct { ProxyNameSet bool ProxyMode string Region string + Locale string + LocaleSet bool + ClearLocale bool Stealth BoolFlag SaveCredentials BoolFlag HealthCheckInterval int @@ -240,6 +245,9 @@ func (c AuthConnectionCmd) Create(ctx context.Context, in AuthConnectionCreateIn if in.Stealth.Set { params.ManagedAuthCreateRequest.Browser.Stealth = kernel.Opt(in.Stealth.Value) } + if in.Locale != "" { + params.ManagedAuthCreateRequest.Browser.Location.Locale = kernel.Opt(in.Locale) + } if in.NoSaveCredentials { params.ManagedAuthCreateRequest.SaveCredentials = kernel.Opt(false) @@ -325,8 +333,8 @@ func managedAuthBrowserRows(cfg kernel.ManagedAuthBrowserConfig) pterm.TableData if proxy := formatBrowserProxyConfig(cfg.Proxy); proxy != "" { rows = append(rows, []string{"Browser Proxy", proxy}) } - if cfg.Region != "" { - rows = append(rows, []string{"Browser Region", string(cfg.Region)}) + if cfg.Location.Locale != "" { + rows = append(rows, []string{"Browser Locale", cfg.Location.Locale}) } // Stealth defaults to true when omitted, so only report what the API sent. if cfg.JSON.Stealth.Valid() { @@ -431,6 +439,19 @@ func (c AuthConnectionCmd) Update(ctx context.Context, in AuthConnectionUpdateIn params.ManagedAuthUpdateRequest.Browser.Stealth = kernel.Opt(in.Stealth.Value) hasChanges = true } + if in.LocaleSet && in.ClearLocale { + return fmt.Errorf("cannot specify both --locale and --clear-locale") + } + if in.LocaleSet && strings.TrimSpace(in.Locale) == "" { + return fmt.Errorf("--locale requires a non-empty value; use --clear-locale to use the browser region's default") + } + if in.ClearLocale { + params.ManagedAuthUpdateRequest.Browser.Location.Locale = param.Null[string]() + hasChanges = true + } else if in.LocaleSet { + params.ManagedAuthUpdateRequest.Browser.Location.Locale = kernel.Opt(in.Locale) + hasChanges = true + } if in.Telemetry != "" || in.TelemetryCdpExclude != "" || in.TelemetryExport != "" || in.TelemetryStorage != "" { t, err := buildManagedAuthTelemetryParam(in.Telemetry, in.TelemetryCdpExclude, in.TelemetryExport, false) @@ -1387,6 +1408,7 @@ func init() { authConnectionsCreateCmd.Flags().String("proxy-name", "", "Proxy name to use for this connection's browser sessions (mutually exclusive with --proxy-id and --proxy-mode)") authConnectionsCreateCmd.Flags().String("proxy-mode", "", "Proxy egress mode instead of a selected proxy: 'direct' for no proxy regardless of stealth, or 'default' for the stealth-derived default") authConnectionsCreateCmd.Flags().String("region", "", "Geographic region for browser sessions: 'us-east', 'us-west', 'eu-west', or 'ap-southeast'. Defaults to us-east") + authConnectionsCreateCmd.Flags().String("locale", "", "Browser locale for login, reauthentication, and health-check sessions (canonical BCP 47, e.g. sv-SE); omit to use the browser region's default") authConnectionsCreateCmd.Flags().Bool("stealth", true, "Run this connection's browser sessions in stealth mode; use --stealth=false to disable") authConnectionsCreateCmd.Flags().Bool("no-save-credentials", false, "Disable saving credentials after successful login") authConnectionsCreateCmd.Flags().Int("health-check-interval", 0, "Interval in seconds between health checks. Defaults to 3600 or your plan minimum, whichever is larger. The maximum is 86400; the minimum depends on your plan (Enterprise 300, Startup 1200, Hobbyist 3600, Free 21600)") @@ -1417,6 +1439,8 @@ func init() { authConnectionsUpdateCmd.Flags().String("proxy-name", "", "Proxy name to use for future browser sessions (mutually exclusive with --proxy-id and --proxy-mode)") authConnectionsUpdateCmd.Flags().String("proxy-mode", "", "Proxy egress mode instead of a selected proxy: 'direct' for no proxy regardless of stealth, or 'default' to drop a selected proxy and use the stealth-derived default") authConnectionsUpdateCmd.Flags().String("region", "", "Geographic region for future browser sessions: 'us-east', 'us-west', 'eu-west', or 'ap-southeast'") + authConnectionsUpdateCmd.Flags().String("locale", "", "Set the browser locale for future login, reauthentication, and health-check sessions (canonical BCP 47, e.g. sv-SE); omit to leave it unchanged (mutually exclusive with --clear-locale)") + authConnectionsUpdateCmd.Flags().Bool("clear-locale", false, "Clear the browser locale override so future sessions use the browser region's default") authConnectionsUpdateCmd.Flags().Bool("stealth", true, "Set whether future browser sessions run in stealth mode; use --stealth=false to disable") authConnectionsUpdateCmd.Flags().Bool("save-credentials", false, "Enable saving credentials after successful login") authConnectionsUpdateCmd.Flags().Bool("no-save-credentials", false, "Disable saving credentials after successful login") @@ -1435,6 +1459,7 @@ func init() { authConnectionsUpdateCmd.MarkFlagsMutuallyExclusive("save-credentials", "no-save-credentials") authConnectionsUpdateCmd.MarkFlagsMutuallyExclusive("health-checks", "no-health-checks") authConnectionsUpdateCmd.MarkFlagsMutuallyExclusive("auto-reauth", "no-auto-reauth") + authConnectionsUpdateCmd.MarkFlagsMutuallyExclusive("locale", "clear-locale") // List flags addJSONOutputFlag(authConnectionsListCmd) @@ -1510,6 +1535,7 @@ func runAuthConnectionsCreate(cmd *cobra.Command, args []string) error { proxyName, _ := cmd.Flags().GetString("proxy-name") proxyMode, _ := cmd.Flags().GetString("proxy-mode") region, _ := cmd.Flags().GetString("region") + locale, _ := cmd.Flags().GetString("locale") noSaveCredentials, _ := cmd.Flags().GetBool("no-save-credentials") healthCheckInterval, _ := cmd.Flags().GetInt("health-check-interval") noHealthChecks, _ := cmd.Flags().GetBool("no-health-checks") @@ -1534,6 +1560,7 @@ func runAuthConnectionsCreate(cmd *cobra.Command, args []string) error { ProxyName: proxyName, ProxyMode: proxyMode, Region: region, + Locale: locale, Stealth: readBoolFlag(cmd.Flags(), "stealth"), NoSaveCredentials: noSaveCredentials, HealthCheckInterval: healthCheckInterval, @@ -1574,6 +1601,8 @@ func runAuthConnectionsUpdate(cmd *cobra.Command, args []string) error { proxyName, _ := cmd.Flags().GetString("proxy-name") proxyMode, _ := cmd.Flags().GetString("proxy-mode") region, _ := cmd.Flags().GetString("region") + locale, _ := cmd.Flags().GetString("locale") + clearLocale, _ := cmd.Flags().GetBool("clear-locale") saveCredentials, _ := cmd.Flags().GetBool("save-credentials") noSaveCredentials, _ := cmd.Flags().GetBool("no-save-credentials") healthCheckInterval, _ := cmd.Flags().GetInt("health-check-interval") @@ -1626,6 +1655,9 @@ func runAuthConnectionsUpdate(cmd *cobra.Command, args []string) error { ProxyNameSet: cmd.Flags().Changed("proxy-name"), ProxyMode: proxyMode, Region: region, + Locale: locale, + LocaleSet: cmd.Flags().Changed("locale"), + ClearLocale: clearLocale, Stealth: readBoolFlag(cmd.Flags(), "stealth"), SaveCredentials: saveCredentialsFlag, HealthCheckInterval: healthCheckInterval, diff --git a/cmd/auth_connections_test.go b/cmd/auth_connections_test.go index 0ebb70c3..ad07b29c 100644 --- a/cmd/auth_connections_test.go +++ b/cmd/auth_connections_test.go @@ -551,6 +551,76 @@ func TestAuthConnectionsRegion_CommandFlags(t *testing.T) { } } +func TestAuthConnectionsLocale_CommandFlags(t *testing.T) { + tests := []struct { + name string + command *cobra.Command + method string + path string + args []string + wantLocale any + wantSet bool + }{ + { + name: "create sets locale", + command: authConnectionsCreateCmd, + method: http.MethodPost, + path: "/auth/connections", + args: []string{"--domain", "example.com", "--profile-name", "work", "--locale", "sv-SE", "--output", "json"}, + wantLocale: "sv-SE", + wantSet: true, + }, + { + name: "create omits locale", + command: authConnectionsCreateCmd, + method: http.MethodPost, + path: "/auth/connections", + args: []string{"--domain", "example.com", "--profile-name", "work", "--output", "json"}, + }, + { + name: "update sets locale", + command: authConnectionsUpdateCmd, + method: http.MethodPatch, + path: "/auth/connections/conn-1", + args: []string{"conn-1", "--locale", "fr-FR", "--output", "json"}, + wantLocale: "fr-FR", + wantSet: true, + }, + { + name: "update clears locale", + command: authConnectionsUpdateCmd, + method: http.MethodPatch, + path: "/auth/connections/conn-1", + args: []string{"conn-1", "--clear-locale", "--output", "json"}, + wantLocale: nil, + wantSet: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := runAuthConnectionCommand(t, tt.command, tt.method, tt.path, tt.args...) + browser, _ := body["browser"].(map[string]any) + location, _ := browser["location"].(map[string]any) + got, present := location["locale"] + assert.Equal(t, tt.wantSet, present) + if tt.wantSet { + assert.Equal(t, tt.wantLocale, got) + } + }) + } + + assert.Nil(t, authConnectionsLoginCmd.Flags().Lookup("locale"), "the API does not support a per-login locale override") + assert.Contains(t, authConnectionsCreateCmd.Flags().Lookup("locale").Usage, "BCP 47") + assert.Contains(t, authConnectionsUpdateCmd.Flags().Lookup("clear-locale").Usage, "region's default") + + err := (AuthConnectionCmd{svc: &FakeAuthConnectionService{}}).Update(context.Background(), AuthConnectionUpdateInput{ + ID: "conn-1", + LocaleSet: true, + }) + assert.ErrorContains(t, err, "--locale requires a non-empty value") +} + func TestAuthConnectionsHitl_CommandFlags(t *testing.T) { wantTrue := true wantFalse := false @@ -631,11 +701,15 @@ func TestAuthConnectionsRegion_RejectsUnknownValue(t *testing.T) { assert.ErrorContains(t, err, "invalid --region value") } -func TestManagedAuthBrowserRows_IncludesRegion(t *testing.T) { +func TestManagedAuthBrowserRows_IncludesRegionAndLocale(t *testing.T) { rows := managedAuthBrowserRows(kernel.ManagedAuthBrowserConfig{ Region: kernel.ManagedAuthBrowserConfigRegionEuWest, + Location: kernel.ManagedAuthBrowserLocation{ + Locale: "sv-SE", + }, }) assert.Contains(t, rows, []string{"Browser Region", "eu-west"}) + assert.Contains(t, rows, []string{"Browser Locale", "sv-SE"}) } // --credential-auto should still be honored (it was a no-op redundant flag diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index 6941960d..e0aad5ce 100644 --- a/cmd/browser_pools.go +++ b/cmd/browser_pools.go @@ -153,6 +153,7 @@ type BrowserPoolsCreateInput struct { Kiosk BoolFlag Memory string RefreshOnProfileUpdate BoolFlag + StartReplay BoolFlag ProfileID string ProfileName string ProxyID string @@ -211,6 +212,9 @@ func (c BrowserPoolsCmd) Create(ctx context.Context, in BrowserPoolsCreateInput) if in.RefreshOnProfileUpdate.Set { params.RefreshOnProfileUpdate = kernel.Bool(in.RefreshOnProfileUpdate.Value) } + if in.StartReplay.Set { + params.StartReplay = kernel.Bool(in.StartReplay.Value) + } profileID, profileName, profileSet, err := resolvePoolProfile(in.ProfileID, in.ProfileName) if err != nil { @@ -346,6 +350,7 @@ func (c BrowserPoolsCmd) Get(ctx context.Context, in BrowserPoolsGetInput) error {"Kiosk Mode", fmt.Sprintf("%t", cfg.KioskMode)}, {"Memory", util.OrDash(string(cfg.Memory))}, {"Refresh On Profile Update", fmt.Sprintf("%t", cfg.RefreshOnProfileUpdate)}, + {"Start Replay", fmt.Sprintf("%t", cfg.StartReplay)}, {"Profile", formatProfile(cfg.Profile)}, {"Proxy ID", util.OrDash(cfg.ProxyID)}, {"Start URL", util.OrDash(cfg.StartURL)}, @@ -372,6 +377,7 @@ type BrowserPoolsUpdateInput struct { Kiosk BoolFlag Memory string RefreshOnProfileUpdate BoolFlag + StartReplay BoolFlag ProfileID string ProfileName string ClearProfile bool @@ -507,6 +513,9 @@ func (c BrowserPoolsCmd) Update(ctx context.Context, in BrowserPoolsUpdateInput) if in.RefreshOnProfileUpdate.Set { params.RefreshOnProfileUpdate = kernel.Bool(in.RefreshOnProfileUpdate.Value) } + if in.StartReplay.Set { + params.StartReplay = kernel.Bool(in.StartReplay.Value) + } profileID, profileName, profileSet, err := resolvePoolProfile(in.ProfileID, in.ProfileName) if err != nil { @@ -657,6 +666,7 @@ type BrowserPoolsAcquireInput struct { Tags map[string]string Telemetry string TelemetryCdpExclude string + StartReplay BoolFlag ProfileID string ProfileName string ProfileSaveChanges bool @@ -691,7 +701,7 @@ func buildAcquireProfileParam(profileID, profileName string, saveChanges bool) ( // path so the per-lease name/tags/start-url/telemetry/profile forwarding cannot silently // diverge between them. The telemetry override merges onto the pool's config for // this lease. -func buildAcquireParams(name string, tags map[string]string, timeoutSeconds int64, telemetry, telemetryCdpExclude, startURL string, profile kernel.BrowserProfileParam) (kernel.BrowserPoolAcquireParams, error) { +func buildAcquireParams(name string, tags map[string]string, timeoutSeconds int64, telemetry, telemetryCdpExclude, startURL string, startReplay BoolFlag, profile kernel.BrowserProfileParam) (kernel.BrowserPoolAcquireParams, error) { params := kernel.BrowserPoolAcquireParams{Profile: profile} if timeoutSeconds > 0 { params.AcquireTimeoutSeconds = kernel.Int(timeoutSeconds) @@ -702,6 +712,9 @@ func buildAcquireParams(name string, tags map[string]string, timeoutSeconds int6 if startURL != "" { params.StartURL = kernel.Opt(startURL) } + if startReplay.Set { + params.StartReplay = kernel.Opt(startReplay.Value) + } if len(tags) > 0 { params.Tags = kernel.Tags(tags) } @@ -724,7 +737,7 @@ func (c BrowserPoolsCmd) Acquire(ctx context.Context, in BrowserPoolsAcquireInpu if err != nil { return err } - params, err := buildAcquireParams(in.Name, in.Tags, in.TimeoutSeconds, in.Telemetry, in.TelemetryCdpExclude, in.StartURL, profile) + params, err := buildAcquireParams(in.Name, in.Tags, in.TimeoutSeconds, in.Telemetry, in.TelemetryCdpExclude, in.StartURL, in.StartReplay, profile) if err != nil { return err } @@ -750,6 +763,7 @@ func (c BrowserPoolsCmd) Acquire(ctx context.Context, in BrowserPoolsAcquireInpu {"Session ID", resp.SessionID}, } tableData = appendBrowserLeasedAtRow(tableData, resp.LeasedAt) + tableData = appendReplayRows(tableData, resp.Replay.ReplayID, resp.Replay.ReplayViewURL, resp.Replay.StartedAt, resp.Replay.FinishedAt) if resp.Name != "" { tableData = append(tableData, []string{"Name", resp.Name}) } @@ -903,6 +917,7 @@ func init() { browserPoolsCreateCmd.Flags().Bool("kiosk", false, "Enable kiosk mode") browserPoolsCreateCmd.Flags().String("memory", "", "Memory for browsers in the pool. Headless: '1GiB' (default), '2GiB' or '4GiB'. Headful: '8GiB' (default) or '16GiB'") browserPoolsCreateCmd.Flags().Bool("refresh-on-profile-update", false, "Flush idle browsers when the pool's profile is updated") + browserPoolsCreateCmd.Flags().Bool("start-replay", false, "Start replay recording automatically whenever a browser is acquired from this pool") browserPoolsCreateCmd.Flags().String("profile-id", "", "Profile ID") browserPoolsCreateCmd.Flags().String("profile-name", "", "Profile name") browserPoolsCreateCmd.Flags().String("proxy-id", "", "Proxy ID") @@ -930,6 +945,7 @@ func init() { browserPoolsUpdateCmd.Flags().Bool("kiosk", false, "Enable kiosk mode") browserPoolsUpdateCmd.Flags().String("memory", "", "Memory for newly-warmed browsers in the pool. Headless: '1GiB', '2GiB' or '4GiB'. Headful: '8GiB' or '16GiB'. Existing browsers keep their allocation; use --discard-all-idle to replace idle browsers") browserPoolsUpdateCmd.Flags().Bool("refresh-on-profile-update", false, "Flush idle browsers when the pool's profile is updated") + browserPoolsUpdateCmd.Flags().Bool("start-replay", false, "Set whether replay recording starts automatically for future acquired browsers; use --start-replay=false to disable") browserPoolsUpdateCmd.Flags().String("profile-id", "", "Profile ID") browserPoolsUpdateCmd.Flags().String("profile-name", "", "Profile name") browserPoolsUpdateCmd.Flags().Bool("clear-profile", false, "Remove the pool profile") @@ -967,6 +983,7 @@ func init() { browserPoolsAcquireCmd.Flags().Int64("timeout", 0, "Acquire timeout in seconds") browserPoolsAcquireCmd.Flags().String("name", "", "Optional name for the acquired session (applies to this lease; cleared on release)") browserPoolsAcquireCmd.Flags().String("start-url", "", "URL to navigate the acquired browser to, overriding the pool's start URL for this acquire only (best-effort)") + browserPoolsAcquireCmd.Flags().Bool("start-replay", false, "Start replay recording immediately for this acquired browser; omit to use the pool's configured default, or use --start-replay=false to disable it for this lease") browserPoolsAcquireCmd.Flags().StringArray("tag", nil, "Set a tag KEY=VALUE on the acquired session (repeatable; applies to this lease)") browserPoolsAcquireCmd.Flags().String("telemetry", "", "Telemetry override for this lease only, merged onto the pool's config: --telemetry=all, --telemetry=off, or --telemetry=console,network") browserPoolsAcquireCmd.Flags().String("profile-id", "", "Profile ID to load into the acquired browser for this lease (mutually exclusive with --profile-name; the browser is destroyed and replaced on release)") @@ -1019,6 +1036,7 @@ func runBrowserPoolsCreate(cmd *cobra.Command, args []string) error { kiosk, _ := cmd.Flags().GetBool("kiosk") memory, _ := cmd.Flags().GetString("memory") refreshOnProfileUpdate, _ := cmd.Flags().GetBool("refresh-on-profile-update") + startReplay, _ := cmd.Flags().GetBool("start-replay") profileID, _ := cmd.Flags().GetString("profile-id") profileName, _ := cmd.Flags().GetString("profile-name") proxyID, _ := cmd.Flags().GetString("proxy-id") @@ -1045,6 +1063,7 @@ func runBrowserPoolsCreate(cmd *cobra.Command, args []string) error { Kiosk: BoolFlag{Set: cmd.Flags().Changed("kiosk"), Value: kiosk}, Memory: memory, RefreshOnProfileUpdate: BoolFlag{Set: cmd.Flags().Changed("refresh-on-profile-update"), Value: refreshOnProfileUpdate}, + StartReplay: BoolFlag{Set: cmd.Flags().Changed("start-replay"), Value: startReplay}, ProfileID: profileID, ProfileName: profileName, ProxyID: proxyID, @@ -1086,6 +1105,7 @@ func runBrowserPoolsUpdate(cmd *cobra.Command, args []string) error { kiosk, _ := cmd.Flags().GetBool("kiosk") memory, _ := cmd.Flags().GetString("memory") refreshOnProfileUpdate, _ := cmd.Flags().GetBool("refresh-on-profile-update") + startReplay, _ := cmd.Flags().GetBool("start-replay") profileID, _ := cmd.Flags().GetString("profile-id") profileName, _ := cmd.Flags().GetString("profile-name") clearProfile, _ := cmd.Flags().GetBool("clear-profile") @@ -1122,6 +1142,7 @@ func runBrowserPoolsUpdate(cmd *cobra.Command, args []string) error { Kiosk: BoolFlag{Set: cmd.Flags().Changed("kiosk"), Value: kiosk}, Memory: memory, RefreshOnProfileUpdate: BoolFlag{Set: cmd.Flags().Changed("refresh-on-profile-update"), Value: refreshOnProfileUpdate}, + StartReplay: BoolFlag{Set: cmd.Flags().Changed("start-replay"), Value: startReplay}, ProfileID: profileID, ProfileName: profileName, ClearProfile: clearProfile, @@ -1166,6 +1187,7 @@ func runBrowserPoolsAcquire(cmd *cobra.Command, args []string) error { timeout, _ := cmd.Flags().GetInt64("timeout") name, _ := cmd.Flags().GetString("name") startURL, _ := cmd.Flags().GetString("start-url") + startReplay, _ := cmd.Flags().GetBool("start-replay") tags, _ := tagsFromFlag(cmd, "tag") telemetry, _ := cmd.Flags().GetString("telemetry") telemetryCdpExclude, _ := cmd.Flags().GetString("telemetry-cdp-exclude") @@ -1182,6 +1204,7 @@ func runBrowserPoolsAcquire(cmd *cobra.Command, args []string) error { Tags: tags, Telemetry: telemetry, TelemetryCdpExclude: telemetryCdpExclude, + StartReplay: BoolFlag{Set: cmd.Flags().Changed("start-replay"), Value: startReplay}, ProfileID: profileID, ProfileName: profileName, ProfileSaveChanges: saveChanges, diff --git a/cmd/browser_pools_test.go b/cmd/browser_pools_test.go index e3def395..724b7454 100644 --- a/cmd/browser_pools_test.go +++ b/cmd/browser_pools_test.go @@ -12,6 +12,7 @@ import ( "github.com/kernel/kernel-go-sdk" "github.com/kernel/kernel-go-sdk/option" "github.com/kernel/kernel-go-sdk/packages/pagination" + "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -250,7 +251,7 @@ func TestBrowserPoolsCreate_PrivateHostNormalization(t *testing.T) { // forwarding used by both `browser-pools acquire` and the `browsers create // --pool-id` lease path. func TestBuildAcquireParams(t *testing.T) { - p, err := buildAcquireParams("lease", map[string]string{"env": "prod"}, 30, "console,network", "", "https://example.com", kernel.BrowserProfileParam{Name: kernel.Opt("my-profile")}) + p, err := buildAcquireParams("lease", map[string]string{"env": "prod"}, 30, "console,network", "", "https://example.com", BoolFlag{Set: true, Value: true}, kernel.BrowserProfileParam{Name: kernel.Opt("my-profile")}) assert.NoError(t, err) assert.True(t, p.Name.Valid()) assert.Equal(t, "lease", p.Name.Value) @@ -259,22 +260,25 @@ func TestBuildAcquireParams(t *testing.T) { assert.Equal(t, int64(30), p.AcquireTimeoutSeconds.Value) assert.True(t, p.StartURL.Valid()) assert.Equal(t, "https://example.com", p.StartURL.Value) + assert.True(t, p.StartReplay.Valid()) + assert.True(t, p.StartReplay.Value) assert.True(t, p.Telemetry.Browser.Console.Enabled.Value) assert.True(t, p.Telemetry.Browser.Network.Enabled.Value) assert.Equal(t, "my-profile", p.Profile.Name.Value) // Unset inputs produce an empty params struct (nothing forwarded). - empty, err := buildAcquireParams("", nil, 0, "", "", "", kernel.BrowserProfileParam{}) + empty, err := buildAcquireParams("", nil, 0, "", "", "", BoolFlag{}, kernel.BrowserProfileParam{}) assert.NoError(t, err) assert.False(t, empty.Name.Valid()) assert.Len(t, empty.Tags, 0) assert.False(t, empty.AcquireTimeoutSeconds.Valid()) assert.False(t, empty.StartURL.Valid()) + assert.False(t, empty.StartReplay.Valid()) assert.False(t, empty.Profile.ID.Valid()) assert.False(t, empty.Profile.Name.Valid()) // An invalid category surfaces an error rather than a partial param. - _, err = buildAcquireParams("", nil, 0, "bogus", "", "", kernel.BrowserProfileParam{}) + _, err = buildAcquireParams("", nil, 0, "bogus", "", "", BoolFlag{}, kernel.BrowserProfileParam{}) assert.Error(t, err) } @@ -375,6 +379,64 @@ func TestBrowserPoolsUpdate_WithRefreshOnProfileUpdate(t *testing.T) { assert.False(t, captured.RefreshOnProfileUpdate.Value) } +func TestBrowserPoolsStartReplayParams(t *testing.T) { + setupStdoutCapture(t) + + var created kernel.BrowserPoolNewParams + var updated kernel.BrowserPoolUpdateParams + var acquired kernel.BrowserPoolAcquireParams + fake := &FakeBrowserPoolsService{ + NewFunc: func(ctx context.Context, body kernel.BrowserPoolNewParams, opts ...option.RequestOption) (*kernel.BrowserPool, error) { + created = body + return &kernel.BrowserPool{ID: "pool-1"}, nil + }, + UpdateFunc: func(ctx context.Context, id string, body kernel.BrowserPoolUpdateParams, opts ...option.RequestOption) (*kernel.BrowserPool, error) { + updated = body + return &kernel.BrowserPool{ID: id}, nil + }, + AcquireFunc: func(ctx context.Context, id string, body kernel.BrowserPoolAcquireParams, opts ...option.RequestOption) (*kernel.BrowserPoolAcquireResponse, error) { + acquired = body + return &kernel.BrowserPoolAcquireResponse{ + SessionID: "sess-1", + Replay: kernel.BrowserPoolAcquireResponseReplay{ + ReplayID: "replay-1", + ReplayViewURL: "https://replays.example/replay-1", + }, + }, nil + }, + } + c := BrowserPoolsCmd{client: fake} + + require.NoError(t, c.Create(context.Background(), BrowserPoolsCreateInput{ + Size: 1, + StartReplay: BoolFlag{Set: true, Value: true}, + })) + require.True(t, created.StartReplay.Valid()) + assert.True(t, created.StartReplay.Value) + + require.NoError(t, c.Update(context.Background(), BrowserPoolsUpdateInput{ + IDOrName: "pool-1", + StartReplay: BoolFlag{Set: true, Value: false}, + })) + require.True(t, updated.StartReplay.Valid()) + assert.False(t, updated.StartReplay.Value) + + require.NoError(t, c.Acquire(context.Background(), BrowserPoolsAcquireInput{ + IDOrName: "pool-1", + StartReplay: BoolFlag{Set: true, Value: true}, + })) + require.True(t, acquired.StartReplay.Valid()) + assert.True(t, acquired.StartReplay.Value) + assert.Contains(t, outBuf.String(), "replay-1") + assert.Contains(t, outBuf.String(), "https://replays.example/replay-1") + + for _, command := range []*cobra.Command{browserPoolsCreateCmd, browserPoolsUpdateCmd, browserPoolsAcquireCmd} { + flag := command.Flags().Lookup("start-replay") + require.NotNil(t, flag) + assert.Contains(t, flag.Usage, "replay") + } +} + func TestBrowserPoolsUpdate_WithExpectedNetwork(t *testing.T) { setupStdoutCapture(t) diff --git a/cmd/browsers.go b/cmd/browsers.go index 23e32188..9c31d5f5 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -550,6 +550,7 @@ type BrowsersCreateInput struct { AllowedHostsProvided bool ProxyRoutes []string StartURL string + StartReplay BoolFlag Timezone string Locale string Extensions []string @@ -716,17 +717,20 @@ func (b BrowsersCmd) List(ctx context.Context, in BrowsersListInput) error { // Prepare table data. leased_at is only present for leased pooled sessions, // so keep the column out of the table when no returned browser has a lease. showLeasedAt := false + showReplay := false for _, browser := range browsers { - if !browser.LeasedAt.IsZero() { - showLeasedAt = true - break - } + showLeasedAt = showLeasedAt || !browser.LeasedAt.IsZero() + showReplay = showReplay || browser.Replay.ReplayID != "" } headers := []string{"Browser ID", "Name", "Created At"} if showLeasedAt { headers = append(headers, "Leased At") } - headers = append(headers, "Region", "Profile", "Pool", "CDP WS URL", "Live View URL") + headers = append(headers, "Region", "Profile", "Pool") + if showReplay { + headers = append(headers, "Replay ID") + } + headers = append(headers, "CDP WS URL", "Live View URL") showDeletedAt := in.IncludeDeleted || in.Status == "deleted" || in.Status == "all" if showDeletedAt { headers = append(headers, "Deleted At") @@ -764,6 +768,11 @@ func (b BrowsersCmd) List(ctx context.Context, in BrowsersListInput) error { util.OrDash(string(browser.Region)), profile, pool, + ) + if showReplay { + row = append(row, util.OrDash(browser.Replay.ReplayID)) + } + row = append(row, truncateURL(browser.CdpWsURL, 50), truncateURL(browser.BrowserLiveViewURL, 50), ) @@ -863,6 +872,9 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { if in.StartURL != "" { params.StartURL = kernel.Opt(in.StartURL) } + if in.StartReplay.Set { + params.StartReplay = kernel.Opt(in.StartReplay.Value) + } if in.Timezone != "" { params.Location.Timezone = kernel.Opt(in.Timezone) } @@ -976,6 +988,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 = appendBrowserLeasedAtRow(tableData, browser.LeasedAt) + tableData = appendReplayRows(tableData, browser.Replay.ReplayID, browser.Replay.ReplayViewURL, browser.Replay.StartedAt, browser.Replay.FinishedAt) 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) @@ -992,9 +1005,10 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { return nil } -func printBrowserSessionResult(sessionID, cdpURL, liveViewURL string, profile kernel.Profile, profileSaveChanges bool, startURL, name string, tags kernel.Tags, leasedAt time.Time) { - tableData := buildBrowserTableData(sessionID, cdpURL, liveViewURL, profile, profileSaveChanges, startURL, name, tags) - tableData = appendBrowserLeasedAtRow(tableData, leasedAt) +func printBrowserPoolAcquireResult(resp *kernel.BrowserPoolAcquireResponse) { + tableData := buildBrowserTableData(resp.SessionID, resp.CdpWsURL, resp.BrowserLiveViewURL, resp.Profile, resp.ProfileSaveChanges, resp.StartURL, resp.Name, resp.Tags) + tableData = appendBrowserLeasedAtRow(tableData, resp.LeasedAt) + tableData = appendReplayRows(tableData, resp.Replay.ReplayID, resp.Replay.ReplayViewURL, resp.Replay.StartedAt, resp.Replay.FinishedAt) PrintTableNoPad(tableData, true) } @@ -1005,6 +1019,23 @@ func appendBrowserLeasedAtRow(tableData pterm.TableData, leasedAt time.Time) pte return append(tableData, []string{"Leased At", util.FormatLocal(leasedAt)}) } +func appendReplayRows(tableData pterm.TableData, replayID, replayViewURL string, startedAt, finishedAt time.Time) pterm.TableData { + if replayID == "" { + return tableData + } + tableData = append(tableData, []string{"Replay ID", replayID}) + if replayViewURL != "" { + tableData = append(tableData, []string{"Replay View URL", replayViewURL}) + } + if !startedAt.IsZero() { + tableData = append(tableData, []string{"Replay Started At", util.FormatLocal(startedAt)}) + } + if !finishedAt.IsZero() { + tableData = append(tableData, []string{"Replay Finished At", util.FormatLocal(finishedAt)}) + } + return tableData +} + // buildBrowserTableData creates a base table with common browser session fields. func buildBrowserTableData(sessionID, cdpURL, liveViewURL string, profile kernel.Profile, profileSaveChanges bool, startURL, name string, tags kernel.Tags) pterm.TableData { tableData := pterm.TableData{ @@ -1111,6 +1142,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 = appendBrowserLeasedAtRow(tableData, browser.LeasedAt) + tableData = appendReplayRows(tableData, browser.Replay.ReplayID, browser.Replay.ReplayViewURL, browser.Replay.StartedAt, browser.Replay.FinishedAt) 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)}) @@ -1461,6 +1493,12 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { if !browser.LeasedAt.IsZero() { pterm.Info.Printf("Leased At: %s\n", util.FormatLocal(browser.LeasedAt)) } + if browser.Replay.ReplayID != "" { + pterm.Info.Printf("Replay ID: %s\n", browser.Replay.ReplayID) + if browser.Replay.ReplayViewURL != "" { + pterm.Info.Printf("Replay View URL: %s\n", browser.Replay.ReplayViewURL) + } + } if hasNameChange { pterm.Info.Printf("Name: %s\n", util.OrDash(browser.Name)) } @@ -3582,6 +3620,7 @@ 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().Bool("start-replay", false, "Start replay recording when the browser is created or acquired; with --pool-id or --pool-name, overrides the pool's configured default") 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)") @@ -3688,6 +3727,7 @@ func poolLeaseAllowedFlags() map[string]bool { "timeout": true, "name": true, "start-url": true, + "start-replay": true, "tag": true, "telemetry": true, "telemetry-cdp-exclude": true, @@ -3726,6 +3766,7 @@ 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") + startReplay, _ := cmd.Flags().GetBool("start-replay") timezone, _ := cmd.Flags().GetString("timezone") locale, _ := cmd.Flags().GetString("locale") extensions, _ := cmd.Flags().GetStringSlice("extension") @@ -3824,7 +3865,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { if err != nil { return err } - acquireParams, err := buildAcquireParams(name, tags, acquireTimeout, telemetry, telemetryCdpExclude, startURL, acquireProfile) + acquireParams, err := buildAcquireParams(name, tags, acquireTimeout, telemetry, telemetryCdpExclude, startURL, BoolFlag{Set: cmd.Flags().Changed("start-replay"), Value: startReplay}, acquireProfile) if err != nil { return err } @@ -3844,7 +3885,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { if output == "json" { return util.PrintPrettyJSON(resp) } - printBrowserSessionResult(resp.SessionID, resp.CdpWsURL, resp.BrowserLiveViewURL, resp.Profile, resp.ProfileSaveChanges, resp.StartURL, resp.Name, resp.Tags, resp.LeasedAt) + printBrowserPoolAcquireResult(resp) return nil } @@ -3887,6 +3928,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { AllowedHostsProvided: cmd.Flags().Changed("allowed-host"), ProxyRoutes: proxyRoutes, StartURL: startURL, + StartReplay: BoolFlag{Set: cmd.Flags().Changed("start-replay"), Value: startReplay}, Timezone: timezone, Locale: locale, Extensions: extensions, diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index 3d927189..b12a23be 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -603,6 +603,39 @@ func TestBrowsersCreate_WithNameAndTags(t *testing.T) { assert.Contains(t, out, "env=staging, team=backend") } +func TestBrowsersCreate_WithStartReplay(t *testing.T) { + setupStdoutCapture(t) + + var captured kernel.BrowserNewParams + startedAt := time.Date(2032, 6, 7, 8, 9, 10, 0, time.UTC) + fake := &FakeBrowsersService{ + NewFunc: func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error) { + captured = body + return &kernel.BrowserNewResponse{ + SessionID: "sess-replay", + Replay: kernel.BrowserNewResponseReplay{ + ReplayID: "replay-1", + ReplayViewURL: "https://replays.example/replay-1", + StartedAt: startedAt, + }, + }, nil + }, + } + + err := (BrowsersCmd{browsers: fake}).Create(context.Background(), BrowsersCreateInput{ + StartReplay: BoolFlag{Set: true, Value: true}, + }) + require.NoError(t, err) + require.True(t, captured.StartReplay.Valid()) + assert.True(t, captured.StartReplay.Value) + assert.Contains(t, outBuf.String(), "replay-1") + assert.Contains(t, outBuf.String(), "https://replays.example/replay-1") + + flag := browsersCreateCmd.Flags().Lookup("start-replay") + require.NotNil(t, flag) + assert.Contains(t, flag.Usage, "pool") +} + func TestBrowsersCreate_WithVaults(t *testing.T) { setupStdoutCapture(t) @@ -1246,6 +1279,7 @@ func TestPoolLeaseAllowedFlags_ExcludesChromePolicy(t *testing.T) { // The flags that genuinely apply per-lease stay allowed. assert.True(t, allowed["pool-id"]) assert.True(t, allowed["name"]) + assert.True(t, allowed["start-replay"]) assert.True(t, allowed["tag"]) } diff --git a/cmd/invoke.go b/cmd/invoke.go index ea7f3d5e..695f90a0 100644 --- a/cmd/invoke.go +++ b/cmd/invoke.go @@ -591,17 +591,20 @@ func runInvocationBrowsers(cmd *cobra.Command, args []string) error { } showLeasedAt := false + showReplay := false for _, browser := range resp.Browsers { - if !browser.LeasedAt.IsZero() { - showLeasedAt = true - break - } + showLeasedAt = showLeasedAt || !browser.LeasedAt.IsZero() + showReplay = showReplay || browser.Replay.ReplayID != "" } headers := []string{"Session ID", "Created At"} if showLeasedAt { headers = append(headers, "Leased At") } - headers = append(headers, "Region", "Headless", "Stealth", "Timeout", "CDP WS URL", "Live View URL") + headers = append(headers, "Region", "Headless", "Stealth", "Timeout") + if showReplay { + headers = append(headers, "Replay ID") + } + headers = append(headers, "CDP WS URL", "Live View URL") table := pterm.TableData{headers} for _, browser := range resp.Browsers { @@ -624,6 +627,11 @@ func runInvocationBrowsers(cmd *cobra.Command, args []string) error { fmt.Sprintf("%v", browser.Headless), fmt.Sprintf("%v", browser.Stealth), fmt.Sprintf("%d", browser.TimeoutSeconds), + ) + if showReplay { + row = append(row, util.OrDash(browser.Replay.ReplayID)) + } + row = append(row, truncateURL(browser.CdpWsURL, 40), truncateURL(liveView, 40), ) diff --git a/go.mod b/go.mod index 6a28ebc3..0690b1ee 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.145.0 + github.com/kernel/kernel-go-sdk v0.147.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 d01fa942..af16f863 100644 --- a/go.sum +++ b/go.sum @@ -68,8 +68,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.145.0 h1:IJDd9I1nxpSMWlnVDqqJuieLhVJAz8dK0rYSrHFn1WE= -github.com/kernel/kernel-go-sdk v0.145.0/go.mod h1:IoxXdj7vgL3LwtAASaRDhs4qgp9AXwBuB3+3EiRBB+U= +github.com/kernel/kernel-go-sdk v0.147.0 h1:gsEZCP12SKRgbKt0ELK6561m3NK7agTgTPJC04Lv9JA= +github.com/kernel/kernel-go-sdk v0.147.0/go.mod h1:IoxXdj7vgL3LwtAASaRDhs4qgp9AXwBuB3+3EiRBB+U= 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 d9554ae7068b018cbe6914c3ce46e82d1e1c500b Mon Sep 17 00:00:00 2001 From: "kernel-internal[bot]" <260533166+kernel-internal[bot]@users.noreply.github.com> Date: Thu, 12 Nov 2026 20:06:17 +0000 Subject: [PATCH 2/2] Update Go SDK to v0.148.0 and CLI coverage --- cmd/browsers.go | 102 +++++++++++++++++++++++++++++++++ cmd/browsers_test.go | 67 ++++++++++++++++++++++ cmd/vaults_commands.go | 14 ++++- cmd/vaults_credentials.go | 19 ++++-- cmd/vaults_onepassword_test.go | 14 ++++- go.mod | 2 +- go.sum | 4 +- 7 files changed, 209 insertions(+), 13 deletions(-) diff --git a/cmd/browsers.go b/cmd/browsers.go index 9c31d5f5..d5908909 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -45,6 +45,11 @@ type BrowsersService interface { SaveProfile(ctx context.Context, idOrName string, body kernel.BrowserSaveProfileParams, opts ...option.RequestOption) (res *kernel.Profile, err error) } +// BrowserLiveViewService defines the subset we use for scoped browser live views. +type BrowserLiveViewService interface { + New(ctx context.Context, idOrName string, body kernel.BrowserLiveViewNewParams, opts ...option.RequestOption) (res *kernel.BrowserLiveView, err error) +} + // BrowserReplaysService defines the subset we use for browser replays. type BrowserReplaysService interface { List(ctx context.Context, idOrName string, opts ...option.RequestOption) (res *[]kernel.BrowserReplayListResponse, err error) @@ -576,6 +581,14 @@ type BrowsersViewInput struct { Output string } +type BrowsersLiveViewCreateInput struct { + Identifier string + Scope string + TtlSeconds int64 + TtlSecondsSet bool + Output string +} + type BrowsersGetInput struct { Identifier string IncludeDeleted bool @@ -631,6 +644,7 @@ type BrowsersUpdateInput struct { // BrowsersCmd is a cobra-independent command handler for browsers operations. type BrowsersCmd struct { browsers BrowsersService + liveView BrowserLiveViewService replays BrowserReplaysService fs BrowserFSService fsWatch BrowserFWatchService @@ -1109,6 +1123,51 @@ func (b BrowsersCmd) View(ctx context.Context, in BrowsersViewInput) error { return nil } +func (b BrowsersCmd) LiveViewCreate(ctx context.Context, in BrowsersLiveViewCreateInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + + scope := in.Scope + if scope == "" { + scope = string(kernel.BrowserLiveViewScopeRead) + } + if scope != string(kernel.BrowserLiveViewScopeRead) && scope != string(kernel.BrowserLiveViewScopeControl) { + return fmt.Errorf("invalid --scope value: %s (must be 'read' or 'control')", scope) + } + if in.TtlSeconds < 0 || in.TtlSeconds > 31536000 || (in.TtlSecondsSet && in.TtlSeconds == 0) { + return fmt.Errorf("--ttl-seconds must be between 1 and 31536000 when set") + } + + params := kernel.BrowserLiveViewNewParams{ + BrowserLiveViewRequest: kernel.BrowserLiveViewRequestParam{ + Scope: kernel.BrowserLiveViewScope(scope), + }, + } + if in.TtlSeconds > 0 { + params.BrowserLiveViewRequest.TtlSeconds = kernel.Opt(in.TtlSeconds) + } + + liveView, err := b.liveView.New(ctx, in.Identifier, params) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if in.Output == "json" { + return util.PrintPrettyJSON(liveView) + } + + rows := pterm.TableData{ + {"Property", "Value"}, + {"URL", liveView.URL}, + {"Scope", string(liveView.Scope)}, + } + if !liveView.ExpiresAt.IsZero() { + rows = append(rows, []string{"Expires At", util.FormatLocal(liveView.ExpiresAt)}) + } + PrintTableNoPad(rows, true) + return nil +} + func (b BrowsersCmd) Get(ctx context.Context, in BrowsersGetInput) error { if err := validateJSONOutput(in.Output); err != nil { return err @@ -3173,6 +3232,25 @@ var browsersViewCmd = &cobra.Command{ RunE: runBrowsersView, } +var browsersLiveViewCmd = &cobra.Command{ + Use: "live-view", + Short: "Create scoped live view URLs", +} + +var browsersLiveViewCreateCmd = &cobra.Command{ + Use: "create ", + Short: "Create a live view URL", + Long: `Create a live view URL for a headful browser with read-only or control access. + +The URL works only in the live viewer; it cannot connect to CDP, WebDriver, or +other browser endpoints. A read-only URL cannot send input. Without +--ttl-seconds, the URL remains valid until the browser is deleted. For a browser +acquired from a pool, it also stops working when the browser is released. The +URL is not stored and cannot be revoked early, so treat it as a credential.`, + Args: cobra.ExactArgs(1), + RunE: runBrowsersLiveViewCreate, +} + var browsersGetCmd = &cobra.Command{ Use: "get ", Short: "Get detailed information about a browser session by ID or name", @@ -3289,6 +3367,12 @@ func init() { // view flags addJSONOutputFlag(browsersViewCmd) + // live-view flags + browsersLiveViewCreateCmd.Flags().String("scope", "read", "Access level: 'read' shows the browser without accepting input; 'control' also lets the viewer drive it") + browsersLiveViewCreateCmd.Flags().Int64("ttl-seconds", 0, "Seconds until the URL expires (1-31536000); omit to keep it valid until the browser is deleted or a pooled browser is released") + addJSONOutputFlag(browsersLiveViewCreateCmd) + browsersLiveViewCmd.AddCommand(browsersLiveViewCreateCmd) + // save-profile flags addJSONOutputFlag(browsersSaveProfileCmd) browsersSaveProfileCmd.Flags().String("profile-id", "", "Existing profile ID to overwrite (mutually exclusive with --profile-name)") @@ -3328,6 +3412,7 @@ func init() { browsersCmd.AddCommand(browsersCreateCmd) browsersCmd.AddCommand(browsersDeleteCmd) browsersCmd.AddCommand(browsersViewCmd) + browsersCmd.AddCommand(browsersLiveViewCmd) browsersCmd.AddCommand(browsersGetCmd) browsersCmd.AddCommand(browsersSaveProfileCmd) browsersCmd.AddCommand(browsersUpdateCmd) @@ -3976,6 +4061,23 @@ func runBrowsersView(cmd *cobra.Command, args []string) error { return b.View(cmd.Context(), in) } +func runBrowsersLiveViewCreate(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") + scope, _ := cmd.Flags().GetString("scope") + ttlSeconds, _ := cmd.Flags().GetInt64("ttl-seconds") + + svc := client.Browsers.LiveView + b := BrowsersCmd{liveView: &svc} + return b.LiveViewCreate(cmd.Context(), BrowsersLiveViewCreateInput{ + Identifier: args[0], + Scope: scope, + TtlSeconds: ttlSeconds, + TtlSecondsSet: cmd.Flags().Changed("ttl-seconds"), + Output: output, + }) +} + func runBrowsersGet(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) out, _ := cmd.Flags().GetString("output") diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index b12a23be..25417294 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -133,6 +133,17 @@ func (f *FakeBrowsersService) SaveProfile(ctx context.Context, id string, body k return &kernel.Profile{}, nil } +type FakeBrowserLiveViewService struct { + NewFunc func(ctx context.Context, id string, body kernel.BrowserLiveViewNewParams, opts ...option.RequestOption) (*kernel.BrowserLiveView, error) +} + +func (f *FakeBrowserLiveViewService) New(ctx context.Context, id string, body kernel.BrowserLiveViewNewParams, opts ...option.RequestOption) (*kernel.BrowserLiveView, error) { + if f.NewFunc != nil { + return f.NewFunc(ctx, id, body, opts...) + } + return &kernel.BrowserLiveView{}, nil +} + func TestBrowsersSaveProfile(t *testing.T) { created := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) for _, tc := range []struct { @@ -187,6 +198,62 @@ func TestBrowsersSaveProfileValidationAndHelp(t *testing.T) { } } +func TestBrowsersLiveViewCreateCommand(t *testing.T) { + setupStdoutCapture(t) + expiresAt := time.Date(2026, 9, 1, 13, 0, 0, 0, time.UTC) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/browsers/browser-one/live_view", r.URL.Path) + payload, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, `{"scope":"control","ttl_seconds":3600}`, string(payload)) + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"url":"https://live.example/view","scope":"control","expires_at":%q}`, expiresAt.Format(time.RFC3339)) + })) + t.Cleanup(server.Close) + + client := kernel.NewClient(option.WithBaseURL(server.URL), option.WithAPIKey("test")) + cmd := &cobra.Command{Use: "create"} + cmd.SetContext(context.WithValue(context.Background(), util.KernelClientKey, client)) + cmd.Flags().String("scope", "read", "") + cmd.Flags().Int64("ttl-seconds", 0, "") + addJSONOutputFlag(cmd) + require.NoError(t, cmd.ParseFlags([]string{"--scope", "control", "--ttl-seconds", "3600"})) + + require.NoError(t, runBrowsersLiveViewCreate(cmd, []string{"browser-one"})) + assert.Contains(t, outBuf.String(), "https://live.example/view") + assert.Contains(t, outBuf.String(), "control") + assert.Contains(t, outBuf.String(), util.FormatLocal(expiresAt)) +} + +func TestBrowsersLiveViewCreateDefaultsAndValidation(t *testing.T) { + setupStdoutCapture(t) + var captured kernel.BrowserLiveViewNewParams + fake := &FakeBrowserLiveViewService{NewFunc: func(ctx context.Context, id string, body kernel.BrowserLiveViewNewParams, opts ...option.RequestOption) (*kernel.BrowserLiveView, error) { + assert.Equal(t, "browser-one", id) + captured = body + return &kernel.BrowserLiveView{URL: "https://live.example/read", Scope: kernel.BrowserLiveViewScopeRead}, nil + }} + b := BrowsersCmd{liveView: fake} + require.NoError(t, b.LiveViewCreate(context.Background(), BrowsersLiveViewCreateInput{Identifier: "browser-one"})) + raw, err := json.Marshal(captured) + require.NoError(t, err) + assert.JSONEq(t, `{"scope":"read"}`, string(raw)) + + assert.ErrorContains(t, b.LiveViewCreate(context.Background(), BrowsersLiveViewCreateInput{Identifier: "browser-one", Scope: "write"}), "must be 'read' or 'control'") + assert.ErrorContains(t, b.LiveViewCreate(context.Background(), BrowsersLiveViewCreateInput{Identifier: "browser-one", TtlSeconds: -1}), "--ttl-seconds") + assert.ErrorContains(t, b.LiveViewCreate(context.Background(), BrowsersLiveViewCreateInput{Identifier: "browser-one", TtlSecondsSet: true}), "--ttl-seconds") + assert.ErrorContains(t, b.LiveViewCreate(context.Background(), BrowsersLiveViewCreateInput{Identifier: "browser-one", TtlSeconds: 31536001}), "--ttl-seconds") + + command, _, err := rootCmd.Find([]string{"browsers", "live-view", "create"}) + require.NoError(t, err) + assert.NotNil(t, command.Flags().Lookup("scope")) + assert.NotNil(t, command.Flags().Lookup("ttl-seconds")) + for _, text := range []string{"headful browser", "read-only or control", "cannot connect to CDP", "cannot be revoked early", "treat it as a credential"} { + assert.Contains(t, command.Long, text) + } +} + func TestSaveProfileOutcome(t *testing.T) { assert.Equal(t, "Created profile", saveProfileOutcome(&http.Response{StatusCode: http.StatusCreated})) assert.Equal(t, "Overwrote profile", saveProfileOutcome(&http.Response{StatusCode: http.StatusOK})) diff --git a/cmd/vaults_commands.go b/cmd/vaults_commands.go index 3ae635f2..f89e7ea3 100644 --- a/cmd/vaults_commands.go +++ b/cmd/vaults_commands.go @@ -326,9 +326,17 @@ and never retry automatically. 400/403/404/409 rejections mean the tool was not 1pw_create_access_request: no browser needed; optional goal (<=140), reason (<=100), keywords (1-5 strings); reason and keywords only for single-entry credentials. present the returned onepassword:// approval link and instructions to the account - owner unchanged; invoke it once per credential. -1pw_access_request_status: no browser needed; optional timeout_seconds 0-120 (default 10). - check status after the account owner has the approval link. + owner unchanged. native iOS, Android, and desktop apps running 1Password 8.12.46 + or later may append &return_url=. invoke it once per advertised + request and never request another link automatically. +1pw_access_request_status: no browser needed; optional timeout_seconds 0-120 (default 10) + controls how long to keep checking. Kernel checks immediately, then every 2 seconds, + slowing to 10- and 20-second intervals. each call reads fresh status and may wait up + to 20 seconds for its first scheduled check even when timeout_seconds is shorter. + honor a 429 Retry-After. after 3 minutes the approval prompt closes: ask whether the + owner approved and check once more, or invoke a newly advertised + 1pw_create_access_request for another link. expired returns the item to + pending_authorization; unknown is treated as failed. 1pw_fill: browser_id and the exact page_url of an open login page; the 1Password extension decides whether an approved login matches and refuses pages that do not. use target_id when several open pages have that exact url, and entry_id when several diff --git a/cmd/vaults_credentials.go b/cmd/vaults_credentials.go index 8932487f..48efbe77 100644 --- a/cmd/vaults_credentials.go +++ b/cmd/vaults_credentials.go @@ -54,12 +54,19 @@ const vaultOnePasswordCredentialHelp = `1Password flow: with the https website of a login page. use one entry unless the user needs several logins, such as separate accounts or sign-in origins; per-entry reason and keywords go in the spec. no field definitions, selectors, or values are accepted. -3. invoke 1pw_create_access_request once; it needs no browser. present the returned - onepassword:// approval link and instructions to the account owner unchanged. -4. invoke 1pw_access_request_status (timeout_seconds 0-120) until the credential is - ready, declined, or failed. ready means approved, not signed in. for credentials made - with a customer-supplied access token, state.access_request.resolved returns the - non-secret granted references. +3. invoke 1pw_create_access_request once per advertised request; it needs no browser. + present the returned onepassword:// approval link and instructions to the account + owner unchanged. native iOS, Android, and desktop apps running 1Password 8.12.46 + or later may append &return_url=. +4. invoke 1pw_access_request_status with timeout_seconds 0-120 (default 10). Kernel + checks immediately, then every 2 seconds, slowing to 10- and 20-second intervals. + each call reads fresh status and may wait up to 20 seconds for its first scheduled + check even when timeout_seconds is shorter; honor a 429 Retry-After. after 3 minutes + the approval prompt closes: ask whether the owner approved and check once more, or + invoke a newly advertised 1pw_create_access_request for another link. expired returns + the item to pending_authorization; unknown is treated as failed. ready means approved, + not signed in. for credentials made with a customer-supplied access token, + state.access_request.resolved returns the non-secret granted references. 5. create a browser with the vault attached, open the login page, and invoke 1pw_fill with browser_id and the exact page_url. the 1Password extension decides whether the approved login matches and refuses pages that do not. when several open pages have diff --git a/cmd/vaults_onepassword_test.go b/cmd/vaults_onepassword_test.go index 13ed4b1a..55151dd6 100644 --- a/cmd/vaults_onepassword_test.go +++ b/cmd/vaults_onepassword_test.go @@ -13,7 +13,7 @@ import ( const onePasswordAccountFixture = `{"id":"account-1","key":"onepassword","type":"credential_account","spec":{"provider":"1password","authorization":{"method":"oauth","client":{"type":"kernel_managed"}}},"state":{"provider":"1password","status":"pending_authorization"},"action":{"name":"1password_oauth","url":"https://my.1password.example/oauth/authorize?client_id=kernel&state=opaque"},"available_operations":[],"available_expansions":[],"created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-01T00:00:00Z"}` -const onePasswordCredentialFixture = `{"id":"credential-2","key":"github","type":"credential","version":1,"spec":{"provider":"1password","account":"onepassword","requests":{"version":2,"entries":[{"type":"login","parameters":{"website":"https://github.com"}}]}},"state":{"provider":"1password","status":"pending_authorization","access_request_id":"req-1","access_request":{"id":"req-1","state":"pending","identity":"never-print-identity","path":"never-print-path","has_autofill_token":false,"granted_count":0,"entries":[{"id":"entry-1","type":"login","parameters":{"website":"https://github.com"}}]}},"action":{"name":"1password_access_approval","url":"onepassword://grant-brokered-access?access_request_reference=ref-1","instructions":"Present this link to the account owner."},"available_operations":[{"type":"1pw_access_request_status","description":"Check the request."}],"available_expansions":[],"created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-01T00:00:00Z"}` +const onePasswordCredentialFixture = `{"id":"credential-2","key":"github","type":"credential","version":1,"spec":{"provider":"1password","account":"onepassword","requests":{"version":2,"entries":[{"type":"login","parameters":{"website":"https://github.com"}}]}},"state":{"provider":"1password","status":"pending_authorization","access_request_id":"req-1","access_request":{"id":"req-1","state":"pending","identity":"never-print-identity","path":"never-print-path","has_autofill_token":false,"granted_count":0,"entries":[{"id":"entry-1","type":"login","parameters":{"website":"https://github.com"}}]}},"action":{"name":"1password_access_approval","url":"onepassword://grant-brokered-access?access_request_reference=ref-1","access_request_reference":"ref-1","instructions":"Present this link to the account owner."},"available_operations":[{"type":"1pw_access_request_status","description":"Check the request."}],"available_expansions":[],"created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-01T00:00:00Z"}` func onePasswordCredentialWithOperation(operation string) string { return strings.Replace(onePasswordCredentialFixture, `"1pw_access_request_status"`, `"`+operation+`"`, 1) @@ -103,6 +103,7 @@ func TestOnePasswordCredentialOutput(t *testing.T) { assert.Contains(t, out, want) } assert.NotContains(t, out, "never-print") + assert.NotContains(t, out, `"access_request_reference":`) out, text, err := executeVaultCommand(t, client, "vaults", "items", "get", "user", "github") require.NoError(t, err) @@ -115,6 +116,7 @@ func TestOnePasswordCredentialOutput(t *testing.T) { assert.Contains(t, output, "invoke: kernel vaults items invoke --params '' -- user github 1pw_access_request_status") assert.NotContains(t, output, "field definitions") assert.NotContains(t, output, "never-print") + assert.NotContains(t, output, `"access_request_reference":`) for _, forged := range []string{ "onepassword://grant-brokered-access?access_request_reference=ref-1&code=secret", @@ -286,6 +288,13 @@ func TestCredentialHelpPresentsBothPaths(t *testing.T) { "ask whose 1Password account holds the login and confirm it is in a non-shared vault", "1-5 login entries", "invoke 1pw_create_access_request once", + "append &return_url=", + "every 2 seconds", + "20 seconds for its first scheduled", + "429 Retry-After", + "after 3 minutes", + "expired returns", + "unknown is treated as failed", "pass entry_id", "not that sign-in succeeded", "do not\ndelete and recreate the item to reset it", @@ -320,6 +329,9 @@ func TestCredentialHelpPresentsBothPaths(t *testing.T) { assert.Contains(t, invoke.Long, "page origin does not identify exactly one") assert.Contains(t, credentials.Long, "extension decides whether the") assert.Contains(t, credentials.Long, "page origin does not identify exactly one") + for _, want := range []string{"append &return_url=", "every 2 seconds", "20 seconds for its first scheduled", "429 Retry-After", "after 3 minutes", "expired returns", "unknown is treated as failed"} { + assert.Contains(t, invoke.Long, want) + } assert.Contains(t, invoke.Long, "optional access_token_expires_at (RFC3339)") assert.Contains(t, invoke.Long, "expiry clears the previously supplied expiry") for _, help := range []string{invoke.Long, invoke.Example, credentials.Long, credentials.Example, connect.Long} { diff --git a/go.mod b/go.mod index 0690b1ee..aa26ffb6 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.147.0 + github.com/kernel/kernel-go-sdk v0.148.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 af16f863..befa2ec3 100644 --- a/go.sum +++ b/go.sum @@ -68,8 +68,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.147.0 h1:gsEZCP12SKRgbKt0ELK6561m3NK7agTgTPJC04Lv9JA= -github.com/kernel/kernel-go-sdk v0.147.0/go.mod h1:IoxXdj7vgL3LwtAASaRDhs4qgp9AXwBuB3+3EiRBB+U= +github.com/kernel/kernel-go-sdk v0.148.0 h1:ShpqouDgYdfpCareZgC+YeHQHo+MuTkHYsM/vfAAvHs= +github.com/kernel/kernel-go-sdk v0.148.0/go.mod h1:IoxXdj7vgL3LwtAASaRDhs4qgp9AXwBuB3+3EiRBB+U= 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=