Skip to content
Open
1 change: 1 addition & 0 deletions .nextchanges/cli/login-reject-empty-host.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* `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))
10 changes: 9 additions & 1 deletion cmd/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"strings"

"github.com/databricks/cli/libs/auth"
"github.com/databricks/cli/libs/cmdio"
Expand Down Expand Up @@ -51,9 +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://<databricks-instance>.cloud.databricks.com")
return cmdio.RunPrompt(ctx, cmdio.PromptOptions{
result, err := cmdio.RunPrompt(ctx, cmdio.PromptOptions{
Label: "Databricks host",
Validate: func(input string) error {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also changes the browser flow. On main, picking "Enter a host" in the profile picker and pressing Enter on an empty prompt left Host == "", so shouldUseDiscovery kicked in and login went through login.databricks.com. Now the prompt loops on "host is required" and Ctrl+C is the only way out (no way back to "Create a new profile").

Was that intended? If so, I think the PR description and changelog should mention it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I intended this change. I suppose discovery fallback is unexpected here, as it is a part of 'Enter host manually' flow

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nice to have] Validate only rejects blank input, so / or ?o=123 passes here and then fails in setHost. In auth login, picking "Enter a host" and typing / first asks for a profile name (with [/] as the suggested default), then errors with invalid host "/" and you have to start over. In the auth token "Enter a host" path the command exits instead of asking again.

I think running the same normalization in Validate after the blank check would keep the prompt open with the error under it:

return normalizeLoginHost(&auth.AuthArguments{Host: strings.TrimSpace(input)})

Checking for a missing hostname (like validateHost in cmd/configure/host.go) would also catch https:// and https://?o=123, which currently get through as https:/ and https:. An explicit --host / has the same problem: it's only rejected after the keyring probe and (without --profile) the profile name prompt.

if strings.TrimSpace(input) == "" {
return errors.New("host is required")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two small nits:

  • The removed errDeviceCodeNeedsHost told users they could pass the host as an argument or with --host. Might be worth keeping that hint in this message.
  • promptModel.glyph() runs Validate on every render, so the prompt now opens with a red ✗ before anything is typed (it used to be a green ✔). Minor, but the first frame reads like an error.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. I'm not sure it is suitable here, as a user already entering the url in an interactive mode
  2. I changed it to show nothing until the first edit (unless input is valid)

}
return nil
},
})
return strings.TrimSpace(result), err
}

func promptForAccountID(ctx context.Context) (string, error) {
Expand Down
14 changes: 14 additions & 0 deletions cmd/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -141,3 +142,16 @@ 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.Golden("01-empty")
tm.Type(" " + termtest.KeyEnter)
tm.Golden("02-spaces")
tm.Type("https://workspace.test " + termtest.KeyEnter)
host, err := tm.Result()
require.NoError(t, err)
assert.Equal(t, "https://workspace.test", host)
}
64 changes: 32 additions & 32 deletions cmd/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -275,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 := normalizeLoginHost(authArguments); err != nil {
return err
}
runHostDiscovery(ctx, authArguments)
}
Expand Down Expand Up @@ -344,15 +334,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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This drops the Host == "" check that ran after setHost. The prompt validation doesn't fully cover it, because setHost can still turn a non-empty input into an empty host (TrimSuffix("/") and then ExtractHostQueryParams). So / or ?o=123 at the prompt, or --host /, ends up as Host == "" here.

With an empty host, isDeviceCodeSupportedForHost("", "") returns true, and ToOAuthArgument resolves the host from DATABRICKS_HOST via ConfigAttributes. If that's an accounts host, we get an account OAuth argument, skip the workspace-only check, and save a profile without a host key. On main this returned errDeviceCodeNeedsHost.

Repro:

DATABRICKS_CLI_DEVICE_CODE=true DATABRICKS_HOST=https://accounts.cloud.databricks.com \
  databricks auth login --use-device-code --profile p --host /

I think we should keep an empty check after setHost (see my comment on promptForHost for where I'd put it).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added a check inside setHost

return errDeviceCodeWorkspaceOnly
}
err = setAccountID(ctx, existingProfile, authArguments)
if err != nil {
Expand Down Expand Up @@ -542,6 +525,32 @@ func setHostAndAccountId(ctx context.Context, existingProfile *profile.Profile,
return setAccountID(ctx, existingProfile, authArguments)
}

var errInvalidHost = errors.New("invalid host")

// 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] The comment says it moves ?o= and ?a=, but ExtractHostQueryParams also handles ?w= (which the help text calls the preferred form), ?workspace_id= and ?account_id=. Something like "moves the workspace and account ID query params into ..." would stay accurate.

// 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 normalizeLoginHost(authArguments *auth.AuthArguments) error {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nice to have] Only the prompt trims spaces. --host " ", a positional arg or a profile host with spaces gets past this check and still fails later with host must start with 'https://': , which is the error the PR description mentions. With --host " https://x.test/?o=1", url.Parse fails on the leading space, so ?o= never ends up in WorkspaceID.

I think trimming once at the top of normalizeLoginHost would cover every source, and then promptForHost wouldn't need to trim its return value.

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nice to have] invalid host "<input>" is user-visible output from both auth login and auth token, but it's only covered by unit tests, and nothing exercises the auth token path end to end. .agents/rules/testing.md asks for acceptance tests first for cmd/... output. This one doesn't need a terminal, so something like musterr $CLI auth login --host '?o=123' --profile test and musterr $CLI auth token --host '?o=123' under acceptance/cmd/auth/login/ and acceptance/cmd/auth/token/ should work.

}
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:
// 1. [HOST] (first positional argument) or --host flag. Error if both are specified.
Expand Down Expand Up @@ -573,17 +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
if authArguments.WorkspaceID == "" {
authArguments.WorkspaceID = params.WorkspaceID
}
if authArguments.AccountID == "" {
authArguments.AccountID = params.AccountID
if err := normalizeLoginHost(authArguments); err != nil {
return err
}

// Inherit workspace_id from the existing profile AFTER URL param extraction.
Expand Down
5 changes: 1 addition & 4 deletions cmd/auth/login_device_code.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 19 additions & 3 deletions cmd/auth/login_device_code_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -233,8 +235,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")
Comment thread
maksim-zuev-db marked this conversation as resolved.
}

func TestLoginDeviceCodePickerEmptyHost(t *testing.T) {
Expand All @@ -249,8 +253,20 @@ 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 TestLoginDeviceCodeHostEmptyAfterNormalization(t *testing.T) {
for _, host := range []string{"/", "?o=123"} {
t.Run(host, func(t *testing.T) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] With host == "/" the subtest name ends up as TestLoginDeviceCodeHostEmptyAfterNormalization//, which is awkward to target with -run. Named cases ({name: "slash", host: "/"}) would avoid that.

Small thing: TestLoginSkipWorkspaceHostEmptyAfterNormalization in login_test.go landed between the paired TestLoginRejectsPositionalArgWithHostFlag and ...WithProfileFlag tests.

tm := startDeviceCodeLogin(t, "", "--profile", "newprof", "--host", host)
_, err := tm.Result()
assert.ErrorIs(t, err, errInvalidHost)
})
}
}

func TestMatchDeviceCodeProfiles(t *testing.T) {
Expand Down
38 changes: 33 additions & 5 deletions cmd/auth/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, errInvalidHost)

// Test host that is empty after normalization from argument
authArguments.Host = ""
err = setHostAndAccountId(ctx, profile1, &authArguments, []string{"/"})
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, errInvalidHost)

// Test host is not set. Should prompt.
authArguments.Host = ""
err = setHostAndAccountId(ctx, nil, &authArguments, []string{})
Expand Down Expand Up @@ -788,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, normalizeLoginHost(args))
runHostDiscovery(ctx, args)

assert.Equal(t, "abc-123", args.AccountID)
Expand Down Expand Up @@ -1449,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{}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Example: https://<databricks-instance>.cloud.databricks.com
✗ Databricks host: █
>> host is required
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Example: https://<databricks-instance>.cloud.databricks.com
✗ Databricks host: █
>> host is required
44 changes: 44 additions & 0 deletions libs/cmdio/cmdiotest/prompt_validate_baseline_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
@@ -1 +1 @@
✗ Workspace host: █
Workspace host: █
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
✗ Workspace host: █
>> host is required
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
✔ Workspace host: █
21 changes: 16 additions & 5 deletions libs/cmdio/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand All @@ -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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coming back on my earlier comment about the red ✗ on the first frame, my bad. This changes every prompt that uses Validate, including the databricks configure host prompt, so I think it's out of scope for this PR. Can we roll back the libs/cmdio/prompt.go change and the TestPromptBaseline_Validate/01-empty.golden update (and drop it from the description)? The two new baseline tests and the cmd/auth goldens should still pass with the old glyph. If we still want the blank glyph, it can go in its own PR.

return " "
}
return ansiBold + ansiGreen + glyphValid + ansiReset
return ansiBold + ansiRed + glyphInvalid + ansiReset
}

func (m *promptModel) Init() tea.Cmd { return nil }
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
Loading