From fb36e8578ce1abcb877fa9016621dbffae5c4d97 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Tue, 27 Oct 2026 13:42:43 +0000 Subject: [PATCH 01/18] Reject existing profile names after "Create a new profile" After "Create a new profile", the profile name prompt suggests DEFAULT, and pressing Enter logged in to an existing DEFAULT profile and reused its host. For device code login that profile may be an account profile the picker hid, which then failed with an error about a profile the user did not pick. The name prompt now rejects the names of all existing profiles in this case. Co-authored-by: Isaac --- cmd/auth/login.go | 28 ++++++++++++++++++++++++---- cmd/auth/login_device_code_test.go | 21 +++++++++++++++++++++ cmd/auth/login_test.go | 16 ++++++++++++++++ cmd/auth/token.go | 2 +- 4 files changed, 62 insertions(+), 5 deletions(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 5c9f322d53d..cae0f7ddf65 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "runtime" + "slices" "strconv" "strings" "time" @@ -26,13 +27,24 @@ import ( "golang.org/x/oauth2" ) -func promptForProfile(ctx context.Context, defaultValue string) (string, error) { +// promptForProfile prompts for a profile name, rejecting the names in +// takenNames. +func promptForProfile(ctx context.Context, defaultValue string, takenNames []string) (string, error) { if !cmdio.IsPromptSupported(ctx) { return "", nil } result, err := cmdio.RunPrompt(ctx, cmdio.PromptOptions{ Label: "Databricks profile name [" + defaultValue + "]", + Validate: func(input string) error { + if input == "" { + input = defaultValue + } + if slices.Contains(takenNames, input) { + return fmt.Errorf("profile %s already exists. Select it from the list to log in to it again", input) + } + return nil + }, }) if result == "" { return defaultValue, err @@ -227,7 +239,8 @@ a new profile is created. // two action entries so the user can choose between web-based discovery // (Create a new profile) and a manual host URL. Device code login skips // discovery, so its "Create a new profile" asks for the host after the - // profile name, unless a profile with that name already has a host. + // profile name. + var takenProfileNames []string if profileName == "" && authArguments.Host == "" && len(args) == 0 && cmdio.IsPromptSupported(ctx) { matchProfiles := profile.MatchAllProfiles if useDeviceCode { @@ -265,7 +278,14 @@ a new profile is created. } authArguments.Host = host case profilePickerCreateNew: - // Fall through to the profile name prompt below. + // Reject the names of existing profiles, including those the + // picker hid, so that the profile name prompt below cannot + // log in to an existing profile and reuse its host. + existingProfiles, err := profile.DefaultProfiler.LoadProfiles(ctx, profile.MatchAllProfiles) + if err != nil && !errors.Is(err, profile.ErrNoConfiguration) { + return err + } + takenProfileNames = existingProfiles.Names() } } @@ -293,7 +313,7 @@ a new profile is created. if profileName == "" { profileName = "DEFAULT" } - profileName, err = promptForProfile(ctx, profileName) + profileName, err = promptForProfile(ctx, profileName, takenProfileNames) if err != nil { return err } diff --git a/cmd/auth/login_device_code_test.go b/cmd/auth/login_device_code_test.go index d38d2543b7c..b5aaf026365 100644 --- a/cmd/auth/login_device_code_test.go +++ b/cmd/auth/login_device_code_test.go @@ -253,6 +253,27 @@ func TestLoginDeviceCodePickerEmptyHost(t *testing.T) { assert.ErrorIs(t, err, errDeviceCodeNeedsHost) } +func TestLoginDeviceCodeCreateNewProfileRejectsHiddenProfileName(t *testing.T) { + // See TestLoginDeviceCodePickerEmptyHost. + if runtime.GOOS == "windows" { + t.Skip("bubbletea cannot cancel reads on non-console input on Windows") + } + tm := startDeviceCodeLogin(t, `[DEFAULT] +host = https://accounts.cloud.databricks.test +account_id = acct-123 +`) + tm.WaitFor(profilePickerCreateNewLabel) + tm.Type(termtest.KeyEnter) + tm.WaitFor("Databricks profile name [DEFAULT]") + tm.Type(termtest.KeyEnter) + tm.WaitFor(">> profile DEFAULT already exists") + tm.Type("newprof" + termtest.KeyEnter) + tm.WaitFor("Databricks host") + tm.Type(termtest.KeyCtrlC) + _, err := tm.Result() + assert.EqualError(t, err, "^C") +} + func TestMatchDeviceCodeProfiles(t *testing.T) { tests := []struct { name string diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index 026a002a158..a2b735a1d05 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -18,6 +18,7 @@ import ( "github.com/databricks/cli/libs/auth/storage" "github.com/databricks/cli/libs/auth/u2m" "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/cmdio/cmdiotest/termtest" "github.com/databricks/cli/libs/databrickscfg/profile" "github.com/databricks/cli/libs/env" "github.com/databricks/cli/libs/log" @@ -1459,3 +1460,18 @@ func TestLoginRejectsPositionalArgWithProfileFlag(t *testing.T) { err := cmd.Execute() assert.ErrorContains(t, err, `argument "https://example.com" cannot be combined with --host or --profile`) } + +func TestPromptForProfileRejectsTakenName(t *testing.T) { + tm := termtest.Run(t, func(ctx context.Context) (string, error) { + return promptForProfile(ctx, "DEFAULT", []string{"DEFAULT", "taken"}) + }) + tm.WaitFor("Databricks profile name [DEFAULT]") + tm.Type(termtest.KeyEnter) + tm.WaitFor(">> profile DEFAULT already exists") + tm.Type("taken" + termtest.KeyEnter) + tm.WaitFor(">> profile taken already exists") + tm.Type(termtest.KeyBackspace + termtest.KeyEnter) + name, err := tm.Result() + require.NoError(t, err) + assert.Equal(t, "take", name) +} diff --git a/cmd/auth/token.go b/cmd/auth/token.go index e28d71083a0..fb0c9efd9de 100644 --- a/cmd/auth/token.go +++ b/cmd/auth/token.go @@ -424,7 +424,7 @@ func resolveNoArgsToken(ctx context.Context, profiler profile.Profiler, authArgs // name and host, performs the OAuth challenge, saves the profile to // .databrickscfg, and returns the new profile name and profile. func runInlineLogin(ctx context.Context, profiler profile.Profiler, tokenStore storage.Store, mode storage.StorageMode) (string, *profile.Profile, error) { - profileName, err := promptForProfile(ctx, "DEFAULT") + profileName, err := promptForProfile(ctx, "DEFAULT", nil) if err != nil { return "", nil, err } From 3786c966b9941ddb5934a651bb1e88edaefefe62 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Tue, 27 Oct 2026 14:16:04 +0000 Subject: [PATCH 02/18] Add a changelog fragment for the new profile name fix Co-authored-by: Isaac --- .nextchanges/cli/login-new-profile-existing-name.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 .nextchanges/cli/login-new-profile-existing-name.md diff --git a/.nextchanges/cli/login-new-profile-existing-name.md b/.nextchanges/cli/login-new-profile-existing-name.md new file mode 100644 index 00000000000..fe7c19ed9e1 --- /dev/null +++ b/.nextchanges/cli/login-new-profile-existing-name.md @@ -0,0 +1 @@ +* `databricks auth login` now rejects the name of an existing profile after "Create a new profile" instead of logging in to that profile. ([#7196](https://github.com/databricks/cli/pull/7196)) From c911d081f70a19facdd8afca9db53b472b493c6d Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Tue, 27 Oct 2026 14:26:03 +0000 Subject: [PATCH 03/18] Do not suggest a taken profile name after "Create a new profile" The name prompt suggested DEFAULT even when a DEFAULT profile already existed, and then rejected it on Enter. A taken suggestion is now left out, so the prompt asks for a name without a default and rejects an empty answer. Co-authored-by: Isaac --- cmd/auth/login.go | 13 +++++++++++-- cmd/auth/login_device_code_test.go | 4 ++-- cmd/auth/login_test.go | 16 ++++++++++++++-- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index cae0f7ddf65..8ea6b547270 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -28,18 +28,27 @@ import ( ) // promptForProfile prompts for a profile name, rejecting the names in -// takenNames. +// takenNames. A taken defaultValue is not suggested. func promptForProfile(ctx context.Context, defaultValue string, takenNames []string) (string, error) { if !cmdio.IsPromptSupported(ctx) { return "", nil } + label := "Databricks profile name" + if slices.Contains(takenNames, defaultValue) { + defaultValue = "" + } else { + label += " [" + defaultValue + "]" + } result, err := cmdio.RunPrompt(ctx, cmdio.PromptOptions{ - Label: "Databricks profile name [" + defaultValue + "]", + Label: label, Validate: func(input string) error { if input == "" { input = defaultValue } + if input == "" { + return errors.New("profile name is required") + } if slices.Contains(takenNames, input) { return fmt.Errorf("profile %s already exists. Select it from the list to log in to it again", input) } diff --git a/cmd/auth/login_device_code_test.go b/cmd/auth/login_device_code_test.go index b5aaf026365..dd466996e5f 100644 --- a/cmd/auth/login_device_code_test.go +++ b/cmd/auth/login_device_code_test.go @@ -264,9 +264,9 @@ account_id = acct-123 `) tm.WaitFor(profilePickerCreateNewLabel) tm.Type(termtest.KeyEnter) - tm.WaitFor("Databricks profile name [DEFAULT]") + tm.WaitFor("Databricks profile name") tm.Type(termtest.KeyEnter) - tm.WaitFor(">> profile DEFAULT already exists") + tm.WaitFor(">> profile name is required") tm.Type("newprof" + termtest.KeyEnter) tm.WaitFor("Databricks host") tm.Type(termtest.KeyCtrlC) diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index a2b735a1d05..b54e9e88406 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -1465,9 +1465,10 @@ func TestPromptForProfileRejectsTakenName(t *testing.T) { tm := termtest.Run(t, func(ctx context.Context) (string, error) { return promptForProfile(ctx, "DEFAULT", []string{"DEFAULT", "taken"}) }) - tm.WaitFor("Databricks profile name [DEFAULT]") + screen := tm.WaitFor("Databricks profile name") + assert.NotContains(t, screen, "[DEFAULT]") tm.Type(termtest.KeyEnter) - tm.WaitFor(">> profile DEFAULT already exists") + tm.WaitFor(">> profile name is required") tm.Type("taken" + termtest.KeyEnter) tm.WaitFor(">> profile taken already exists") tm.Type(termtest.KeyBackspace + termtest.KeyEnter) @@ -1475,3 +1476,14 @@ func TestPromptForProfileRejectsTakenName(t *testing.T) { require.NoError(t, err) assert.Equal(t, "take", name) } + +func TestPromptForProfileSuggestsFreeDefault(t *testing.T) { + tm := termtest.Run(t, func(ctx context.Context) (string, error) { + return promptForProfile(ctx, "DEFAULT", []string{"taken"}) + }) + tm.WaitFor("Databricks profile name [DEFAULT]") + tm.Type(termtest.KeyEnter) + name, err := tm.Result() + require.NoError(t, err) + assert.Equal(t, "DEFAULT", name) +} From 32ea824f7147ba9c877d926a87ed916e8fc1cf38 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Tue, 27 Oct 2026 14:49:02 +0000 Subject: [PATCH 04/18] Reject existing profile names after "Create a new profile" in auth token auth token offers the same "Create a new profile" picker entry, and its name prompt could still log in to an existing profile. It now rejects existing profile names too. The error no longer tells the user to select the profile from the list, because device code login hides some profiles from the picker. auth login now loads the profiles once and filters the picker list from them. Co-authored-by: Isaac --- cmd/auth/login.go | 24 +++++++++++------------- cmd/auth/token.go | 9 +++++---- cmd/auth/token_test.go | 24 ++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 17 deletions(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 8ea6b547270..5d99155ea96 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -50,7 +50,7 @@ func promptForProfile(ctx context.Context, defaultValue string, takenNames []str return errors.New("profile name is required") } if slices.Contains(takenNames, input) { - return fmt.Errorf("profile %s already exists. Select it from the list to log in to it again", input) + return fmt.Errorf("profile %s already exists. Choose a different name", input) } return nil }, @@ -251,20 +251,22 @@ a new profile is created. // profile name. var takenProfileNames []string if profileName == "" && authArguments.Host == "" && len(args) == 0 && cmdio.IsPromptSupported(ctx) { - matchProfiles := profile.MatchAllProfiles - if useDeviceCode { - matchProfiles = matchDeviceCodeProfiles - } - allProfiles, err := profile.DefaultProfiler.LoadProfiles(ctx, matchProfiles) + allProfiles, err := profile.DefaultProfiler.LoadProfiles(ctx, profile.MatchAllProfiles) if err != nil && !errors.Is(err, profile.ErrNoConfiguration) { return err } + pickerProfiles := allProfiles + if useDeviceCode { + pickerProfiles = slices.DeleteFunc(slices.Clone(allProfiles), func(p profile.Profile) bool { + return !matchDeviceCodeProfiles(p) + }) + } label := "Select a profile" - if len(allProfiles) == 0 { + if len(pickerProfiles) == 0 { label = "How would you like to log in?" } currentDefault, _ := databrickscfg.GetDefaultProfile(ctx, env.Get(ctx, "DATABRICKS_CONFIG_FILE")) - result, selected, err := pickAuthProfile(ctx, allProfiles, profilePickerOptions{ + result, selected, err := pickAuthProfile(ctx, pickerProfiles, profilePickerOptions{ Label: label, Default: currentDefault, IncludeExtras: true, @@ -290,11 +292,7 @@ a new profile is created. // Reject the names of existing profiles, including those the // picker hid, so that the profile name prompt below cannot // log in to an existing profile and reuse its host. - existingProfiles, err := profile.DefaultProfiler.LoadProfiles(ctx, profile.MatchAllProfiles) - if err != nil && !errors.Is(err, profile.ErrNoConfiguration) { - return err - } - takenProfileNames = existingProfiles.Names() + takenProfileNames = allProfiles.Names() } } diff --git a/cmd/auth/token.go b/cmd/auth/token.go index fb0c9efd9de..fb3c9f3c651 100644 --- a/cmd/auth/token.go +++ b/cmd/auth/token.go @@ -410,7 +410,7 @@ func resolveNoArgsToken(ctx context.Context, profiler profile.Profiler, authArgs // Fall through — setHostAndAccountId will prompt for the host. return "", nil, nil case profilePickerCreateNew: - return runInlineLogin(ctx, profiler, tokenStore, mode) + return runInlineLogin(ctx, profiler, allProfiles.Names(), tokenStore, mode) default: p, err := loadProfileByName(ctx, selectedName, profiler) if err != nil { @@ -422,9 +422,10 @@ func resolveNoArgsToken(ctx context.Context, profiler profile.Profiler, authArgs // runInlineLogin runs a minimal interactive login flow: prompts for a profile // name and host, performs the OAuth challenge, saves the profile to -// .databrickscfg, and returns the new profile name and profile. -func runInlineLogin(ctx context.Context, profiler profile.Profiler, tokenStore storage.Store, mode storage.StorageMode) (string, *profile.Profile, error) { - profileName, err := promptForProfile(ctx, "DEFAULT", nil) +// .databrickscfg, and returns the new profile name and profile. It rejects +// the names in takenNames so that it cannot log in to an existing profile. +func runInlineLogin(ctx context.Context, profiler profile.Profiler, takenNames []string, tokenStore storage.Store, mode storage.StorageMode) (string, *profile.Profile, error) { + profileName, err := promptForProfile(ctx, "DEFAULT", takenNames) if err != nil { return "", nil, err } diff --git a/cmd/auth/token_test.go b/cmd/auth/token_test.go index e37e7cdc01b..4b1c841ba8d 100644 --- a/cmd/auth/token_test.go +++ b/cmd/auth/token_test.go @@ -7,6 +7,7 @@ import ( "errors" "net/http" "net/url" + "runtime" "testing" "time" @@ -14,6 +15,7 @@ import ( "github.com/databricks/cli/libs/auth/storage" "github.com/databricks/cli/libs/auth/u2m" "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/cmdio/cmdiotest/termtest" "github.com/databricks/cli/libs/databrickscfg/profile" "github.com/databricks/cli/libs/env" "github.com/databricks/databricks-sdk-go/httpclient/fixtures" @@ -965,3 +967,25 @@ func TestWriteTokenErrorOutput(t *testing.T) { assert.Equal(t, unauthenticatedErrorCode, got.ErrorCode) assert.Equal(t, "refresh token is invalid", got.Message) } + +func TestResolveNoArgsTokenCreateNewProfileRejectsTakenName(t *testing.T) { + // See TestLoginDeviceCodePickerEmptyHost. + if runtime.GOOS == "windows" { + t.Skip("bubbletea cannot cancel reads on non-console input on Windows") + } + t.Setenv("DATABRICKS_CONFIG_FILE", writeTempConfig(t, `[prod] +host = https://prod.cloud.databricks.test +`)) + tm := termtest.Run(t, func(ctx context.Context) (string, error) { + name, _, err := resolveNoArgsToken(ctx, profile.DefaultProfiler, &auth.AuthArguments{}, nil, storage.StorageModePlaintext) + return name, err + }) + tm.WaitFor(profilePickerCreateNewLabel) + tm.Type(termtest.KeyDown + termtest.KeyEnter) + tm.WaitFor("Databricks profile name [DEFAULT]") + tm.Type("prod" + termtest.KeyEnter) + tm.WaitFor(">> profile prod already exists") + tm.Type(termtest.KeyCtrlC) + _, err := tm.Result() + assert.EqualError(t, err, "^C") +} From 3cf9d95dbe928d150dbe64cce1db7c0a1e616b2a Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Tue, 27 Oct 2026 14:51:32 +0000 Subject: [PATCH 05/18] Mention auth token in the changelog fragment Co-authored-by: Isaac --- .nextchanges/cli/login-new-profile-existing-name.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.nextchanges/cli/login-new-profile-existing-name.md b/.nextchanges/cli/login-new-profile-existing-name.md index fe7c19ed9e1..9575a716810 100644 --- a/.nextchanges/cli/login-new-profile-existing-name.md +++ b/.nextchanges/cli/login-new-profile-existing-name.md @@ -1 +1 @@ -* `databricks auth login` now rejects the name of an existing profile after "Create a new profile" instead of logging in to that profile. ([#7196](https://github.com/databricks/cli/pull/7196)) +* `databricks auth login` and `databricks auth token` now reject the name of an existing profile after "Create a new profile" instead of logging in to that profile. ([#7196](https://github.com/databricks/cli/pull/7196)) From b8b03a277cde788f34728427432451d606d2f705 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Tue, 27 Oct 2026 18:06:54 +0000 Subject: [PATCH 06/18] Trim spaces around the profile name before checking it The prompt validated the raw buffer, but RunPrompt trims the trailing newline only after validation. A pasted "taken\n" passed the taken-name check, and a pasted blank line or spaces passed the required check. Co-authored-by: Isaac --- cmd/auth/login.go | 2 ++ cmd/auth/login_test.go | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 5d99155ea96..c69bc58fe7b 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -43,6 +43,7 @@ func promptForProfile(ctx context.Context, defaultValue string, takenNames []str result, err := cmdio.RunPrompt(ctx, cmdio.PromptOptions{ Label: label, Validate: func(input string) error { + input = strings.TrimSpace(input) if input == "" { input = defaultValue } @@ -55,6 +56,7 @@ func promptForProfile(ctx context.Context, defaultValue string, takenNames []str return nil }, }) + result = strings.TrimSpace(result) if result == "" { return defaultValue, err } diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index b54e9e88406..14a3d374f80 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -10,6 +10,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "sync" "testing" "time" @@ -1487,3 +1488,20 @@ func TestPromptForProfileSuggestsFreeDefault(t *testing.T) { require.NoError(t, err) assert.Equal(t, "DEFAULT", name) } + +func TestPromptForProfileTrimsPastedName(t *testing.T) { + tm := termtest.Run(t, func(ctx context.Context) (string, error) { + return promptForProfile(ctx, "DEFAULT", []string{"DEFAULT", "taken"}) + }) + tm.WaitFor("Databricks profile name") + // A bracketed paste arrives as a single input event, so the trailing + // newline lands in the buffer instead of submitting the prompt. + tm.Type("\x1b[200~taken\n\x1b[201~" + termtest.KeyEnter) + tm.WaitFor(">> profile taken already exists") + tm.Type(strings.Repeat(termtest.KeyBackspace, len("taken\n")) + " " + termtest.KeyEnter) + tm.WaitFor(">> profile name is required") + tm.Type(" new " + termtest.KeyEnter) + name, err := tm.Result() + require.NoError(t, err) + assert.Equal(t, "new", name) +} From 9e54ff09b32d73c1f947eb085eeda6b353f484a4 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Tue, 27 Oct 2026 18:08:07 +0000 Subject: [PATCH 07/18] Reject the reserved __settings__ name at the profile name prompt SaveToProfile rejects it, but only after the user has gone through the OAuth flow. Co-authored-by: Isaac --- cmd/auth/login.go | 4 ++++ cmd/auth/login_test.go | 4 +++- libs/databrickscfg/ops.go | 17 +++++++++++++---- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index c69bc58fe7b..54dd96dfccc 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -50,6 +50,10 @@ func promptForProfile(ctx context.Context, defaultValue string, takenNames []str if input == "" { return errors.New("profile name is required") } + // Fail here rather than after the OAuth flow, in SaveToProfile. + if err := databrickscfg.ValidateProfileName(input); err != nil { + return err + } if slices.Contains(takenNames, input) { return fmt.Errorf("profile %s already exists. Choose a different name", input) } diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index 14a3d374f80..90cd0efe4f3 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -1472,7 +1472,9 @@ func TestPromptForProfileRejectsTakenName(t *testing.T) { tm.WaitFor(">> profile name is required") tm.Type("taken" + termtest.KeyEnter) tm.WaitFor(">> profile taken already exists") - tm.Type(termtest.KeyBackspace + termtest.KeyEnter) + tm.Type(strings.Repeat(termtest.KeyBackspace, len("taken")) + "__settings__" + termtest.KeyEnter) + tm.WaitFor(`>> profile name "__settings__" is reserved for internal use`) + tm.Type(strings.Repeat(termtest.KeyBackspace, len("__settings__")) + "take" + termtest.KeyEnter) name, err := tm.Result() require.NoError(t, err) assert.Equal(t, "take", name) diff --git a/libs/databrickscfg/ops.go b/libs/databrickscfg/ops.go index 6358c5ba49d..ea04412691d 100644 --- a/libs/databrickscfg/ops.go +++ b/libs/databrickscfg/ops.go @@ -210,11 +210,20 @@ func isFirstProfileInFile(configFile *config.File) bool { return true } -// SetDefaultProfile writes the default_profile key to the [__settings__] section. -func SetDefaultProfile(ctx context.Context, profileName, configFilePath string) error { +// ValidateProfileName returns an error if profileName is reserved for the +// [__settings__] section. +func ValidateProfileName(profileName string) error { if profileName == databricksSettingsSection { return fmt.Errorf("profile name %q is reserved for internal use", databricksSettingsSection) } + return nil +} + +// SetDefaultProfile writes the default_profile key to the [__settings__] section. +func SetDefaultProfile(ctx context.Context, profileName, configFilePath string) error { + if err := ValidateProfileName(profileName); err != nil { + return err + } configFile, err := loadOrCreateConfigFile(ctx, configFilePath) if err != nil { @@ -428,8 +437,8 @@ func backupConfigFile(ctx context.Context, configFile *config.File) error { // removed (use this for mutually exclusive fields like cluster_id vs // serverless_compute_id, or to drop stale auth credentials on auth-type switch). func SaveToProfile(ctx context.Context, cfg *config.Config, clearKeys ...string) error { - if cfg.Profile == databricksSettingsSection { - return fmt.Errorf("profile name %q is reserved for internal use", databricksSettingsSection) + if err := ValidateProfileName(cfg.Profile); err != nil { + return err } configFile, err := loadOrCreateConfigFile(ctx, cfg.ConfigFile) From 11079212d916c7a7c948f5afbccdafe95dda81fa Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Tue, 27 Oct 2026 18:10:51 +0000 Subject: [PATCH 08/18] Treat sections without a host as taken profile names LoadProfiles skips sections without a host, so a name like [foo] with only cluster_id was accepted as a new profile, and SaveToProfile merged the login into that section, keeping its old keys. Read the taken names from the config file sections instead. Co-authored-by: Isaac --- cmd/auth/login.go | 10 +++++++--- cmd/auth/token.go | 11 ++++++++--- cmd/auth/token_test.go | 6 ++++++ libs/databrickscfg/ops.go | 21 +++++++++++++++++++++ libs/databrickscfg/ops_test.go | 26 ++++++++++++++++++++++++++ 5 files changed, 68 insertions(+), 6 deletions(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 54dd96dfccc..7707e57f0dd 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -296,9 +296,13 @@ a new profile is created. authArguments.Host = host case profilePickerCreateNew: // Reject the names of existing profiles, including those the - // picker hid, so that the profile name prompt below cannot - // log in to an existing profile and reuse its host. - takenProfileNames = allProfiles.Names() + // picker hid and sections without a host, so that the profile + // name prompt below cannot log in to an existing profile and + // reuse its host or keys. + takenProfileNames, err = databrickscfg.GetSectionNames(ctx, env.Get(ctx, "DATABRICKS_CONFIG_FILE")) + if err != nil { + return err + } } } diff --git a/cmd/auth/token.go b/cmd/auth/token.go index fb3c9f3c651..74e04bf9fad 100644 --- a/cmd/auth/token.go +++ b/cmd/auth/token.go @@ -410,7 +410,7 @@ func resolveNoArgsToken(ctx context.Context, profiler profile.Profiler, authArgs // Fall through — setHostAndAccountId will prompt for the host. return "", nil, nil case profilePickerCreateNew: - return runInlineLogin(ctx, profiler, allProfiles.Names(), tokenStore, mode) + return runInlineLogin(ctx, profiler, tokenStore, mode) default: p, err := loadProfileByName(ctx, selectedName, profiler) if err != nil { @@ -423,8 +423,13 @@ func resolveNoArgsToken(ctx context.Context, profiler profile.Profiler, authArgs // runInlineLogin runs a minimal interactive login flow: prompts for a profile // name and host, performs the OAuth challenge, saves the profile to // .databrickscfg, and returns the new profile name and profile. It rejects -// the names in takenNames so that it cannot log in to an existing profile. -func runInlineLogin(ctx context.Context, profiler profile.Profiler, takenNames []string, tokenStore storage.Store, mode storage.StorageMode) (string, *profile.Profile, error) { +// the names of existing sections so that it cannot log in to an existing +// profile. +func runInlineLogin(ctx context.Context, profiler profile.Profiler, tokenStore storage.Store, mode storage.StorageMode) (string, *profile.Profile, error) { + takenNames, err := databrickscfg.GetSectionNames(ctx, env.Get(ctx, "DATABRICKS_CONFIG_FILE")) + if err != nil { + return "", nil, err + } profileName, err := promptForProfile(ctx, "DEFAULT", takenNames) if err != nil { return "", nil, err diff --git a/cmd/auth/token_test.go b/cmd/auth/token_test.go index 4b1c841ba8d..6037bb188de 100644 --- a/cmd/auth/token_test.go +++ b/cmd/auth/token_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" "runtime" + "strings" "testing" "time" @@ -975,6 +976,9 @@ func TestResolveNoArgsTokenCreateNewProfileRejectsTakenName(t *testing.T) { } t.Setenv("DATABRICKS_CONFIG_FILE", writeTempConfig(t, `[prod] host = https://prod.cloud.databricks.test + +[hostless] +cluster_id = abc `)) tm := termtest.Run(t, func(ctx context.Context) (string, error) { name, _, err := resolveNoArgsToken(ctx, profile.DefaultProfiler, &auth.AuthArguments{}, nil, storage.StorageModePlaintext) @@ -985,6 +989,8 @@ host = https://prod.cloud.databricks.test tm.WaitFor("Databricks profile name [DEFAULT]") tm.Type("prod" + termtest.KeyEnter) tm.WaitFor(">> profile prod already exists") + tm.Type(strings.Repeat(termtest.KeyBackspace, len("prod")) + "hostless" + termtest.KeyEnter) + tm.WaitFor(">> profile hostless already exists") tm.Type(termtest.KeyCtrlC) _, err := tm.Result() assert.EqualError(t, err, "^C") diff --git a/libs/databrickscfg/ops.go b/libs/databrickscfg/ops.go index ea04412691d..826abe7bc20 100644 --- a/libs/databrickscfg/ops.go +++ b/libs/databrickscfg/ops.go @@ -123,6 +123,27 @@ func GetDefaultProfile(ctx context.Context, configFilePath string) (string, erro return GetDefaultProfileFrom(configFile), nil } +// GetSectionNames returns the names of the non-empty sections in the config +// file at configFilePath. Unlike LoadProfiles, it includes sections without a +// host. SaveToProfile merges into an existing section with the same name, so +// none of these names is free for a new profile. Returns nil if the file +// doesn't exist. +func GetSectionNames(ctx context.Context, configFilePath string) ([]string, error) { + configFile, err := loadConfigFile(ctx, configFilePath) + if err != nil || configFile == nil { + return nil, err + } + var names []string + for _, s := range configFile.Sections() { + // go-ini always has a DEFAULT section, even if the file does not. + if len(s.Keys()) == 0 { + continue + } + names = append(names, s.Name()) + } + return names, nil +} + // loadConfigFile loads a config file without creating it if it doesn't exist. // Returns (nil, nil) when the file is not found. func loadConfigFile(ctx context.Context, filename string) (*config.File, error) { diff --git a/libs/databrickscfg/ops_test.go b/libs/databrickscfg/ops_test.go index 8fe1956a81f..d4c3d805bca 100644 --- a/libs/databrickscfg/ops_test.go +++ b/libs/databrickscfg/ops_test.go @@ -294,6 +294,32 @@ func TestGetDefaultProfile_NoFile(t *testing.T) { assert.NoFileExists(t, path) } +func TestGetSectionNames(t *testing.T) { + path := filepath.Join(t.TempDir(), "databrickscfg") + err := os.WriteFile(path, []byte(`[__settings__] +default_profile = ws + +[ws] +host = https://ws.cloud.databricks.test + +[hostless] +cluster_id = abc + +[empty] +`), 0o600) + require.NoError(t, err) + + got, err := GetSectionNames(t.Context(), path) + require.NoError(t, err) + assert.Equal(t, []string{"__settings__", "ws", "hostless"}, got) +} + +func TestGetSectionNames_NoFile(t *testing.T) { + got, err := GetSectionNames(t.Context(), filepath.Join(t.TempDir(), "databrickscfg")) + require.NoError(t, err) + assert.Nil(t, got) +} + func TestGetConfiguredDefaultProfile(t *testing.T) { testCases := []struct { name string From 69ec79731656fd90b581b8751f24c2deac77261b Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Tue, 27 Oct 2026 18:11:48 +0000 Subject: [PATCH 09/18] Load only the picker profiles in auth login The taken profile names now come from the config file sections, so login no longer needs all profiles to filter the picker list from. Co-authored-by: Isaac --- cmd/auth/login.go | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 7707e57f0dd..988098d892b 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -257,22 +257,20 @@ a new profile is created. // profile name. var takenProfileNames []string if profileName == "" && authArguments.Host == "" && len(args) == 0 && cmdio.IsPromptSupported(ctx) { - allProfiles, err := profile.DefaultProfiler.LoadProfiles(ctx, profile.MatchAllProfiles) + matchProfiles := profile.MatchAllProfiles + if useDeviceCode { + matchProfiles = matchDeviceCodeProfiles + } + allProfiles, err := profile.DefaultProfiler.LoadProfiles(ctx, matchProfiles) if err != nil && !errors.Is(err, profile.ErrNoConfiguration) { return err } - pickerProfiles := allProfiles - if useDeviceCode { - pickerProfiles = slices.DeleteFunc(slices.Clone(allProfiles), func(p profile.Profile) bool { - return !matchDeviceCodeProfiles(p) - }) - } label := "Select a profile" - if len(pickerProfiles) == 0 { + if len(allProfiles) == 0 { label = "How would you like to log in?" } currentDefault, _ := databrickscfg.GetDefaultProfile(ctx, env.Get(ctx, "DATABRICKS_CONFIG_FILE")) - result, selected, err := pickAuthProfile(ctx, pickerProfiles, profilePickerOptions{ + result, selected, err := pickAuthProfile(ctx, allProfiles, profilePickerOptions{ Label: label, Default: currentDefault, IncludeExtras: true, From 1ab0bd1519c113469ad709ba97286a57413dbe24 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Tue, 27 Oct 2026 18:12:10 +0000 Subject: [PATCH 10/18] Type the hidden profile name in the device code picker test The test only checked that an empty answer is rejected, so it would still pass if the hidden DEFAULT profile name were only dropped from the suggestion. Co-authored-by: Isaac --- cmd/auth/login_device_code_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cmd/auth/login_device_code_test.go b/cmd/auth/login_device_code_test.go index dd466996e5f..2a77cd0803c 100644 --- a/cmd/auth/login_device_code_test.go +++ b/cmd/auth/login_device_code_test.go @@ -267,7 +267,9 @@ account_id = acct-123 tm.WaitFor("Databricks profile name") tm.Type(termtest.KeyEnter) tm.WaitFor(">> profile name is required") - tm.Type("newprof" + termtest.KeyEnter) + tm.Type("DEFAULT" + termtest.KeyEnter) + tm.WaitFor(">> profile DEFAULT already exists") + tm.Type(strings.Repeat(termtest.KeyBackspace, len("DEFAULT")) + "newprof" + termtest.KeyEnter) tm.WaitFor("Databricks host") tm.Type(termtest.KeyCtrlC) _, err := tm.Result() From 907f2795b2afa20a7ac0835d2820383142566715 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Tue, 27 Oct 2026 18:12:30 +0000 Subject: [PATCH 11/18] Clear DATABRICKS_HOST and DATABRICKS_CONFIG_PROFILE in the auth token picker test env.Get falls back to the OS environment, so with either one exported resolveNoArgsToken returned before the picker and the test timed out. Co-authored-by: Isaac --- cmd/auth/token_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmd/auth/token_test.go b/cmd/auth/token_test.go index 6037bb188de..001aa527034 100644 --- a/cmd/auth/token_test.go +++ b/cmd/auth/token_test.go @@ -974,6 +974,9 @@ func TestResolveNoArgsTokenCreateNewProfileRejectsTakenName(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("bubbletea cannot cancel reads on non-console input on Windows") } + // Either one would resolve the profile before the picker is shown. + t.Setenv("DATABRICKS_HOST", "") + t.Setenv("DATABRICKS_CONFIG_PROFILE", "") t.Setenv("DATABRICKS_CONFIG_FILE", writeTempConfig(t, `[prod] host = https://prod.cloud.databricks.test From 70810f8dbf8e54ceb5360ea7ef0ead57070eb0e9 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Tue, 27 Oct 2026 18:13:46 +0000 Subject: [PATCH 12/18] Drop the existing profile handling from the auth token inline login The profile name prompt rejects the names of all existing sections, so runInlineLogin always creates a new profile. Looking up the existing profile, preserving its scopes, resources and client ID, and clearing its keys could no longer do anything. Co-authored-by: Isaac --- cmd/auth/token.go | 40 ++-------------------------------------- 1 file changed, 2 insertions(+), 38 deletions(-) diff --git a/cmd/auth/token.go b/cmd/auth/token.go index 74e04bf9fad..ab46de2e483 100644 --- a/cmd/auth/token.go +++ b/cmd/auth/token.go @@ -435,31 +435,15 @@ func runInlineLogin(ctx context.Context, profiler profile.Profiler, tokenStore s return "", nil, err } - existingProfile, err := loadProfileByName(ctx, profileName, profiler) - if err != nil { - return "", nil, err - } - loginArgs := &auth.AuthArguments{} - err = setHostAndAccountId(ctx, existingProfile, loginArgs, nil) + err = setHostAndAccountId(ctx, nil, loginArgs, nil) if err != nil { return "", nil, err } loginArgs.Profile = profileName - // Preserve scopes from the existing profile so the inline login - // uses the same scopes the user previously configured. - var scopesList []string - if existingProfile != nil && existingProfile.Scopes != "" { - scopesList = splitScopes(existingProfile.Scopes) - } - - // Preserve RFC 8707 resource indicators from the existing profile so the - // inline login requests the same resources the user previously configured. - resourcesList := u2mResourcesFromProfile(existingProfile) - oauthArgument, err := loginArgs.ToOAuthArgument() if err != nil { return "", nil, err @@ -468,15 +452,6 @@ func runInlineLogin(ctx context.Context, profiler profile.Profiler, tokenStore s u2m.WithOAuthArgument(oauthArgument), u2m.WithBrowser(func(url string) error { return browser.Open(ctx, url) }), } - if clientID := u2mClientIDFromProfile(existingProfile); clientID != "" { - persistentAuthOpts = append(persistentAuthOpts, u2m.WithClientID(clientID)) - } - if len(scopesList) > 0 { - persistentAuthOpts = append(persistentAuthOpts, u2m.WithScopes(scopesList)) - } - if len(resourcesList) > 0 { - persistentAuthOpts = append(persistentAuthOpts, u2m.WithResources(resourcesList)) - } persistentAuth, err := u2m.NewPersistentAuth(ctx, persistentAuthOpts...) if err != nil { return "", nil, err @@ -491,9 +466,6 @@ func runInlineLogin(ctx context.Context, profiler profile.Profiler, tokenStore s return "", nil, err } - clearKeys := oauthLoginClearKeys() - clearKeys = append(clearKeys, databrickscfg.ExperimentalIsUnifiedHostKey) - err = databrickscfg.SaveToProfile(ctx, &config.Config{ Profile: profileName, Host: loginArgs.Host, @@ -501,18 +473,10 @@ func runInlineLogin(ctx context.Context, profiler profile.Profiler, tokenStore s AccountID: loginArgs.AccountID, WorkspaceID: loginArgs.WorkspaceID, ConfigFile: env.Get(ctx, "DATABRICKS_CONFIG_FILE"), - Scopes: scopesList, - ClientID: u2mClientIDFromProfile(existingProfile), - }, clearKeys...) + }) if err != nil { return "", nil, err } - // TODO: resources is not supported in the SDK yet. This function is - // a short-cut to add the property to the profile until it is supported - // in the SDK. - if err := databrickscfg.SaveResourcesToProfile(ctx, profileName, env.Get(ctx, "DATABRICKS_CONFIG_FILE"), resourcesList); err != nil { - return "", nil, err - } if err := storeLoginToken(ctx, tokenStore, mode, oauthArgument, token); err != nil { return "", nil, err } From 19207b8aa8dc9f37b9a0e61d8b9e5d64aa4ec635 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Tue, 27 Oct 2026 18:14:50 +0000 Subject: [PATCH 13/18] Test "Create a new profile" in the browser auth login flow This is the flow of the original bug. The other tests cover the prompt itself, device code login and auth token, but not this wiring. Co-authored-by: Isaac --- cmd/auth/login_test.go | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index 90cd0efe4f3..2caf417b8eb 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -5,11 +5,13 @@ import ( "context" "encoding/json" "errors" + "io" "log/slog" "net/http" "net/http/httptest" "os" "path/filepath" + "runtime" "strings" "sync" "testing" @@ -1507,3 +1509,37 @@ func TestPromptForProfileTrimsPastedName(t *testing.T) { require.NoError(t, err) assert.Equal(t, "new", name) } + +func TestLoginCreateNewProfileRejectsTakenName(t *testing.T) { + // See TestLoginDeviceCodePickerEmptyHost. + if runtime.GOOS == "windows" { + t.Skip("bubbletea cannot cancel reads on non-console input on Windows") + } + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("DATABRICKS_CONFIG_FILE", writeTempConfig(t, `[DEFAULT] +host = https://default.cloud.databricks.test +`)) + t.Setenv("DATABRICKS_AUTH_STORAGE", "plaintext") + t.Setenv(deviceCodeEnvVar, "") + + tm := termtest.Run(t, func(ctx context.Context) (struct{}, error) { + cmd := newLoginCommand(&auth.AuthArguments{}) + cmd.Flags().String("profile", "", "") + cmd.SetContext(ctx) + cmd.SetArgs([]string{}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + return struct{}{}, cmd.Execute() + }) + tm.WaitFor(profilePickerCreateNewLabel) + tm.Type(termtest.KeyDown + termtest.KeyEnter) + screen := tm.WaitFor("Databricks profile name") + assert.NotContains(t, screen, "[DEFAULT]") + tm.Type("DEFAULT" + termtest.KeyEnter) + tm.WaitFor(">> profile DEFAULT already exists") + tm.Type(termtest.KeyCtrlC) + _, err := tm.Result() + assert.EqualError(t, err, "^C") +} From 6f63417c87c29445f53293a44aa84818269fb157 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Wed, 28 Oct 2026 09:53:53 +0000 Subject: [PATCH 14/18] Share the auth login test setup between browser and device code tests TestLoginCreateNewProfileRejectsTakenName copied the setup of startDeviceCodeLogin. Move that setup into startLogin, which startDeviceCodeLogin now calls. Co-authored-by: Isaac --- cmd/auth/login_device_code_test.go | 17 ++++++++++++----- cmd/auth/login_test.go | 20 ++------------------ 2 files changed, 14 insertions(+), 23 deletions(-) diff --git a/cmd/auth/login_device_code_test.go b/cmd/auth/login_device_code_test.go index 2a77cd0803c..38cab796d9c 100644 --- a/cmd/auth/login_device_code_test.go +++ b/cmd/auth/login_device_code_test.go @@ -208,27 +208,34 @@ func TestIsDeviceCodeSupportedForHost(t *testing.T) { } } -// startDeviceCodeLogin runs "auth login --use-device-code" with args in a -// terminal emulator, using config as the contents of .databrickscfg. -func startDeviceCodeLogin(t *testing.T, config string, args ...string) *termtest.Term[struct{}] { +// startLogin runs "auth login" with args in a terminal emulator, using config +// as the contents of .databrickscfg. +func startLogin(t *testing.T, config string, args ...string) *termtest.Term[struct{}] { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) t.Setenv("DATABRICKS_CONFIG_FILE", writeTempConfig(t, config)) t.Setenv("DATABRICKS_AUTH_STORAGE", "plaintext") - t.Setenv(deviceCodeEnvVar, "true") return termtest.Run(t, func(ctx context.Context) (struct{}, error) { cmd := newLoginCommand(&auth.AuthArguments{}) cmd.Flags().String("profile", "", "") cmd.SetContext(ctx) - cmd.SetArgs(append([]string{"--use-device-code"}, args...)) + // Cobra reads os.Args when the args are nil. + cmd.SetArgs(append([]string{}, args...)) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) return struct{}{}, cmd.Execute() }) } +// startDeviceCodeLogin runs "auth login --use-device-code" with args in a +// terminal emulator, using config as the contents of .databrickscfg. +func startDeviceCodeLogin(t *testing.T, config string, args ...string) *termtest.Term[struct{}] { + t.Setenv(deviceCodeEnvVar, "true") + return startLogin(t, config, append([]string{"--use-device-code"}, args...)...) +} + func TestLoginDeviceCodeNewProfilePromptsForHost(t *testing.T) { tm := startDeviceCodeLogin(t, "", "--profile", "newprof") tm.WaitFor("Databricks host") diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index 2caf417b8eb..68764648527 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "errors" - "io" "log/slog" "net/http" "net/http/httptest" @@ -1515,24 +1514,9 @@ func TestLoginCreateNewProfileRejectsTakenName(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("bubbletea cannot cancel reads on non-console input on Windows") } - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("USERPROFILE", home) - t.Setenv("DATABRICKS_CONFIG_FILE", writeTempConfig(t, `[DEFAULT] + tm := startLogin(t, `[DEFAULT] host = https://default.cloud.databricks.test -`)) - t.Setenv("DATABRICKS_AUTH_STORAGE", "plaintext") - t.Setenv(deviceCodeEnvVar, "") - - tm := termtest.Run(t, func(ctx context.Context) (struct{}, error) { - cmd := newLoginCommand(&auth.AuthArguments{}) - cmd.Flags().String("profile", "", "") - cmd.SetContext(ctx) - cmd.SetArgs([]string{}) - cmd.SetOut(io.Discard) - cmd.SetErr(io.Discard) - return struct{}{}, cmd.Execute() - }) +`) tm.WaitFor(profilePickerCreateNewLabel) tm.Type(termtest.KeyDown + termtest.KeyEnter) screen := tm.WaitFor("Databricks profile name") From b8f7aef8d0a1a1b387f15b29a9a715552c3015ff Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Fri, 30 Oct 2026 10:41:16 +0000 Subject: [PATCH 15/18] Clear stale auth keys when the auth token inline login saves the profile The taken profile names are read before the OAuth wait. If a section with the chosen name appears in the meantime, the save merges into it, so clear the same keys as the auth login saves do. Co-authored-by: Isaac --- cmd/auth/token.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cmd/auth/token.go b/cmd/auth/token.go index ab46de2e483..e7c004d7b4c 100644 --- a/cmd/auth/token.go +++ b/cmd/auth/token.go @@ -466,6 +466,11 @@ func runInlineLogin(ctx context.Context, profiler profile.Profiler, tokenStore s return "", nil, err } + // The section may have been created during the OAuth wait, after the + // taken names were read. + clearKeys := oauthLoginClearKeys() + clearKeys = append(clearKeys, databrickscfg.ExperimentalIsUnifiedHostKey) + err = databrickscfg.SaveToProfile(ctx, &config.Config{ Profile: profileName, Host: loginArgs.Host, @@ -473,7 +478,7 @@ func runInlineLogin(ctx context.Context, profiler profile.Profiler, tokenStore s AccountID: loginArgs.AccountID, WorkspaceID: loginArgs.WorkspaceID, ConfigFile: env.Get(ctx, "DATABRICKS_CONFIG_FILE"), - }) + }, clearKeys...) if err != nil { return "", nil, err } From 5c6c1fcc0f5615d9f0458d6d88cc8cd5cdae1a9c Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Fri, 30 Oct 2026 10:43:02 +0000 Subject: [PATCH 16/18] Tell how to use an existing profile at the profile name prompt A section without a host is hidden from the picker but rejected at the name prompt, so the error now says to rerun the command with --profile. The prompt is shared by auth login and auth token, so the hint does not name a specific command. Co-authored-by: Isaac --- cmd/auth/login.go | 2 +- cmd/auth/login_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 988098d892b..166eda36312 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -55,7 +55,7 @@ func promptForProfile(ctx context.Context, defaultValue string, takenNames []str return err } if slices.Contains(takenNames, input) { - return fmt.Errorf("profile %s already exists. Choose a different name", input) + return fmt.Errorf("profile %s already exists. Choose a different name, or rerun with --profile %s to use it", input, input) } return nil }, diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index 68764648527..bf62563b59a 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -1472,7 +1472,7 @@ func TestPromptForProfileRejectsTakenName(t *testing.T) { tm.Type(termtest.KeyEnter) tm.WaitFor(">> profile name is required") tm.Type("taken" + termtest.KeyEnter) - tm.WaitFor(">> profile taken already exists") + tm.WaitFor(">> profile taken already exists. Choose a different name, or rerun with --profile taken to use it") tm.Type(strings.Repeat(termtest.KeyBackspace, len("taken")) + "__settings__" + termtest.KeyEnter) tm.WaitFor(`>> profile name "__settings__" is reserved for internal use`) tm.Type(strings.Repeat(termtest.KeyBackspace, len("__settings__")) + "take" + termtest.KeyEnter) From 19ac6e45569e6135cb304e22330a5ecfc700bef1 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Fri, 30 Oct 2026 15:08:35 +0000 Subject: [PATCH 17/18] Reject line breaks and square brackets in profile names go-ini writes section headers unescaped, so a pasted "foo\nbar" or a name like "[foo]" produced a config file that no longer parses. Co-authored-by: Isaac --- cmd/auth/login_test.go | 15 +++++++++++++++ libs/databrickscfg/ops.go | 10 +++++++++- libs/databrickscfg/ops_test.go | 25 +++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index bf62563b59a..84083ec64f1 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -1509,6 +1509,21 @@ func TestPromptForProfileTrimsPastedName(t *testing.T) { assert.Equal(t, "new", name) } +func TestPromptForProfileRejectsMultilinePaste(t *testing.T) { + tm := termtest.Run(t, func(ctx context.Context) (string, error) { + return promptForProfile(ctx, "DEFAULT", nil) + }) + tm.WaitFor("Databricks profile name [DEFAULT]") + tm.Type("\x1b[200~foo\nbar\x1b[201~" + termtest.KeyEnter) + tm.WaitFor(`>> profile name "foo\nbar" must not contain line breaks`) + tm.Type(strings.Repeat(termtest.KeyBackspace, len("foo\nbar")) + "[foo]" + termtest.KeyEnter) + tm.WaitFor(`>> profile name "[foo]" must not contain square brackets`) + tm.Type(strings.Repeat(termtest.KeyBackspace, len("[foo]")) + "foo" + termtest.KeyEnter) + name, err := tm.Result() + require.NoError(t, err) + assert.Equal(t, "foo", name) +} + func TestLoginCreateNewProfileRejectsTakenName(t *testing.T) { // See TestLoginDeviceCodePickerEmptyHost. if runtime.GOOS == "windows" { diff --git a/libs/databrickscfg/ops.go b/libs/databrickscfg/ops.go index 826abe7bc20..a910afccece 100644 --- a/libs/databrickscfg/ops.go +++ b/libs/databrickscfg/ops.go @@ -232,11 +232,19 @@ func isFirstProfileInFile(configFile *config.File) bool { } // ValidateProfileName returns an error if profileName is reserved for the -// [__settings__] section. +// [__settings__] section or cannot be written as a section header. func ValidateProfileName(profileName string) error { if profileName == databricksSettingsSection { return fmt.Errorf("profile name %q is reserved for internal use", databricksSettingsSection) } + // go-ini writes section headers unescaped, so these characters would + // produce a config file that no longer parses. + if strings.ContainsAny(profileName, "\r\n") { + return fmt.Errorf("profile name %q must not contain line breaks", profileName) + } + if strings.ContainsAny(profileName, "[]") { + return fmt.Errorf("profile name %q must not contain square brackets", profileName) + } return nil } diff --git a/libs/databrickscfg/ops_test.go b/libs/databrickscfg/ops_test.go index d4c3d805bca..441981efdbb 100644 --- a/libs/databrickscfg/ops_test.go +++ b/libs/databrickscfg/ops_test.go @@ -532,6 +532,31 @@ func TestSetDefaultProfile_RoundTrip(t *testing.T) { assert.Equal(t, "xyz", section.Key("token").String()) } +func TestValidateProfileName(t *testing.T) { + for _, tc := range []struct { + name string + profile string + wantErr string + }{ + {name: "plain", profile: "my-workspace"}, + {name: "dotted", profile: "team.prod"}, + {name: "reserved", profile: "__settings__", wantErr: "reserved for internal use"}, + {name: "newline", profile: "foo\nbar", wantErr: "must not contain line breaks"}, + {name: "carriage return", profile: "foo\rbar", wantErr: "must not contain line breaks"}, + {name: "opening bracket", profile: "[foo", wantErr: "must not contain square brackets"}, + {name: "closing bracket", profile: "foo]", wantErr: "must not contain square brackets"}, + } { + t.Run(tc.name, func(t *testing.T) { + err := ValidateProfileName(tc.profile) + if tc.wantErr == "" { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, tc.wantErr) + } + }) + } +} + func TestSaveToProfile_RejectsReservedProfileName(t *testing.T) { ctx := t.Context() path := filepath.Join(t.TempDir(), "databrickscfg") From 73e6af8b9f50641b06e3ccc7928a79ffeb60e41d Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Fri, 30 Oct 2026 16:01:26 +0000 Subject: [PATCH 18/18] Allow square brackets in profile names go-ini reads the section name up to the last ']' on the line, so names like "[foo]" or "a[b" round-trip. Rejecting them broke existing profiles with such names in auth switch, configure and auth login --profile. Co-authored-by: Isaac --- cmd/auth/login_test.go | 4 +--- libs/databrickscfg/ops.go | 7 ++----- libs/databrickscfg/ops_test.go | 22 ++++++++++++++++++++-- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index 84083ec64f1..3bbf7249e45 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -1517,11 +1517,9 @@ func TestPromptForProfileRejectsMultilinePaste(t *testing.T) { tm.Type("\x1b[200~foo\nbar\x1b[201~" + termtest.KeyEnter) tm.WaitFor(`>> profile name "foo\nbar" must not contain line breaks`) tm.Type(strings.Repeat(termtest.KeyBackspace, len("foo\nbar")) + "[foo]" + termtest.KeyEnter) - tm.WaitFor(`>> profile name "[foo]" must not contain square brackets`) - tm.Type(strings.Repeat(termtest.KeyBackspace, len("[foo]")) + "foo" + termtest.KeyEnter) name, err := tm.Result() require.NoError(t, err) - assert.Equal(t, "foo", name) + assert.Equal(t, "[foo]", name) } func TestLoginCreateNewProfileRejectsTakenName(t *testing.T) { diff --git a/libs/databrickscfg/ops.go b/libs/databrickscfg/ops.go index a910afccece..352cac235d7 100644 --- a/libs/databrickscfg/ops.go +++ b/libs/databrickscfg/ops.go @@ -237,14 +237,11 @@ func ValidateProfileName(profileName string) error { if profileName == databricksSettingsSection { return fmt.Errorf("profile name %q is reserved for internal use", databricksSettingsSection) } - // go-ini writes section headers unescaped, so these characters would - // produce a config file that no longer parses. + // go-ini writes section headers unescaped, so a line break would produce a + // config file that no longer parses. if strings.ContainsAny(profileName, "\r\n") { return fmt.Errorf("profile name %q must not contain line breaks", profileName) } - if strings.ContainsAny(profileName, "[]") { - return fmt.Errorf("profile name %q must not contain square brackets", profileName) - } return nil } diff --git a/libs/databrickscfg/ops_test.go b/libs/databrickscfg/ops_test.go index 441981efdbb..674723aafcc 100644 --- a/libs/databrickscfg/ops_test.go +++ b/libs/databrickscfg/ops_test.go @@ -543,8 +543,8 @@ func TestValidateProfileName(t *testing.T) { {name: "reserved", profile: "__settings__", wantErr: "reserved for internal use"}, {name: "newline", profile: "foo\nbar", wantErr: "must not contain line breaks"}, {name: "carriage return", profile: "foo\rbar", wantErr: "must not contain line breaks"}, - {name: "opening bracket", profile: "[foo", wantErr: "must not contain square brackets"}, - {name: "closing bracket", profile: "foo]", wantErr: "must not contain square brackets"}, + {name: "opening bracket", profile: "[foo"}, + {name: "closing bracket", profile: "foo]"}, } { t.Run(tc.name, func(t *testing.T) { err := ValidateProfileName(tc.profile) @@ -557,6 +557,24 @@ func TestValidateProfileName(t *testing.T) { } } +func TestSaveToProfile_SquareBracketsRoundTrip(t *testing.T) { + ctx := t.Context() + path := filepath.Join(t.TempDir(), "databrickscfg") + + for _, name := range []string{"[foo]", "a[b", "a]b"} { + err := SaveToProfile(ctx, &config.Config{ + ConfigFile: path, + Profile: name, + Host: "https://ws.cloud.databricks.test", + }) + require.NoError(t, err) + } + + got, err := GetSectionNames(ctx, path) + require.NoError(t, err) + assert.Equal(t, []string{"[foo]", "__settings__", "a[b", "a]b"}, got) +} + func TestSaveToProfile_RejectsReservedProfileName(t *testing.T) { ctx := t.Context() path := filepath.Join(t.TempDir(), "databrickscfg")