From 12f136699746d0809147b24a67b454dc4cf7d1d5 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Mon, 26 Oct 2026 11:58:15 +0000 Subject: [PATCH 1/9] Prompt for the host instead of failing when device code login has none Discovery login opens a browser on this machine and learns the workspace host only after sign-in. Device code login is meant for machines without a browser, but when no host was known it still went into the discovery branch and failed with "--use-device-code requires a workspace host". This broke "Create a new profile" in the login picker and "auth login --use-device-code --profile ". Skip discovery for device code login so setHost resolves the host. It prompts for it when interactive and asks for --host otherwise. Co-authored-by: Isaac --- .../device-code/unsupported-host/output.txt | 2 +- .../login/device-code/unsupported-host/script | 3 +- cmd/auth/login.go | 9 +++--- cmd/auth/login_device_code.go | 5 +--- cmd/auth/login_device_code_test.go | 30 +++++++++++++++++++ docs/device-code-login.md | 3 +- 6 files changed, 40 insertions(+), 12 deletions(-) diff --git a/acceptance/cmd/auth/login/device-code/unsupported-host/output.txt b/acceptance/cmd/auth/login/device-code/unsupported-host/output.txt index 5d327ce2db2..7875f49ed6b 100644 --- a/acceptance/cmd/auth/login/device-code/unsupported-host/output.txt +++ b/acceptance/cmd/auth/login/device-code/unsupported-host/output.txt @@ -2,7 +2,7 @@ === No host >>> musterr [CLI] auth login --profile new-profile --use-device-code -Error: --use-device-code requires a workspace host. Pass it as an argument or with --host +Error: the command is being run in a non-interactive environment, please specify a host using --host === Account host diff --git a/acceptance/cmd/auth/login/device-code/unsupported-host/script b/acceptance/cmd/auth/login/device-code/unsupported-host/script index dd8a9ddca47..d744aec1709 100644 --- a/acceptance/cmd/auth/login/device-code/unsupported-host/script +++ b/acceptance/cmd/auth/login/device-code/unsupported-host/script @@ -3,7 +3,8 @@ sethome "./home" title "No host\n" # The profile does not exist yet and no host is given. auth login does not read # DATABRICKS_HOST, so the usual login would fall back to the browser-based -# discovery flow, which device code login does not support. +# discovery flow. Device code login skips it and asks for the host instead, +# which fails without a terminal. trace musterr $CLI auth login --profile new-profile --use-device-code envsubst < "./home/.databrickscfg.tmpl" > "./home/.databrickscfg" diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 13a5a8a61e0..f2d117c5edb 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -306,11 +306,10 @@ a new profile is created. } // If no host is available from any source, use the discovery flow - // via login.databricks.com. - if shouldUseDiscovery(authArguments.Host, args, existingProfile) { - if useDeviceCode { - return errDeviceCodeNeedsHost - } + // via login.databricks.com. Discovery opens a browser on this machine, + // and device code login is meant for machines without one, so it skips + // discovery and setHost resolves the host instead. + if !useDeviceCode && shouldUseDiscovery(authArguments.Host, args, existingProfile) { if err := validateDiscoveryFlagCompatibility(cmd); err != nil { return err } diff --git a/cmd/auth/login_device_code.go b/cmd/auth/login_device_code.go index 93455ad9dd7..59181563934 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") // validateDeviceCodeHost returns an error if the host resolved by setHost is // an account or unified host. A workspace without device code login enabled diff --git a/cmd/auth/login_device_code_test.go b/cmd/auth/login_device_code_test.go index 84fdc3f0bbd..33282e2ea8c 100644 --- a/cmd/auth/login_device_code_test.go +++ b/cmd/auth/login_device_code_test.go @@ -12,6 +12,7 @@ import ( "github.com/databricks/cli/libs/auth" "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/env" "github.com/databricks/cli/libs/flags" "github.com/spf13/cobra" @@ -204,3 +205,32 @@ func TestValidateDeviceCodeHost(t *testing.T) { }) } } + +func TestLoginDeviceCodeCreateNewProfilePromptsForHost(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("DATABRICKS_CONFIG_FILE", writeTempConfig(t, "[myprof]\nhost = https://workspace.test\n")) + t.Setenv("DATABRICKS_AUTH_STORAGE", "plaintext") + t.Setenv(deviceCodeEnvVar, "true") + + tm := termtest.Run(t, func(ctx context.Context) (struct{}, error) { + cmd := newLoginCommand(&auth.AuthArguments{}) + cmd.Flags().String("profile", "", "") + cmd.SetContext(ctx) + cmd.SetArgs([]string{"--use-device-code"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + return struct{}{}, cmd.Execute() + }) + tm.WaitFor(profilePickerCreateNewLabel) + tm.Type("Create" + termtest.KeyEnter) + tm.WaitFor("Databricks profile name") + tm.Type("newprof" + termtest.KeyEnter) + // Without a host, device code login asks for one instead of starting the + // discovery login. + tm.WaitFor("Databricks host") + tm.Type(termtest.KeyCtrlC) + _, err := tm.Result() + assert.EqualError(t, err, "^C") +} diff --git a/docs/device-code-login.md b/docs/device-code-login.md index 87c96c03c16..978c951a861 100644 --- a/docs/device-code-login.md +++ b/docs/device-code-login.md @@ -33,7 +33,8 @@ Profile my-profile was successfully saved ``` 1. Run `databricks auth login` with `--use-device-code`. The host can be passed as - an argument, with `--host`, or come from an existing profile. + an argument, with `--host`, or come from an existing profile. Otherwise the + CLI prompts for it. 2. On another device, open the URL that the CLI shows, sign in to the workspace if needed, and enter the code. The code expires after about 10 minutes. From faf6adca52ba1add2ba6389591871687f41a11ec Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Mon, 26 Oct 2026 12:31:34 +0000 Subject: [PATCH 2/9] Drive the device code host prompt test with --profile On Windows, bubbletea cannot cancel its read from the test's stdin pipe, so after the profile picker quits its reader swallows the input typed for the profile name prompt, and the test timed out waiting for the host prompt. Passing --profile makes the host prompt the only prompt while still covering the skipped discovery branch. Co-authored-by: Isaac --- cmd/auth/login_device_code_test.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cmd/auth/login_device_code_test.go b/cmd/auth/login_device_code_test.go index 33282e2ea8c..877b85aa166 100644 --- a/cmd/auth/login_device_code_test.go +++ b/cmd/auth/login_device_code_test.go @@ -206,7 +206,11 @@ func TestValidateDeviceCodeHost(t *testing.T) { } } -func TestLoginDeviceCodeCreateNewProfilePromptsForHost(t *testing.T) { +// The test passes --profile instead of picking "Create a new profile" so that +// the host prompt is the only prompt. On Windows, bubbletea cannot cancel its +// read from the test's stdin pipe, so a finished prompt keeps reading and +// swallows the input typed for the next one. +func TestLoginDeviceCodeNewProfilePromptsForHost(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) @@ -218,15 +222,11 @@ func TestLoginDeviceCodeCreateNewProfilePromptsForHost(t *testing.T) { cmd := newLoginCommand(&auth.AuthArguments{}) cmd.Flags().String("profile", "", "") cmd.SetContext(ctx) - cmd.SetArgs([]string{"--use-device-code"}) + cmd.SetArgs([]string{"--use-device-code", "--profile", "newprof"}) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) return struct{}{}, cmd.Execute() }) - tm.WaitFor(profilePickerCreateNewLabel) - tm.Type("Create" + termtest.KeyEnter) - tm.WaitFor("Databricks profile name") - tm.Type("newprof" + termtest.KeyEnter) // Without a host, device code login asks for one instead of starting the // discovery login. tm.WaitFor("Databricks host") From f0568c907c61b791debd080c79a4531f0d1956ce Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Mon, 26 Oct 2026 12:34:18 +0000 Subject: [PATCH 3/9] Hide profiles that device code login always rejects from the picker With --use-device-code, the login picker leaves out profiles on a classic account host and profiles saved with resources, which device code login always rejects. Profiles on unified hosts are still listed, because the discovery URL that identifies them is not saved on the profile. isDeviceCodeSupportedForHost replaces validateDeviceCodeHost so the picker and the check after setHost share the host check. Co-authored-by: Isaac --- cmd/auth/login.go | 12 ++-- cmd/auth/login_device_code.go | 25 +++++--- cmd/auth/login_device_code_test.go | 92 ++++++++++++++++++++++++++---- docs/device-code-login.md | 5 +- 4 files changed, 107 insertions(+), 27 deletions(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index f2d117c5edb..f64bdf4614a 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -227,7 +227,11 @@ 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. 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 } @@ -332,10 +336,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. - if useDeviceCode { - if err := validateDeviceCodeHost(authArguments); err != nil { - return err - } + 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 59181563934..8e21663e884 100644 --- a/cmd/auth/login_device_code.go +++ b/cmd/auth/login_device_code.go @@ -6,9 +6,9 @@ import ( "fmt" "strings" - "github.com/databricks/cli/libs/auth" "github.com/databricks/cli/libs/auth/u2m" "github.com/databricks/cli/libs/cmdio" + "github.com/databricks/cli/libs/databrickscfg/profile" "github.com/databricks/cli/libs/env" "github.com/spf13/cobra" ) @@ -24,14 +24,21 @@ var deviceCodeIncompatibleFlags = []string{ var errDeviceCodeWorkspaceOnly = errors.New("--use-device-code is only supported for workspace hosts") -// validateDeviceCodeHost returns an error if the host resolved by setHost is -// an account or unified host. A workspace without device code login enabled -// is rejected by DeviceCodeChallenge before it creates a device code. -func validateDeviceCodeHost(authArguments *auth.AuthArguments) error { - if isAccountOrUnifiedHost(authArguments.Host, authArguments.DiscoveryURL) { - return errDeviceCodeWorkspaceOnly - } - return nil +// isDeviceCodeSupportedForHost reports whether device code login is supported +// for host, which is only the case for workspace hosts. A unified host is only +// recognized by its account-scoped discoveryURL, so with an empty +// discoveryURL only classic account hosts are rejected. A workspace without +// device code login enabled is rejected by DeviceCodeChallenge before it +// creates a device code. +func isDeviceCodeSupportedForHost(host, discoveryURL string) bool { + return !isAccountOrUnifiedHost(host, discoveryURL) +} + +// matchDeviceCodeProfiles matches the profiles that device code login does not +// always reject. Profiles on unified hosts still match, because the discovery +// URL that identifies them is not saved on the profile. +func matchDeviceCodeProfiles(p profile.Profile) bool { + return isDeviceCodeSupportedForHost(p.Host, "") && len(u2mResourcesFromProfile(&p)) == 0 } // validateDeviceCodeLogin returns an error if device code login is not enabled diff --git a/cmd/auth/login_device_code_test.go b/cmd/auth/login_device_code_test.go index 877b85aa166..b8e9b74490f 100644 --- a/cmd/auth/login_device_code_test.go +++ b/cmd/auth/login_device_code_test.go @@ -13,6 +13,7 @@ import ( "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/flags" "github.com/spf13/cobra" @@ -168,7 +169,7 @@ func TestFormatCode(t *testing.T) { assert.Equal(t, "ABC", formatCode("ABC")) } -func TestValidateDeviceCodeHost(t *testing.T) { +func TestIsDeviceCodeSupportedForHost(t *testing.T) { workspace := newDiscoveryServer(t, map[string]any{ "account_id": "acct-123", "workspace_id": "12345", @@ -185,23 +186,23 @@ func TestValidateDeviceCodeHost(t *testing.T) { }) tests := []struct { - name string - args auth.AuthArguments - wantErr error + name string + args auth.AuthArguments + want bool }{ - {name: "workspace host", args: auth.AuthArguments{Host: workspace.URL}}, - {name: "workspace host with query params", args: auth.AuthArguments{Host: workspace.URL + "?o=12345"}}, - {name: "classic account host", args: auth.AuthArguments{Host: "https://accounts.cloud.databricks.test"}, wantErr: errDeviceCodeWorkspaceOnly}, - {name: "unified host", args: auth.AuthArguments{Host: unified.URL}, wantErr: errDeviceCodeWorkspaceOnly}, - {name: "unified host with query params", args: auth.AuthArguments{Host: unified.URL + "?o=12345"}, wantErr: errDeviceCodeWorkspaceOnly}, - {name: "unified host with account ID in query params", args: auth.AuthArguments{Host: unifiedWithoutAccountID.URL + "?a=acct-123"}, wantErr: errDeviceCodeWorkspaceOnly}, - {name: "unified host with account ID flag", args: auth.AuthArguments{Host: unifiedWithoutAccountID.URL, AccountID: "acct-123"}, wantErr: errDeviceCodeWorkspaceOnly}, + {name: "workspace host", args: auth.AuthArguments{Host: workspace.URL}, want: true}, + {name: "workspace host with query params", args: auth.AuthArguments{Host: workspace.URL + "?o=12345"}, want: true}, + {name: "classic account host", args: auth.AuthArguments{Host: "https://accounts.cloud.databricks.test"}}, + {name: "unified host", args: auth.AuthArguments{Host: unified.URL}}, + {name: "unified host with query params", args: auth.AuthArguments{Host: unified.URL + "?o=12345"}}, + {name: "unified host with account ID in query params", args: auth.AuthArguments{Host: unifiedWithoutAccountID.URL + "?a=acct-123"}}, + {name: "unified host with account ID flag", args: auth.AuthArguments{Host: unifiedWithoutAccountID.URL, AccountID: "acct-123"}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { args := tt.args require.NoError(t, setHost(t.Context(), nil, &args, nil)) - assert.ErrorIs(t, validateDeviceCodeHost(&args), tt.wantErr) + assert.Equal(t, tt.want, isDeviceCodeSupportedForHost(args.Host, args.DiscoveryURL)) }) } } @@ -234,3 +235,70 @@ func TestLoginDeviceCodeNewProfilePromptsForHost(t *testing.T) { _, err := tm.Result() assert.EqualError(t, err, "^C") } + +func TestMatchDeviceCodeProfiles(t *testing.T) { + tests := []struct { + name string + profile profile.Profile + want bool + }{ + {name: "workspace host", profile: profile.Profile{Host: "https://workspace.test"}, want: true}, + // setHost prompts for the host of a profile without one. + {name: "no host", profile: profile.Profile{}, want: true}, + {name: "classic account host", profile: profile.Profile{Host: "https://accounts.cloud.databricks.test", AccountID: "acct-123"}}, + {name: "classic account host for DoD", profile: profile.Profile{Host: "https://accounts-dod.cloud.databricks.test", AccountID: "acct-123"}}, + { + name: "scoped to resources", + profile: profile.Profile{Host: "https://workspace.test", AuthType: authTypeDatabricksCLI, Resources: "https://workspace.test/ai-gateway/mcp-services/system.ai.github"}, + }, + // Login only reuses the resources of a databricks-cli profile. + { + name: "resources on another auth type", + profile: profile.Profile{Host: "https://workspace.test", AuthType: "pat", Resources: "https://workspace.test/ai-gateway/mcp-services/system.ai.github"}, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, matchDeviceCodeProfiles(tt.profile)) + }) + } +} + +func TestLoginDeviceCodePickerHidesUnsupportedProfiles(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("DATABRICKS_CONFIG_FILE", writeTempConfig(t, `[workspace] +host = https://workspace.test + +[account] +host = https://accounts.cloud.databricks.test +account_id = acct-123 + +[scoped] +host = https://workspace.test +auth_type = databricks-cli +resources = https://workspace.test/ai-gateway/mcp-services/system.ai.github +`)) + t.Setenv("DATABRICKS_AUTH_STORAGE", "plaintext") + t.Setenv(deviceCodeEnvVar, "true") + + tm := termtest.Run(t, func(ctx context.Context) (struct{}, error) { + cmd := newLoginCommand(&auth.AuthArguments{}) + cmd.Flags().String("profile", "", "") + cmd.SetContext(ctx) + cmd.SetArgs([]string{"--use-device-code"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + return struct{}{}, cmd.Execute() + }) + tm.WaitFor(profilePickerEnterHostLabel) + screen := tm.Snapshot() + assert.Contains(t, screen, "workspace") + assert.NotContains(t, screen, "account") + assert.NotContains(t, screen, "scoped") + tm.Type(termtest.KeyCtrlC) + _, err := tm.Result() + assert.EqualError(t, err, "^C") +} diff --git a/docs/device-code-login.md b/docs/device-code-login.md index 978c951a861..08f1fcab358 100644 --- a/docs/device-code-login.md +++ b/docs/device-code-login.md @@ -34,7 +34,10 @@ Profile my-profile was successfully saved 1. Run `databricks auth login` with `--use-device-code`. The host can be passed as an argument, with `--host`, or come from an existing profile. Otherwise the - CLI prompts for it. + CLI prompts for it. The profile picker leaves out profiles that device code + login cannot use: profiles for an account host and profiles saved with + `--resource`. Profiles for a unified host are still listed and are rejected + after you pick one. 2. On another device, open the URL that the CLI shows, sign in to the workspace if needed, and enter the code. The code expires after about 10 minutes. From b21c6ce5c3d2b8eb058dd17a63db1d6b54a8ec01 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Mon, 26 Oct 2026 12:44:56 +0000 Subject: [PATCH 4/9] Simplify device code login terminal tests Share the setup of the two termtest tests in startDeviceCodeLogin. Co-authored-by: Isaac --- cmd/auth/login_device_code_test.go | 39 +++++++++--------------------- 1 file changed, 12 insertions(+), 27 deletions(-) diff --git a/cmd/auth/login_device_code_test.go b/cmd/auth/login_device_code_test.go index b8e9b74490f..ba0344be131 100644 --- a/cmd/auth/login_device_code_test.go +++ b/cmd/auth/login_device_code_test.go @@ -207,29 +207,29 @@ func TestIsDeviceCodeSupportedForHost(t *testing.T) { } } -// The test passes --profile instead of picking "Create a new profile" so that -// the host prompt is the only prompt. On Windows, bubbletea cannot cancel its -// read from the test's stdin pipe, so a finished prompt keeps reading and -// swallows the input typed for the next one. -func TestLoginDeviceCodeNewProfilePromptsForHost(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{}] { home := t.TempDir() t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) - t.Setenv("DATABRICKS_CONFIG_FILE", writeTempConfig(t, "[myprof]\nhost = https://workspace.test\n")) + t.Setenv("DATABRICKS_CONFIG_FILE", writeTempConfig(t, config)) t.Setenv("DATABRICKS_AUTH_STORAGE", "plaintext") t.Setenv(deviceCodeEnvVar, "true") - tm := termtest.Run(t, func(ctx context.Context) (struct{}, error) { + return termtest.Run(t, func(ctx context.Context) (struct{}, error) { cmd := newLoginCommand(&auth.AuthArguments{}) cmd.Flags().String("profile", "", "") cmd.SetContext(ctx) - cmd.SetArgs([]string{"--use-device-code", "--profile", "newprof"}) + cmd.SetArgs(append([]string{"--use-device-code"}, args...)) cmd.SetOut(io.Discard) cmd.SetErr(io.Discard) return struct{}{}, cmd.Execute() }) - // Without a host, device code login asks for one instead of starting the - // discovery login. +} + +func TestLoginDeviceCodeNewProfilePromptsForHost(t *testing.T) { + tm := startDeviceCodeLogin(t, "", "--profile", "newprof") tm.WaitFor("Databricks host") tm.Type(termtest.KeyCtrlC) _, err := tm.Result() @@ -266,10 +266,7 @@ func TestMatchDeviceCodeProfiles(t *testing.T) { } func TestLoginDeviceCodePickerHidesUnsupportedProfiles(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - t.Setenv("USERPROFILE", home) - t.Setenv("DATABRICKS_CONFIG_FILE", writeTempConfig(t, `[workspace] + tm := startDeviceCodeLogin(t, `[workspace] host = https://workspace.test [account] @@ -280,19 +277,7 @@ account_id = acct-123 host = https://workspace.test auth_type = databricks-cli resources = https://workspace.test/ai-gateway/mcp-services/system.ai.github -`)) - t.Setenv("DATABRICKS_AUTH_STORAGE", "plaintext") - t.Setenv(deviceCodeEnvVar, "true") - - tm := termtest.Run(t, func(ctx context.Context) (struct{}, error) { - cmd := newLoginCommand(&auth.AuthArguments{}) - cmd.Flags().String("profile", "", "") - cmd.SetContext(ctx) - cmd.SetArgs([]string{"--use-device-code"}) - cmd.SetOut(io.Discard) - cmd.SetErr(io.Discard) - return struct{}{}, cmd.Execute() - }) +`) tm.WaitFor(profilePickerEnterHostLabel) screen := tm.Snapshot() assert.Contains(t, screen, "workspace") From ffcd8b0d0ea7e1b0c98eccf42d5bab4c70a5f5cc Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Mon, 26 Oct 2026 12:52:29 +0000 Subject: [PATCH 5/9] Tighten device code login comments and picker test assertions Co-authored-by: Isaac --- cmd/auth/login.go | 7 +++++-- cmd/auth/login_device_code.go | 9 +++------ cmd/auth/login_device_code_test.go | 12 ++++++------ 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index f64bdf4614a..43463c6f53f 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -225,7 +225,9 @@ a new profile is created. // the user re-login to an existing profile, create a new one, or enter // a host URL. With no profiles configured the picker still shows the // two action entries so the user can choose between web-based discovery - // (Create a new profile) and a manual host URL. + // (Create a new profile) and a manual host URL. Device code login skips + // discovery, so for it "Create a new profile" prompts for the host after + // the profile name. if profileName == "" && authArguments.Host == "" && len(args) == 0 && cmdio.IsPromptSupported(ctx) { matchProfiles := profile.MatchAllProfiles if useDeviceCode { @@ -335,7 +337,8 @@ a new profile is created. return err } // Check the host once it is resolved, before setAccountID prompts for - // an account ID for account and unified hosts. + // an account ID for account and unified hosts. A workspace without + // device code login enabled is rejected later by DeviceCodeChallenge. if useDeviceCode && !isDeviceCodeSupportedForHost(authArguments.Host, authArguments.DiscoveryURL) { return errDeviceCodeWorkspaceOnly } diff --git a/cmd/auth/login_device_code.go b/cmd/auth/login_device_code.go index 8e21663e884..971f31ea71b 100644 --- a/cmd/auth/login_device_code.go +++ b/cmd/auth/login_device_code.go @@ -24,12 +24,9 @@ var deviceCodeIncompatibleFlags = []string{ var errDeviceCodeWorkspaceOnly = errors.New("--use-device-code is only supported for workspace hosts") -// isDeviceCodeSupportedForHost reports whether device code login is supported -// for host, which is only the case for workspace hosts. A unified host is only -// recognized by its account-scoped discoveryURL, so with an empty -// discoveryURL only classic account hosts are rejected. A workspace without -// device code login enabled is rejected by DeviceCodeChallenge before it -// creates a device code. +// isDeviceCodeSupportedForHost reports whether host is a workspace host. A +// unified host is only recognized by its account-scoped discoveryURL, so with +// an empty discoveryURL only classic account hosts are rejected. func isDeviceCodeSupportedForHost(host, discoveryURL string) bool { return !isAccountOrUnifiedHost(host, discoveryURL) } diff --git a/cmd/auth/login_device_code_test.go b/cmd/auth/login_device_code_test.go index ba0344be131..f6231d20b1a 100644 --- a/cmd/auth/login_device_code_test.go +++ b/cmd/auth/login_device_code_test.go @@ -266,23 +266,23 @@ func TestMatchDeviceCodeProfiles(t *testing.T) { } func TestLoginDeviceCodePickerHidesUnsupportedProfiles(t *testing.T) { - tm := startDeviceCodeLogin(t, `[workspace] + tm := startDeviceCodeLogin(t, `[workspace-prof] host = https://workspace.test -[account] +[account-prof] host = https://accounts.cloud.databricks.test account_id = acct-123 -[scoped] +[scoped-prof] host = https://workspace.test auth_type = databricks-cli resources = https://workspace.test/ai-gateway/mcp-services/system.ai.github `) tm.WaitFor(profilePickerEnterHostLabel) screen := tm.Snapshot() - assert.Contains(t, screen, "workspace") - assert.NotContains(t, screen, "account") - assert.NotContains(t, screen, "scoped") + assert.Contains(t, screen, "workspace-prof") + assert.NotContains(t, screen, "account-prof") + assert.NotContains(t, screen, "scoped-prof") tm.Type(termtest.KeyCtrlC) _, err := tm.Result() assert.EqualError(t, err, "^C") From 47ea28ff9486ead742dd77052f98b1198a36d3d0 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Tue, 27 Oct 2026 10:24:03 +0000 Subject: [PATCH 6/9] Reject an empty host in device code login Co-authored-by: Isaac --- cmd/auth/login.go | 11 +++++++++-- cmd/auth/login_device_code.go | 5 ++++- cmd/auth/login_device_code_test.go | 4 ++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 43463c6f53f..cc4d0f4b90f 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -339,8 +339,15 @@ 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 && !isDeviceCodeSupportedForHost(authArguments.Host, authArguments.DiscoveryURL) { - return errDeviceCodeWorkspaceOnly + 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 + } } 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 971f31ea71b..84ca1b03762 100644 --- a/cmd/auth/login_device_code.go +++ b/cmd/auth/login_device_code.go @@ -22,7 +22,10 @@ var deviceCodeIncompatibleFlags = []string{ "skip-workspace", } -var errDeviceCodeWorkspaceOnly = errors.New("--use-device-code is only supported for workspace hosts") +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") +) // 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 f6231d20b1a..2ae0282d097 100644 --- a/cmd/auth/login_device_code_test.go +++ b/cmd/auth/login_device_code_test.go @@ -231,9 +231,9 @@ func startDeviceCodeLogin(t *testing.T, config string, args ...string) *termtest func TestLoginDeviceCodeNewProfilePromptsForHost(t *testing.T) { tm := startDeviceCodeLogin(t, "", "--profile", "newprof") tm.WaitFor("Databricks host") - tm.Type(termtest.KeyCtrlC) + tm.Type(termtest.KeyEnter) _, err := tm.Result() - assert.EqualError(t, err, "^C") + assert.ErrorIs(t, err, errDeviceCodeNeedsHost) } func TestMatchDeviceCodeProfiles(t *testing.T) { From cb938c0063681fdfe94082f2447aa0f6d9cb2643 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Tue, 27 Oct 2026 10:44:46 +0000 Subject: [PATCH 7/9] Reject an empty host from the device code login picker Co-authored-by: Isaac --- cmd/auth/login.go | 5 +++++ cmd/auth/login_device_code_test.go | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index cc4d0f4b90f..89a8a7731f4 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -258,6 +258,11 @@ 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. diff --git a/cmd/auth/login_device_code_test.go b/cmd/auth/login_device_code_test.go index 2ae0282d097..4070f9ab827 100644 --- a/cmd/auth/login_device_code_test.go +++ b/cmd/auth/login_device_code_test.go @@ -236,6 +236,16 @@ func TestLoginDeviceCodeNewProfilePromptsForHost(t *testing.T) { assert.ErrorIs(t, err, errDeviceCodeNeedsHost) } +func TestLoginDeviceCodePickerEmptyHost(t *testing.T) { + tm := startDeviceCodeLogin(t, "") + tm.WaitFor(profilePickerEnterHostLabel) + tm.Type(termtest.KeyDown + termtest.KeyEnter) + tm.WaitFor("Databricks host") + tm.Type(termtest.KeyEnter) + _, err := tm.Result() + assert.ErrorIs(t, err, errDeviceCodeNeedsHost) +} + func TestMatchDeviceCodeProfiles(t *testing.T) { tests := []struct { name string From 59a133e307f7d4334535190dbe32f8bc9627bdce Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Tue, 27 Oct 2026 11:47:07 +0000 Subject: [PATCH 8/9] Skip the device code picker empty host test on Windows Co-authored-by: Isaac --- cmd/auth/login_device_code_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cmd/auth/login_device_code_test.go b/cmd/auth/login_device_code_test.go index 4070f9ab827..d38d2543b7c 100644 --- a/cmd/auth/login_device_code_test.go +++ b/cmd/auth/login_device_code_test.go @@ -5,6 +5,7 @@ import ( "context" "errors" "io" + "runtime" "strings" "testing" "time" @@ -237,6 +238,12 @@ func TestLoginDeviceCodeNewProfilePromptsForHost(t *testing.T) { } func TestLoginDeviceCodePickerEmptyHost(t *testing.T) { + // termtest feeds stdin through a pipe. On Windows, bubbletea cannot + // cancel its read on a pipe, so the picker's reader is still blocked + // after it exits and takes the Enter meant for the host prompt. + if runtime.GOOS == "windows" { + t.Skip("bubbletea cannot cancel reads on non-console input on Windows") + } tm := startDeviceCodeLogin(t, "") tm.WaitFor(profilePickerEnterHostLabel) tm.Type(termtest.KeyDown + termtest.KeyEnter) From 45f4673804e2bc1991cc4a75fc5725376c36de01 Mon Sep 17 00:00:00 2001 From: Maksim Zuev Date: Tue, 27 Oct 2026 11:49:45 +0000 Subject: [PATCH 9/9] Clarify when device code login asks for the host Co-authored-by: Isaac --- cmd/auth/login.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 89a8a7731f4..5c9f322d53d 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -226,8 +226,8 @@ a new profile is created. // a host URL. With no profiles configured the picker still shows the // 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 for it "Create a new profile" prompts for the host after - // the profile name. + // 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. if profileName == "" && authArguments.Host == "" && len(args) == 0 && cmdio.IsPromptSupported(ctx) { matchProfiles := profile.MatchAllProfiles if useDeviceCode {