diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index 984315a..bd94317 100644 --- a/cmd/auth_connections.go +++ b/cmd/auth_connections.go @@ -51,6 +51,9 @@ type AuthConnectionCreateInput struct { SaveCredentials bool NoSaveCredentials bool HealthCheckInterval int + NoHealthChecks bool + NoAutoReauth bool + RecordSession bool Telemetry string Output string } @@ -80,6 +83,9 @@ type AuthConnectionUpdateInput struct { SaveCredentials BoolFlag HealthCheckInterval int HealthCheckIntervalSet bool + HealthChecks BoolFlag + AutoReauth BoolFlag + RecordSession BoolFlag Telemetry string Output string } @@ -98,11 +104,12 @@ type AuthConnectionDeleteInput struct { } type AuthConnectionLoginInput struct { - ID string - ProxyID string - ProxyName string - Telemetry string - Output string + ID string + ProxyID string + ProxyName string + RecordSession BoolFlag + Telemetry string + Output string } type AuthConnectionSubmitInput struct { @@ -201,6 +208,18 @@ func (c AuthConnectionCmd) Create(ctx context.Context, in AuthConnectionCreateIn params.ManagedAuthCreateRequest.SaveCredentials = kernel.Opt(false) } + if in.NoHealthChecks { + params.ManagedAuthCreateRequest.HealthChecks = kernel.Opt(false) + } + + if in.NoAutoReauth { + params.ManagedAuthCreateRequest.AutoReauth = kernel.Opt(false) + } + + if in.RecordSession { + params.ManagedAuthCreateRequest.RecordSession = kernel.Opt(true) + } + if in.Telemetry != "" { t, err := buildAuthConnectionCreateTelemetryParam(in.Telemetry) if err != nil { @@ -276,6 +295,18 @@ func (c AuthConnectionCmd) Update(ctx context.Context, in AuthConnectionUpdateIn params.ManagedAuthUpdateRequest.SaveCredentials = kernel.Opt(in.SaveCredentials.Value) hasChanges = true } + if in.HealthChecks.Set { + params.ManagedAuthUpdateRequest.HealthChecks = kernel.Opt(in.HealthChecks.Value) + hasChanges = true + } + if in.AutoReauth.Set { + params.ManagedAuthUpdateRequest.AutoReauth = kernel.Opt(in.AutoReauth.Value) + hasChanges = true + } + if in.RecordSession.Set { + params.ManagedAuthUpdateRequest.RecordSession = kernel.Opt(in.RecordSession.Value) + hasChanges = true + } if in.AllowedDomainsSet { params.ManagedAuthUpdateRequest.AllowedDomains = in.AllowedDomains hasChanges = true @@ -620,6 +651,10 @@ func (c AuthConnectionCmd) Login(ctx context.Context, in AuthConnectionLoginInpu } } + if in.RecordSession.Set { + params.RecordSession = kernel.Opt(in.RecordSession.Value) + } + if in.Telemetry != "" { t, err := buildAuthConnectionLoginTelemetryParam(in.Telemetry) if err != nil { @@ -839,7 +874,7 @@ func (c AuthConnectionCmd) Timeline(ctx context.Context, in AuthConnectionTimeli return nil } - tableData := pterm.TableData{{"Timestamp", "Type", "Status", "Step", "Browser Session", "Details"}} + tableData := pterm.TableData{{"Timestamp", "Type", "Status", "Step", "Browser Session", "Telemetry", "Details"}} for _, e := range events { details := e.ErrorMessage if details == "" { @@ -848,12 +883,20 @@ func (c AuthConnectionCmd) Timeline(ctx context.Context, in AuthConnectionTimeli if details == "" && e.PreviousStatus != "" { details = fmt.Sprintf("%s -> %s", e.PreviousStatus, e.Status) } + // Telemetry only means something when the event has a browser session + // whose events `kernel browsers telemetry` could fetch, and when the API + // actually reported the field. + telemetry := "-" + if e.BrowserSessionID != "" && e.JSON.TelemetryCaptured.Valid() { + telemetry = lo.Ternary(e.TelemetryCaptured, "yes", "no") + } tableData = append(tableData, []string{ util.FormatLocal(e.Timestamp), string(e.Type), string(e.Status), string(e.Step), util.OrDash(e.BrowserSessionID), + telemetry, util.OrDash(details), }) } @@ -1050,6 +1093,9 @@ func init() { authConnectionsCreateCmd.Flags().String("proxy-name", "", "Proxy name to use") 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 (300-86400)") + authConnectionsCreateCmd.Flags().Bool("no-health-checks", false, "Disable periodic health checks (enabled by default)") + authConnectionsCreateCmd.Flags().Bool("no-auto-reauth", false, "Mark expired sessions as NEEDS_AUTH instead of attempting automatic re-authentication (auto re-auth is enabled by default)") + authConnectionsCreateCmd.Flags().Bool("record-session", false, "Record browser sessions for this connection by default (useful for debugging)") authConnectionsCreateCmd.Flags().String("telemetry", "", "Configure telemetry for this connection's browser sessions (opt-in): --telemetry=all (default set), --telemetry=off (disable), or --telemetry=console,network (capture exactly those categories)") _ = authConnectionsCreateCmd.MarkFlagRequired("domain") _ = authConnectionsCreateCmd.MarkFlagRequired("profile-name") @@ -1071,9 +1117,18 @@ func init() { 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") authConnectionsUpdateCmd.Flags().Int("health-check-interval", 0, "Interval in seconds between health checks") + authConnectionsUpdateCmd.Flags().Bool("health-checks", false, "Enable periodic health checks") + authConnectionsUpdateCmd.Flags().Bool("no-health-checks", false, "Disable periodic health checks") + authConnectionsUpdateCmd.Flags().Bool("auto-reauth", false, "Permit automatic re-authentication when a health check detects an expired session") + authConnectionsUpdateCmd.Flags().Bool("no-auto-reauth", false, "Mark expired sessions as NEEDS_AUTH instead of attempting automatic re-authentication") + authConnectionsUpdateCmd.Flags().Bool("record-session", false, "Record browser sessions for this connection by default") + authConnectionsUpdateCmd.Flags().Bool("no-record-session", false, "Stop recording browser sessions for this connection by default") authConnectionsUpdateCmd.Flags().String("telemetry", "", "Update telemetry for future browser sessions: --telemetry=all (reset to default set), --telemetry=off (disable), or --telemetry=console,network (merge those categories into the current selection)") authConnectionsUpdateCmd.MarkFlagsMutuallyExclusive("credential-name", "credential-provider") authConnectionsUpdateCmd.MarkFlagsMutuallyExclusive("save-credentials", "no-save-credentials") + authConnectionsUpdateCmd.MarkFlagsMutuallyExclusive("health-checks", "no-health-checks") + authConnectionsUpdateCmd.MarkFlagsMutuallyExclusive("auto-reauth", "no-auto-reauth") + authConnectionsUpdateCmd.MarkFlagsMutuallyExclusive("record-session", "no-record-session") // List flags addJSONOutputFlag(authConnectionsListCmd) @@ -1089,6 +1144,9 @@ func init() { addJSONOutputFlag(authConnectionsLoginCmd) authConnectionsLoginCmd.Flags().String("proxy-id", "", "Proxy ID to use for this login") authConnectionsLoginCmd.Flags().String("proxy-name", "", "Proxy name to use for this login") + authConnectionsLoginCmd.Flags().Bool("record-session", false, "Record this login's browser session, overriding the connection's default") + authConnectionsLoginCmd.Flags().Bool("no-record-session", false, "Do not record this login's browser session, overriding the connection's default") + authConnectionsLoginCmd.MarkFlagsMutuallyExclusive("record-session", "no-record-session") authConnectionsLoginCmd.Flags().String("telemetry", "", "Telemetry override for this login only, merged onto the connection's config: --telemetry=all, --telemetry=off, or --telemetry=console,network") // Submit flags @@ -1139,6 +1197,9 @@ func runAuthConnectionsCreate(cmd *cobra.Command, args []string) error { proxyName, _ := cmd.Flags().GetString("proxy-name") noSaveCredentials, _ := cmd.Flags().GetBool("no-save-credentials") healthCheckInterval, _ := cmd.Flags().GetInt("health-check-interval") + noHealthChecks, _ := cmd.Flags().GetBool("no-health-checks") + noAutoReauth, _ := cmd.Flags().GetBool("no-auto-reauth") + recordSession, _ := cmd.Flags().GetBool("record-session") telemetry, _ := cmd.Flags().GetString("telemetry") svc := client.Auth.Connections @@ -1156,6 +1217,9 @@ func runAuthConnectionsCreate(cmd *cobra.Command, args []string) error { ProxyName: proxyName, NoSaveCredentials: noSaveCredentials, HealthCheckInterval: healthCheckInterval, + NoHealthChecks: noHealthChecks, + NoAutoReauth: noAutoReauth, + RecordSession: recordSession, Telemetry: telemetry, Output: output, }) @@ -1190,6 +1254,7 @@ func runAuthConnectionsUpdate(cmd *cobra.Command, args []string) error { telemetry, _ := cmd.Flags().GetString("telemetry") saveCredentialsFlag := BoolFlag{} + if cmd.Flags().Changed("save-credentials") { saveCredentialsFlag = BoolFlag{Set: true, Value: saveCredentials} } @@ -1197,6 +1262,20 @@ func runAuthConnectionsUpdate(cmd *cobra.Command, args []string) error { saveCredentialsFlag = BoolFlag{Set: true, Value: !noSaveCredentials} } + // Each of these is a tri-state override: --x sets true, --no-x sets false, and + // omitting both leaves the connection's current value untouched. + togglePair := func(onFlag, offFlag string) BoolFlag { + if cmd.Flags().Changed(onFlag) { + v, _ := cmd.Flags().GetBool(onFlag) + return BoolFlag{Set: true, Value: v} + } + if cmd.Flags().Changed(offFlag) { + v, _ := cmd.Flags().GetBool(offFlag) + return BoolFlag{Set: true, Value: !v} + } + return BoolFlag{} + } + svc := client.Auth.Connections c := AuthConnectionCmd{svc: &svc} return c.Update(cmd.Context(), AuthConnectionUpdateInput{ @@ -1219,6 +1298,9 @@ func runAuthConnectionsUpdate(cmd *cobra.Command, args []string) error { SaveCredentials: saveCredentialsFlag, HealthCheckInterval: healthCheckInterval, HealthCheckIntervalSet: cmd.Flags().Changed("health-check-interval"), + HealthChecks: togglePair("health-checks", "no-health-checks"), + AutoReauth: togglePair("auto-reauth", "no-auto-reauth"), + RecordSession: togglePair("record-session", "no-record-session"), Telemetry: telemetry, Output: output, }) @@ -1262,14 +1344,21 @@ func runAuthConnectionsLogin(cmd *cobra.Command, args []string) error { proxyName, _ := cmd.Flags().GetString("proxy-name") telemetry, _ := cmd.Flags().GetString("telemetry") + var recordSessionFlag BoolFlag + if cmd.Flags().Changed("record-session") || cmd.Flags().Changed("no-record-session") { + noRecordSession, _ := cmd.Flags().GetBool("no-record-session") + recordSessionFlag = BoolFlag{Set: true, Value: !noRecordSession} + } + svc := client.Auth.Connections c := AuthConnectionCmd{svc: &svc} return c.Login(cmd.Context(), AuthConnectionLoginInput{ - ID: args[0], - ProxyID: proxyID, - ProxyName: proxyName, - Telemetry: telemetry, - Output: output, + ID: args[0], + ProxyID: proxyID, + ProxyName: proxyName, + RecordSession: recordSessionFlag, + Telemetry: telemetry, + Output: output, }) } diff --git a/cmd/auth_connections_test.go b/cmd/auth_connections_test.go index edd392e..4b9bca9 100644 --- a/cmd/auth_connections_test.go +++ b/cmd/auth_connections_test.go @@ -690,13 +690,23 @@ func TestSubmit_CanonicalAndLegacyAreMutuallyExclusive(t *testing.T) { func TestTimeline_RendersEventsAndPagination(t *testing.T) { buf := capturePtermOutput(t) var captured kernel.AuthConnectionTimelineParams + // Decoded from JSON so the telemetry_captured field registers as present; + // the CLI distinguishes "reported false" from "not reported at all". + var loginEvent kernel.ManagedAuthTimelineEvent + require.NoError(t, json.Unmarshal([]byte(`{ + "id": "e1", + "type": "login", + "status": "SUCCESS", + "browser_session_id": "browser_1", + "telemetry_captured": true + }`), &loginEvent)) fake := &FakeAuthConnectionService{ TimelineFunc: func(ctx context.Context, id string, query kernel.AuthConnectionTimelineParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.ManagedAuthTimelineEvent], error) { captured = query // Return perPage+1 events so the CLI reports another page exists. return &pagination.OffsetPagination[kernel.ManagedAuthTimelineEvent]{ Items: []kernel.ManagedAuthTimelineEvent{ - {ID: "e1", Type: "login", Status: "SUCCESS", BrowserSessionID: "browser_1"}, + loginEvent, {ID: "e2", Type: "reauth", Status: "FAILED", ErrorMessage: "boom"}, {ID: "e3", Type: "health_check", Status: "SUCCESS"}, }, @@ -714,6 +724,9 @@ func TestTimeline_RendersEventsAndPagination(t *testing.T) { out := buf.String() assert.Contains(t, out, "browser_1") assert.Contains(t, out, "boom") + // Telemetry capture is reported for events that have a browser session. + assert.Contains(t, out, "Telemetry") + assert.Regexp(t, `browser_1.*yes`, out) // The third event is truncated off the page. assert.NotContains(t, out, "health_check") assert.Contains(t, out, "Has more: yes") diff --git a/cmd/browsers.go b/cmd/browsers.go index 88f520c..0561184 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -1335,6 +1335,7 @@ type BrowsersReplaysStartInput struct { Identifier string Framerate int MaxDurationSeconds int + RecordAudio bool Output string } @@ -1395,6 +1396,9 @@ func (b BrowsersCmd) ReplaysStart(ctx context.Context, in BrowsersReplaysStartIn if in.MaxDurationSeconds > 0 { body.MaxDurationInSeconds = kernel.Opt(int64(in.MaxDurationSeconds)) } + if in.RecordAudio { + body.RecordAudio = kernel.Opt(true) + } res, err := b.replays.Start(ctx, br.SessionID, body) if err != nil { return util.CleanedUpSdkError{Err: err} @@ -2536,6 +2540,7 @@ func init() { replaysStart := &cobra.Command{Use: "start ", Short: "Start a replay recording", Args: cobra.ExactArgs(1), RunE: runBrowsersReplaysStart} replaysStart.Flags().Int("framerate", 0, "Recording framerate (fps)") replaysStart.Flags().Int("max-duration", 0, "Maximum duration in seconds") + replaysStart.Flags().Bool("record-audio", false, "Record audio in addition to video (default is video-only)") addJSONOutputFlag(replaysStart) replaysStop := &cobra.Command{Use: "stop ", Short: "Stop a replay recording", Args: cobra.ExactArgs(2), RunE: runBrowsersReplaysStop} replaysDownload := &cobra.Command{Use: "download ", Short: "Download a replay video", Args: cobra.ExactArgs(2), RunE: runBrowsersReplaysDownload} @@ -3141,9 +3146,10 @@ func runBrowsersReplaysStart(cmd *cobra.Command, args []string) error { svc := client.Browsers fr, _ := cmd.Flags().GetInt("framerate") md, _ := cmd.Flags().GetInt("max-duration") + recordAudio, _ := cmd.Flags().GetBool("record-audio") output, _ := cmd.Flags().GetString("output") b := BrowsersCmd{browsers: &svc, replays: &svc.Replays} - return b.ReplaysStart(cmd.Context(), BrowsersReplaysStartInput{Identifier: args[0], Framerate: fr, MaxDurationSeconds: md, Output: output}) + return b.ReplaysStart(cmd.Context(), BrowsersReplaysStartInput{Identifier: args[0], Framerate: fr, MaxDurationSeconds: md, RecordAudio: recordAudio, Output: output}) } func runBrowsersReplaysStop(cmd *cobra.Command, args []string) error { diff --git a/cmd/credentials.go b/cmd/credentials.go index 8a58370..56fcbdc 100644 --- a/cmd/credentials.go +++ b/cmd/credentials.go @@ -52,12 +52,13 @@ type CredentialsCreateInput struct { } type CredentialsUpdateInput struct { - Identifier string - Name string - SSOProvider string - TotpSecret string - Values map[string]string - Output string + Identifier string + Name string + SSOProvider string + TotpSecret string + Values map[string]string + RemoveValueKeys []string + Output string } type CredentialsDeleteInput struct { @@ -263,6 +264,9 @@ func (c CredentialsCmd) Update(ctx context.Context, in CredentialsUpdateInput) e if len(in.Values) > 0 { params.UpdateCredentialRequest.Values = in.Values } + if len(in.RemoveValueKeys) > 0 { + params.UpdateCredentialRequest.RemoveValueKeys = in.RemoveValueKeys + } if in.Output != "json" { pterm.Info.Printf("Updating credential '%s'...\n", in.Identifier) @@ -428,6 +432,7 @@ func init() { credentialsUpdateCmd.Flags().String("sso-provider", "", "SSO provider (set to empty string to remove)") credentialsUpdateCmd.Flags().String("totp-secret", "", "Base32-encoded TOTP secret (set to empty string to remove)") credentialsUpdateCmd.Flags().StringArray("value", []string{}, "Field name=value pair to update (repeatable)") + credentialsUpdateCmd.Flags().StringArray("remove-value-key", []string{}, "Field name to remove from the credential's stored values (repeatable). Removals are applied before --value is merged, so a key given to both keeps its new value") // Delete flags credentialsDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") @@ -503,6 +508,7 @@ func runCredentialsUpdate(cmd *cobra.Command, args []string) error { ssoProvider, _ := cmd.Flags().GetString("sso-provider") totpSecret, _ := cmd.Flags().GetString("totp-secret") valuePairs, _ := cmd.Flags().GetStringArray("value") + removeValueKeys, _ := cmd.Flags().GetStringArray("remove-value-key") // Parse value pairs into map values := make(map[string]string) @@ -517,12 +523,13 @@ func runCredentialsUpdate(cmd *cobra.Command, args []string) error { svc := client.Credentials c := CredentialsCmd{credentials: &svc} return c.Update(cmd.Context(), CredentialsUpdateInput{ - Identifier: args[0], - Name: name, - SSOProvider: ssoProvider, - TotpSecret: totpSecret, - Values: values, - Output: output, + Identifier: args[0], + Name: name, + SSOProvider: ssoProvider, + TotpSecret: totpSecret, + Values: values, + RemoveValueKeys: removeValueKeys, + Output: output, }) } diff --git a/cmd/proxies/check.go b/cmd/proxies/check.go index 6c6657e..7381446 100644 --- a/cmd/proxies/check.go +++ b/cmd/proxies/check.go @@ -148,6 +148,11 @@ func getProxyCheckConfigRows(proxy *kernel.ProxyCheckResponse) [][]string { hasPassword = "Yes" } rows = append(rows, []string{"Has Password", hasPassword}) + hasCaBundle := "No" + if config.HasCaBundle { + hasCaBundle = "Yes" + } + rows = append(rows, []string{"Has CA Bundle", hasCaBundle}) } return rows diff --git a/cmd/proxies/create.go b/cmd/proxies/create.go index cd5011c..68e4675 100644 --- a/cmd/proxies/create.go +++ b/cmd/proxies/create.go @@ -159,6 +159,10 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { } } + if proxyType != kernel.ProxyNewParamsTypeCustom && in.CaBundleFile != "" { + pterm.Warning.Println("--ca-bundle is only supported for custom proxies and will be ignored") + } + // Set protocol (defaults to https if not specified) if in.Protocol != "" { // Validate and convert protocol @@ -206,6 +210,11 @@ func (p ProxyCmd) Create(ctx context.Context, in ProxyCreateInput) error { } rows = append(rows, []string{"Protocol", protocol}) + // The CA bundle is write-only, so confirm the API stored it. + if proxy.Config.HasCaBundle { + rows = append(rows, []string{"Has CA Bundle", "Yes"}) + } + table.PrintTableNoPad(rows, true) return nil } diff --git a/cmd/proxies/get.go b/cmd/proxies/get.go index c0fc5c2..e5a7aa8 100644 --- a/cmd/proxies/get.go +++ b/cmd/proxies/get.go @@ -126,6 +126,11 @@ func getProxyConfigRows(proxy *kernel.ProxyGetResponse) [][]string { hasPassword = "Yes" } rows = append(rows, []string{"Has Password", hasPassword}) + hasCaBundle := "No" + if config.HasCaBundle { + hasCaBundle = "Yes" + } + rows = append(rows, []string{"Has CA Bundle", hasCaBundle}) } return rows diff --git a/cmd/proxies/list.go b/cmd/proxies/list.go index a7ff4bc..83a8f74 100644 --- a/cmd/proxies/list.go +++ b/cmd/proxies/list.go @@ -141,7 +141,11 @@ func formatProxyConfig(proxy *kernel.ProxyListResponse) string { if config.Username != "" { auth = fmt.Sprintf(", Auth: %s", config.Username) } - return fmt.Sprintf("%s:%d%s", config.Host, config.Port, auth) + caBundle := "" + if config.HasCaBundle { + caBundle = ", CA bundle" + } + return fmt.Sprintf("%s:%d%s%s", config.Host, config.Port, auth, caBundle) } } return "-"