From 1a8288d0830f641e3cb260dc223ff3e96ca0f2f3 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Tue, 27 Oct 2026 13:40:46 +0000 Subject: [PATCH 1/9] Reject an empty host at the login host prompt Pressing Enter at the "Databricks host" prompt left the host empty, so login fell back to DATABRICKS_HOST or failed later with an unrelated error. The prompt now refuses an empty answer, which replaces the device code specific empty host checks. Co-authored-by: Isaac --- cmd/auth/auth.go | 6 ++++++ cmd/auth/auth_test.go | 12 ++++++++++++ cmd/auth/login.go | 16 ++-------------- cmd/auth/login_device_code.go | 5 +---- cmd/auth/login_device_code_test.go | 8 ++++++-- 5 files changed, 27 insertions(+), 20 deletions(-) diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index 6bbee3730b3..a8276b11fb4 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -53,6 +53,12 @@ func promptForHost(ctx context.Context) (string, error) { cmdio.LogString(ctx, "Example: https://.cloud.databricks.com") return cmdio.RunPrompt(ctx, cmdio.PromptOptions{ Label: "Databricks host", + Validate: func(input string) error { + if input == "" { + return errors.New("host is required") + } + return nil + }, }) } diff --git a/cmd/auth/auth_test.go b/cmd/auth/auth_test.go index 9dbf04fc664..4bd8bb80f46 100644 --- a/cmd/auth/auth_test.go +++ b/cmd/auth/auth_test.go @@ -7,6 +7,7 @@ import ( "github.com/databricks/cli/cmd/root" "github.com/databricks/cli/libs/cmdctx" + "github.com/databricks/cli/libs/cmdio/cmdiotest/termtest" "github.com/databricks/cli/libs/databrickscfg/profile" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -141,3 +142,14 @@ func TestProfileHostCompatibleViaCobra(t *testing.T) { assert.NotContains(t, err.Error(), "conflicts with --host") } } + +func TestPromptForHostRejectsEmptyHost(t *testing.T) { + tm := termtest.Run(t, promptForHost) + tm.WaitFor("Databricks host") + tm.Type(termtest.KeyEnter) + tm.WaitFor(">> host is required") + tm.Type("https://workspace.test" + termtest.KeyEnter) + host, err := tm.Result() + require.NoError(t, err) + assert.Equal(t, "https://workspace.test", host) +} diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 5c9f322d53d..f14a4570f2e 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -258,11 +258,6 @@ a new profile is created. if err != nil { return err } - // Fail here rather than prompting for the host again after - // the profile name. - if useDeviceCode && host == "" { - return errDeviceCodeNeedsHost - } authArguments.Host = host case profilePickerCreateNew: // Fall through to the profile name prompt below. @@ -344,15 +339,8 @@ a new profile is created. // Check the host once it is resolved, before setAccountID prompts for // an account ID for account and unified hosts. A workspace without // device code login enabled is rejected later by DeviceCodeChallenge. - if useDeviceCode { - // The host prompt accepts an empty answer. Without this check, - // login would resolve the host from DATABRICKS_HOST instead. - if authArguments.Host == "" { - return errDeviceCodeNeedsHost - } - if !isDeviceCodeSupportedForHost(authArguments.Host, authArguments.DiscoveryURL) { - return errDeviceCodeWorkspaceOnly - } + if useDeviceCode && !isDeviceCodeSupportedForHost(authArguments.Host, authArguments.DiscoveryURL) { + return errDeviceCodeWorkspaceOnly } err = setAccountID(ctx, existingProfile, authArguments) if err != nil { diff --git a/cmd/auth/login_device_code.go b/cmd/auth/login_device_code.go index 84ca1b03762..971f31ea71b 100644 --- a/cmd/auth/login_device_code.go +++ b/cmd/auth/login_device_code.go @@ -22,10 +22,7 @@ var deviceCodeIncompatibleFlags = []string{ "skip-workspace", } -var ( - errDeviceCodeNeedsHost = errors.New("--use-device-code requires a workspace host. Pass it as an argument or with --host") - errDeviceCodeWorkspaceOnly = errors.New("--use-device-code is only supported for workspace hosts") -) +var errDeviceCodeWorkspaceOnly = errors.New("--use-device-code is only supported for workspace hosts") // isDeviceCodeSupportedForHost reports whether host is a workspace host. A // unified host is only recognized by its account-scoped discoveryURL, so with diff --git a/cmd/auth/login_device_code_test.go b/cmd/auth/login_device_code_test.go index d38d2543b7c..20a2cfbc1a4 100644 --- a/cmd/auth/login_device_code_test.go +++ b/cmd/auth/login_device_code_test.go @@ -233,8 +233,10 @@ func TestLoginDeviceCodeNewProfilePromptsForHost(t *testing.T) { tm := startDeviceCodeLogin(t, "", "--profile", "newprof") tm.WaitFor("Databricks host") tm.Type(termtest.KeyEnter) + tm.WaitFor(">> host is required") + tm.Type(termtest.KeyCtrlC) _, err := tm.Result() - assert.ErrorIs(t, err, errDeviceCodeNeedsHost) + assert.EqualError(t, err, "^C") } func TestLoginDeviceCodePickerEmptyHost(t *testing.T) { @@ -249,8 +251,10 @@ func TestLoginDeviceCodePickerEmptyHost(t *testing.T) { tm.Type(termtest.KeyDown + termtest.KeyEnter) tm.WaitFor("Databricks host") tm.Type(termtest.KeyEnter) + tm.WaitFor(">> host is required") + tm.Type(termtest.KeyCtrlC) _, err := tm.Result() - assert.ErrorIs(t, err, errDeviceCodeNeedsHost) + assert.EqualError(t, err, "^C") } func TestMatchDeviceCodeProfiles(t *testing.T) { From 765a41fd7ac06334e94403c1f5d7da0910b3347b Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Tue, 27 Oct 2026 13:51:02 +0000 Subject: [PATCH 2/9] Add a changelog fragment for the empty host prompt fix Co-authored-by: Isaac --- .nextchanges/cli/login-reject-empty-host.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 .nextchanges/cli/login-reject-empty-host.md diff --git a/.nextchanges/cli/login-reject-empty-host.md b/.nextchanges/cli/login-reject-empty-host.md new file mode 100644 index 00000000000..e7f368760d4 --- /dev/null +++ b/.nextchanges/cli/login-reject-empty-host.md @@ -0,0 +1 @@ +* `databricks auth login` now rejects an empty host at the host prompt instead of falling back to `DATABRICKS_HOST` or failing later. ([#7195](https://github.com/databricks/cli/pull/7195)) From 01186a1e66952d1acfc2c434d08709141066b815 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Tue, 27 Oct 2026 13:58:31 +0000 Subject: [PATCH 3/9] Trim spaces around the host entered at the login host prompt A host pasted with surrounding spaces failed later with an unrelated URL error, and a host of only spaces got past the empty host check. Co-authored-by: Isaac --- cmd/auth/auth.go | 6 ++++-- cmd/auth/auth_test.go | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index a8276b11fb4..94f62d5edfe 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "github.com/databricks/cli/libs/auth" "github.com/databricks/cli/libs/cmdio" @@ -51,15 +52,16 @@ func promptForHost(ctx context.Context) (string, error) { // long "label + value" line that wraps causes each keystroke to leave a // stale render on screen instead of overwriting the previous one. cmdio.LogString(ctx, "Example: https://.cloud.databricks.com") - return cmdio.RunPrompt(ctx, cmdio.PromptOptions{ + result, err := cmdio.RunPrompt(ctx, cmdio.PromptOptions{ Label: "Databricks host", Validate: func(input string) error { - if input == "" { + if strings.TrimSpace(input) == "" { return errors.New("host is required") } return nil }, }) + return strings.TrimSpace(result), err } func promptForAccountID(ctx context.Context) (string, error) { diff --git a/cmd/auth/auth_test.go b/cmd/auth/auth_test.go index 4bd8bb80f46..714d2ac4073 100644 --- a/cmd/auth/auth_test.go +++ b/cmd/auth/auth_test.go @@ -148,7 +148,9 @@ func TestPromptForHostRejectsEmptyHost(t *testing.T) { tm.WaitFor("Databricks host") tm.Type(termtest.KeyEnter) tm.WaitFor(">> host is required") - tm.Type("https://workspace.test" + termtest.KeyEnter) + tm.Type(" " + termtest.KeyEnter) + tm.WaitFor(">> host is required") + tm.Type("https://workspace.test " + termtest.KeyEnter) host, err := tm.Result() require.NoError(t, err) assert.Equal(t, "https://workspace.test", host) From 3e96944344eae8a91362f090f6a37730dc3256b0 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Tue, 27 Oct 2026 15:45:16 +0000 Subject: [PATCH 4/9] Reject a host that is empty after normalization setHost strips a trailing "/" and the query params, so "/" or "?o=123" became an empty host after the prompt check. Login and auth token then resolved the host from DATABRICKS_HOST or failed later. Checking in setHost covers the prompt, --host, the positional argument, and profile hosts, and keeps the hint from the removed errDeviceCodeNeedsHost. Co-authored-by: Isaac --- cmd/auth/login.go | 7 +++++++ cmd/auth/login_device_code_test.go | 14 +++++++++++++- cmd/auth/login_test.go | 15 +++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index f14a4570f2e..f1a7f4fbeda 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -530,6 +530,8 @@ func setHostAndAccountId(ctx context.Context, existingProfile *profile.Profile, return setAccountID(ctx, existingProfile, authArguments) } +var errHostRequired = errors.New("host is required. Pass it as an argument or with --host") + // Sets the host in the persistentAuth object based on the provided arguments and flags. // Follows the following precedence: // 1. [HOST] (first positional argument) or --host flag. Error if both are specified. @@ -567,6 +569,11 @@ func setHost(ctx context.Context, existingProfile *profile.Profile, authArgument // URL params from explicit --host override stale profile values. params := auth.ExtractHostQueryParams(authArguments.Host) authArguments.Host = params.Host + // Checked after normalization because a host like "/" or "?o=123" becomes + // empty. Without a host, login would resolve one from DATABRICKS_HOST. + if authArguments.Host == "" { + return errHostRequired + } if authArguments.WorkspaceID == "" { authArguments.WorkspaceID = params.WorkspaceID } diff --git a/cmd/auth/login_device_code_test.go b/cmd/auth/login_device_code_test.go index 20a2cfbc1a4..e9cd7eba8ac 100644 --- a/cmd/auth/login_device_code_test.go +++ b/cmd/auth/login_device_code_test.go @@ -219,8 +219,10 @@ func startDeviceCodeLogin(t *testing.T, config string, args ...string) *termtest t.Setenv(deviceCodeEnvVar, "true") return termtest.Run(t, func(ctx context.Context) (struct{}, error) { - cmd := newLoginCommand(&auth.AuthArguments{}) + authArguments := &auth.AuthArguments{} + cmd := newLoginCommand(authArguments) cmd.Flags().String("profile", "", "") + cmd.Flags().StringVar(&authArguments.Host, "host", "", "") cmd.SetContext(ctx) cmd.SetArgs(append([]string{"--use-device-code"}, args...)) cmd.SetOut(io.Discard) @@ -257,6 +259,16 @@ func TestLoginDeviceCodePickerEmptyHost(t *testing.T) { assert.EqualError(t, err, "^C") } +func TestLoginDeviceCodeHostEmptyAfterNormalization(t *testing.T) { + for _, host := range []string{"/", "?o=123"} { + t.Run(host, func(t *testing.T) { + tm := startDeviceCodeLogin(t, "", "--profile", "newprof", "--host", host) + _, err := tm.Result() + assert.ErrorIs(t, err, errHostRequired) + }) + } +} + 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..42f41e1bbf9 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -203,6 +203,21 @@ func TestSetHost(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "https://www.host2.test", authArguments.Host) + // Test host that is empty after normalization from flag + authArguments.Host = "?o=123" + err = setHostAndAccountId(ctx, profile1, &authArguments, []string{}) + assert.ErrorIs(t, err, errHostRequired) + + // Test host that is empty after normalization from argument + authArguments.Host = "" + err = setHostAndAccountId(ctx, profile1, &authArguments, []string{"/"}) + assert.ErrorIs(t, err, errHostRequired) + + // Test host that is empty after normalization from profile + authArguments.Host = "" + err = setHostAndAccountId(ctx, &profile.Profile{Host: "/"}, &authArguments, []string{}) + assert.ErrorIs(t, err, errHostRequired) + // Test host is not set. Should prompt. authArguments.Host = "" err = setHostAndAccountId(ctx, nil, &authArguments, []string{}) From ecd576f982af90f6714bc14a5f3ee933181a5471 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Tue, 27 Oct 2026 15:45:17 +0000 Subject: [PATCH 5/9] Check the screen in the empty host prompt test WaitFor matched the error printed for the first empty answer, so the test passed even if a whitespace-only host was accepted. Golden steps compare the rendered screen after each Enter. Co-authored-by: Isaac --- cmd/auth/auth_test.go | 4 ++-- .../TestPromptForHostRejectsEmptyHost/01-empty.golden | 3 +++ .../TestPromptForHostRejectsEmptyHost/02-spaces.golden | 3 +++ 3 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 cmd/auth/testdata/TestPromptForHostRejectsEmptyHost/01-empty.golden create mode 100644 cmd/auth/testdata/TestPromptForHostRejectsEmptyHost/02-spaces.golden diff --git a/cmd/auth/auth_test.go b/cmd/auth/auth_test.go index 714d2ac4073..bfef5a34c4f 100644 --- a/cmd/auth/auth_test.go +++ b/cmd/auth/auth_test.go @@ -147,9 +147,9 @@ func TestPromptForHostRejectsEmptyHost(t *testing.T) { tm := termtest.Run(t, promptForHost) tm.WaitFor("Databricks host") tm.Type(termtest.KeyEnter) - tm.WaitFor(">> host is required") + tm.Golden("01-empty") tm.Type(" " + termtest.KeyEnter) - tm.WaitFor(">> host is required") + tm.Golden("02-spaces") tm.Type("https://workspace.test " + termtest.KeyEnter) host, err := tm.Result() require.NoError(t, err) diff --git a/cmd/auth/testdata/TestPromptForHostRejectsEmptyHost/01-empty.golden b/cmd/auth/testdata/TestPromptForHostRejectsEmptyHost/01-empty.golden new file mode 100644 index 00000000000..01918873e19 --- /dev/null +++ b/cmd/auth/testdata/TestPromptForHostRejectsEmptyHost/01-empty.golden @@ -0,0 +1,3 @@ +Example: https://.cloud.databricks.com +✗ Databricks host: █ +>> host is required diff --git a/cmd/auth/testdata/TestPromptForHostRejectsEmptyHost/02-spaces.golden b/cmd/auth/testdata/TestPromptForHostRejectsEmptyHost/02-spaces.golden new file mode 100644 index 00000000000..7c5f1e4da33 --- /dev/null +++ b/cmd/auth/testdata/TestPromptForHostRejectsEmptyHost/02-spaces.golden @@ -0,0 +1,3 @@ +Example: https://.cloud.databricks.com +✗ Databricks host: █ +>> host is required From 69e2b689d697e76ea961fb220d7b907f161fabc4 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Tue, 27 Oct 2026 16:40:18 +0000 Subject: [PATCH 6/9] Show a blank prompt glyph until invalid input is edited The prompt glyph ran Validate on every render, so a prompt that rejects empty input, such as the login host prompt, opened with a red cross before anything was typed. The glyph is now blank until the first edit or Enter, then shows a cross while the input is invalid and a checkmark once it is valid. Co-authored-by: Isaac --- .../prompt_validate_baseline_test.go | 44 +++++++++++++++++++ .../01-empty.golden | 2 +- .../01-enter.golden | 2 + .../01-initial.golden | 1 + libs/cmdio/prompt.go | 21 ++++++--- 5 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 libs/cmdio/cmdiotest/testdata/TestPromptBaseline_ValidateEnterBeforeEdit/01-enter.golden create mode 100644 libs/cmdio/cmdiotest/testdata/TestPromptBaseline_ValidateValidBeforeEdit/01-initial.golden diff --git a/libs/cmdio/cmdiotest/prompt_validate_baseline_test.go b/libs/cmdio/cmdiotest/prompt_validate_baseline_test.go index 5f703a5865d..68d507a57fb 100644 --- a/libs/cmdio/cmdiotest/prompt_validate_baseline_test.go +++ b/libs/cmdio/cmdiotest/prompt_validate_baseline_test.go @@ -45,3 +45,47 @@ func TestPromptBaseline_Validate(t *testing.T) { require.NoError(t, err, "raw output: %q", tm.Raw()) assert.Equal(t, "https://example.com", v, "snapshot:\n%s", tm.Snapshot()) } + +// TestPromptBaseline_ValidateEnterBeforeEdit pins that Enter on an untouched +// prompt flips the glyph to "✗" along with the error line. +func TestPromptBaseline_ValidateEnterBeforeEdit(t *testing.T) { + t.Parallel() + tm := termtest.NewPrompt(t, cmdio.PromptOptions{ + Label: "Workspace host", + Validate: func(s string) error { + if s == "" { + return errors.New("host is required") + } + return nil + }, + }) + tm.WaitFor("Workspace host") + tm.Type(termtest.KeyEnter) + tm.Golden("01-enter") + + tm.Type(termtest.KeyCtrlC) + _, err := tm.Result() + assert.Error(t, err) +} + +// TestPromptBaseline_ValidateValidBeforeEdit pins that an untouched prompt +// whose input already passes Validate shows "✔". +func TestPromptBaseline_ValidateValidBeforeEdit(t *testing.T) { + t.Parallel() + tm := termtest.NewPrompt(t, cmdio.PromptOptions{ + Label: "Workspace host", + Validate: func(s string) error { + if strings.Contains(s, " ") { + return errors.New("must not contain spaces") + } + return nil + }, + }) + tm.WaitFor("Workspace host") + tm.Golden("01-initial") + + tm.Type(termtest.KeyEnter) + v, err := tm.Result() + require.NoError(t, err, "raw output: %q", tm.Raw()) + assert.Empty(t, v) +} diff --git a/libs/cmdio/cmdiotest/testdata/TestPromptBaseline_Validate/01-empty.golden b/libs/cmdio/cmdiotest/testdata/TestPromptBaseline_Validate/01-empty.golden index aef99ba9f27..6da352062e8 100644 --- a/libs/cmdio/cmdiotest/testdata/TestPromptBaseline_Validate/01-empty.golden +++ b/libs/cmdio/cmdiotest/testdata/TestPromptBaseline_Validate/01-empty.golden @@ -1 +1 @@ -✗ Workspace host: █ + Workspace host: █ diff --git a/libs/cmdio/cmdiotest/testdata/TestPromptBaseline_ValidateEnterBeforeEdit/01-enter.golden b/libs/cmdio/cmdiotest/testdata/TestPromptBaseline_ValidateEnterBeforeEdit/01-enter.golden new file mode 100644 index 00000000000..a001d0c4bfa --- /dev/null +++ b/libs/cmdio/cmdiotest/testdata/TestPromptBaseline_ValidateEnterBeforeEdit/01-enter.golden @@ -0,0 +1,2 @@ +✗ Workspace host: █ +>> host is required diff --git a/libs/cmdio/cmdiotest/testdata/TestPromptBaseline_ValidateValidBeforeEdit/01-initial.golden b/libs/cmdio/cmdiotest/testdata/TestPromptBaseline_ValidateValidBeforeEdit/01-initial.golden new file mode 100644 index 00000000000..c2487520307 --- /dev/null +++ b/libs/cmdio/cmdiotest/testdata/TestPromptBaseline_ValidateValidBeforeEdit/01-initial.golden @@ -0,0 +1 @@ +✔ Workspace host: █ diff --git a/libs/cmdio/prompt.go b/libs/cmdio/prompt.go index 8c8ca1a3a39..52870183e09 100644 --- a/libs/cmdio/prompt.go +++ b/libs/cmdio/prompt.go @@ -33,7 +33,9 @@ type PromptOptions struct { // Validate, when set, is called on every keystroke. While it returns a // non-nil error the leading glyph flips from "✔" to "✗" and Enter is // inert; pressing Enter while invalid surfaces the returned error - // below the prompt until the next edit. + // below the prompt until the next edit. Until the first edit or Enter, + // an invalid input shows a blank glyph instead, so an empty prompt does + // not open as an error. Validate func(input string) error } @@ -73,6 +75,9 @@ type promptModel struct { deleted bool submitted bool + // edited is set by the first change to runes. + edited bool + // submitErr is the error from the last failed Enter attempt. Rendered // below the prompt and cleared on the next edit. submitErr error @@ -92,12 +97,16 @@ func (m *promptModel) value() string { } // glyph returns the leading status indicator: bold-green ✔ when valid, -// bold-red ✗ when validate rejects the buffer. +// bold-red ✗ when validate rejects the buffer after an edit or Enter, and a +// blank before that. func (m *promptModel) glyph() string { - if m.validate != nil && m.validate(m.value()) != nil { - return ansiBold + ansiRed + glyphInvalid + ansiReset + if m.validate == nil || m.validate(m.value()) == nil { + return ansiBold + ansiGreen + glyphValid + ansiReset + } + if !m.edited && m.submitErr == nil { + return " " } - return ansiBold + ansiGreen + glyphValid + ansiReset + return ansiBold + ansiRed + glyphInvalid + ansiReset } func (m *promptModel) Init() tea.Cmd { return nil } @@ -154,6 +163,7 @@ func (m *promptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.runes = append(m.runes[:m.cursor-1], m.runes[m.cursor:]...) m.cursor-- + m.edited = true m.submitErr = nil case tea.KeyRunes, tea.KeySpace: @@ -172,6 +182,7 @@ func (m *promptModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.runes = append(m.runes[:m.cursor], typed...) m.runes = append(m.runes, tail...) m.cursor += len(typed) + m.edited = true m.submitErr = nil default: From f80c59f7e650d7640b49f57b39de27dc427da051 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Tue, 27 Oct 2026 16:40:21 +0000 Subject: [PATCH 7/9] Update the changelog fragment for the review fixes Mention auth token, and that an empty host after "Enter a host" no longer starts discovery login. Co-authored-by: Isaac --- .nextchanges/cli/login-reject-empty-host.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.nextchanges/cli/login-reject-empty-host.md b/.nextchanges/cli/login-reject-empty-host.md index e7f368760d4..ddb03d804a3 100644 --- a/.nextchanges/cli/login-reject-empty-host.md +++ b/.nextchanges/cli/login-reject-empty-host.md @@ -1 +1 @@ -* `databricks auth login` now rejects an empty host at the host prompt instead of falling back to `DATABRICKS_HOST` or failing later. ([#7195](https://github.com/databricks/cli/pull/7195)) +* `databricks auth login` and `databricks auth token` now reject an empty host instead of falling back to `DATABRICKS_HOST`. An empty host after "Enter a host" in the login profile picker no longer starts discovery login. ([#7195](https://github.com/databricks/cli/pull/7195)) From 2c68da87b1735aef14ef362349f7f2eb9c0daf9c Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Tue, 27 Oct 2026 17:45:52 +0000 Subject: [PATCH 8/9] Check for an empty host in one place, including --skip-workspace With --skip-workspace, login stripped the query params from the host before setHost ran, so a host like "?a=123" became empty and login went to discovery instead of failing. Both places now share normalizeHost, which does the empty host check. The error is now `invalid host ""`. The previous hint to pass the host as an argument or with --host did not help, because the error only happens when a host was given. Co-authored-by: Isaac --- cmd/auth/login.go | 53 ++++++++++++++++-------------- cmd/auth/login_device_code_test.go | 2 +- cmd/auth/login_test.go | 29 +++++++++++----- 3 files changed, 51 insertions(+), 33 deletions(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index f1a7f4fbeda..ab1e02c3da6 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -270,13 +270,8 @@ a new profile is created. // only populated by setHostAndAccountId further down, which runs after // the profile-name prompt. if skipWorkspace && profileName == "" && authArguments.Host != "" && authArguments.AccountID == "" { - params := auth.ExtractHostQueryParams(authArguments.Host) - authArguments.Host = params.Host - if authArguments.AccountID == "" { - authArguments.AccountID = params.AccountID - } - if authArguments.WorkspaceID == "" { - authArguments.WorkspaceID = params.WorkspaceID + if err := normalizeHost(authArguments); err != nil { + return err } runHostDiscovery(ctx, authArguments) } @@ -530,7 +525,31 @@ func setHostAndAccountId(ctx context.Context, existingProfile *profile.Profile, return setAccountID(ctx, existingProfile, authArguments) } -var errHostRequired = errors.New("host is required. Pass it as an argument or with --host") +var errInvalidHost = errors.New("invalid host") + +// normalizeHost strips the trailing slash and the query params from the host +// and moves ?o= and ?a= into WorkspaceID and AccountID unless they are set. +// It fails if nothing is left of the host, e.g. for "/" or "?o=123", because +// with an empty host login would use discovery or DATABRICKS_HOST instead. +func normalizeHost(authArguments *auth.AuthArguments) error { + rawHost := authArguments.Host + authArguments.Host = strings.TrimSuffix(authArguments.Host, "/") + + // Extract query parameters from the host URL (?o=workspace_id, ?a=account_id). + // URL params from explicit --host override stale profile values. + params := auth.ExtractHostQueryParams(authArguments.Host) + authArguments.Host = params.Host + if authArguments.Host == "" { + return fmt.Errorf("%w %q", errInvalidHost, rawHost) + } + if authArguments.WorkspaceID == "" { + authArguments.WorkspaceID = params.WorkspaceID + } + if authArguments.AccountID == "" { + authArguments.AccountID = params.AccountID + } + return nil +} // Sets the host in the persistentAuth object based on the provided arguments and flags. // Follows the following precedence: @@ -563,22 +582,8 @@ func setHost(ctx context.Context, existingProfile *profile.Profile, authArgument } } - authArguments.Host = strings.TrimSuffix(authArguments.Host, "/") - - // Extract query parameters from the host URL (?o=workspace_id, ?a=account_id). - // URL params from explicit --host override stale profile values. - params := auth.ExtractHostQueryParams(authArguments.Host) - authArguments.Host = params.Host - // Checked after normalization because a host like "/" or "?o=123" becomes - // empty. Without a host, login would resolve one from DATABRICKS_HOST. - if authArguments.Host == "" { - return errHostRequired - } - if authArguments.WorkspaceID == "" { - authArguments.WorkspaceID = params.WorkspaceID - } - if authArguments.AccountID == "" { - authArguments.AccountID = params.AccountID + if err := normalizeHost(authArguments); err != nil { + return err } // Inherit workspace_id from the existing profile AFTER URL param extraction. diff --git a/cmd/auth/login_device_code_test.go b/cmd/auth/login_device_code_test.go index e9cd7eba8ac..0bd91136644 100644 --- a/cmd/auth/login_device_code_test.go +++ b/cmd/auth/login_device_code_test.go @@ -264,7 +264,7 @@ func TestLoginDeviceCodeHostEmptyAfterNormalization(t *testing.T) { t.Run(host, func(t *testing.T) { tm := startDeviceCodeLogin(t, "", "--profile", "newprof", "--host", host) _, err := tm.Result() - assert.ErrorIs(t, err, errHostRequired) + assert.ErrorIs(t, err, errInvalidHost) }) } } diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index 42f41e1bbf9..32ee419656f 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -206,17 +206,17 @@ func TestSetHost(t *testing.T) { // Test host that is empty after normalization from flag authArguments.Host = "?o=123" err = setHostAndAccountId(ctx, profile1, &authArguments, []string{}) - assert.ErrorIs(t, err, errHostRequired) + assert.ErrorIs(t, err, errInvalidHost) // Test host that is empty after normalization from argument authArguments.Host = "" err = setHostAndAccountId(ctx, profile1, &authArguments, []string{"/"}) - assert.ErrorIs(t, err, errHostRequired) + assert.ErrorIs(t, err, errInvalidHost) // Test host that is empty after normalization from profile authArguments.Host = "" err = setHostAndAccountId(ctx, &profile.Profile{Host: "/"}, &authArguments, []string{}) - assert.ErrorIs(t, err, errHostRequired) + assert.ErrorIs(t, err, errInvalidHost) // Test host is not set. Should prompt. authArguments.Host = "" @@ -803,11 +803,7 @@ func TestSkipWorkspaceProfileNameUsesDiscoveredAccountID(t *testing.T) { args := &auth.AuthArguments{Host: server.URL} // Mirrors the pre-naming block in newLoginCommand's RunE for --skip-workspace. - params := auth.ExtractHostQueryParams(args.Host) - args.Host = params.Host - if args.AccountID == "" { - args.AccountID = params.AccountID - } + require.NoError(t, normalizeHost(args)) runHostDiscovery(ctx, args) assert.Equal(t, "abc-123", args.AccountID) @@ -1464,6 +1460,23 @@ func TestLoginRejectsPositionalArgWithHostFlag(t *testing.T) { assert.ErrorContains(t, err, `argument "myprofile" cannot be combined with --host or --profile`) } +func TestLoginSkipWorkspaceHostEmptyAfterNormalization(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("DATABRICKS_CONFIG_FILE", writeTempConfig(t, "")) + t.Setenv("DATABRICKS_AUTH_STORAGE", "plaintext") + + ctx := cmdio.MockDiscard(t.Context()) + authArgs := &auth.AuthArguments{Host: "?a=123"} + cmd := newLoginCommand(authArgs) + cmd.Flags().String("profile", "", "") + cmd.SetContext(ctx) + cmd.SetArgs([]string{"--skip-workspace"}) + err := cmd.Execute() + assert.EqualError(t, err, `invalid host "?a=123"`) +} + func TestLoginRejectsPositionalArgWithProfileFlag(t *testing.T) { ctx := cmdio.MockDiscard(t.Context()) authArgs := &auth.AuthArguments{} From 50e19928e0058e755782e7b611758f63d9e11839 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Tue, 27 Oct 2026 17:49:27 +0000 Subject: [PATCH 9/9] Rename normalizeHost to normalizeLoginHost cmd/configure and libs/databrickscfg already have a normalizeHost. Co-authored-by: Isaac --- cmd/auth/login.go | 10 +++++----- cmd/auth/login_test.go | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index ab1e02c3da6..62855f6f0c0 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -270,7 +270,7 @@ a new profile is created. // only populated by setHostAndAccountId further down, which runs after // the profile-name prompt. if skipWorkspace && profileName == "" && authArguments.Host != "" && authArguments.AccountID == "" { - if err := normalizeHost(authArguments); err != nil { + if err := normalizeLoginHost(authArguments); err != nil { return err } runHostDiscovery(ctx, authArguments) @@ -527,11 +527,11 @@ func setHostAndAccountId(ctx context.Context, existingProfile *profile.Profile, var errInvalidHost = errors.New("invalid host") -// normalizeHost strips the trailing slash and the query params from the host -// and moves ?o= and ?a= into WorkspaceID and AccountID unless they are set. +// normalizeLoginHost strips the trailing slash and the query params from the +// host and moves ?o= and ?a= into WorkspaceID and AccountID unless they are set. // It fails if nothing is left of the host, e.g. for "/" or "?o=123", because // with an empty host login would use discovery or DATABRICKS_HOST instead. -func normalizeHost(authArguments *auth.AuthArguments) error { +func normalizeLoginHost(authArguments *auth.AuthArguments) error { rawHost := authArguments.Host authArguments.Host = strings.TrimSuffix(authArguments.Host, "/") @@ -582,7 +582,7 @@ func setHost(ctx context.Context, existingProfile *profile.Profile, authArgument } } - if err := normalizeHost(authArguments); err != nil { + if err := normalizeLoginHost(authArguments); err != nil { return err } diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index 32ee419656f..81caea17ba9 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -803,7 +803,7 @@ func TestSkipWorkspaceProfileNameUsesDiscoveredAccountID(t *testing.T) { args := &auth.AuthArguments{Host: server.URL} // Mirrors the pre-naming block in newLoginCommand's RunE for --skip-workspace. - require.NoError(t, normalizeHost(args)) + require.NoError(t, normalizeLoginHost(args)) runHostDiscovery(ctx, args) assert.Equal(t, "abc-123", args.AccountID)