From 9cee127ab0d0f632aad094296ebb01bb1d48fb2b Mon Sep 17 00:00:00 2001 From: simon Date: Tue, 19 May 2026 16:00:15 +0200 Subject: [PATCH 1/4] Make secure token storage the default storage mode U2M tokens for the databricks-cli auth type now write to the OS-native keyring by default. Users who need the previous file-backed cache can opt back via DATABRICKS_AUTH_STORAGE=plaintext or auth_storage = plaintext under [__settings__] in .databrickscfg; the env var takes precedence. The login-time keyring probe and fallback (already on main) activate with this change. Co-authored-by: Isaac --- NEXT_CHANGELOG.md | 2 ++ .../auth/describe/u2m-json-output/output.txt | 4 ++-- .../describe/u2m-plaintext-default/test.toml | 3 --- .../out.test.toml | 0 .../output.txt | 4 ++-- .../script | 0 .../auth/describe/u2m-secure-default/test.toml | 11 +++++++++++ acceptance/script.prepare | 6 ++++++ cmd/auth/describe_test.go | 14 +++++++------- libs/auth/storage/cache.go | 18 +++--------------- libs/auth/storage/cache_test.go | 6 +++--- libs/auth/storage/mode.go | 18 +++++++++++------- libs/auth/storage/mode_test.go | 16 ++++++++-------- 13 files changed, 55 insertions(+), 47 deletions(-) delete mode 100644 acceptance/cmd/auth/describe/u2m-plaintext-default/test.toml rename acceptance/cmd/auth/describe/{u2m-plaintext-default => u2m-secure-default}/out.test.toml (100%) rename acceptance/cmd/auth/describe/{u2m-plaintext-default => u2m-secure-default}/output.txt (78%) rename acceptance/cmd/auth/describe/{u2m-plaintext-default => u2m-secure-default}/script (100%) create mode 100644 acceptance/cmd/auth/describe/u2m-secure-default/test.toml diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index d85528c1e59..c33fa1ea56f 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -4,6 +4,8 @@ ### Notable Changes +* Breaking change: U2M (`auth_type = databricks-cli`) tokens are now stored in the OS-native secure store by default (Keychain on macOS, Credential Manager on Windows, Secret Service on Linux) instead of `~/.databricks/token-cache.json`. After upgrading, run `databricks auth login` once per profile to re-authenticate; cached tokens from older versions are not migrated. To keep the previous file-backed storage, set `DATABRICKS_AUTH_STORAGE=plaintext` or add `auth_storage = plaintext` under `[__settings__]` in `~/.databrickscfg` (the env var takes precedence over the config setting), then re-run `databricks auth login`. + ### CLI * Added `databricks aitools` command group for installing Databricks skills into your coding agents (Claude Code, Cursor, Codex CLI, OpenCode, GitHub Copilot, Antigravity). Skills are fetched from [github.com/databricks/databricks-agent-skills](https://github.com/databricks/databricks-agent-skills) and either symlinked into each agent's skills directory or copied into the current project. Use `databricks aitools install` to set up, `update` to pull newer versions, `list` to see what's available, and `uninstall` to remove them. diff --git a/acceptance/cmd/auth/describe/u2m-json-output/output.txt b/acceptance/cmd/auth/describe/u2m-json-output/output.txt index 7e2ac070cbc..bd5e6108735 100644 --- a/acceptance/cmd/auth/describe/u2m-json-output/output.txt +++ b/acceptance/cmd/auth/describe/u2m-json-output/output.txt @@ -2,7 +2,7 @@ >>> [CLI] auth describe --profile u2m-profile --output json Warn: [hostmetadata] failed to fetch host metadata for https://u2m-profile.databricks.test, will skip for 1m0s { - "mode": "plaintext", - "location": "~/.databricks/token-cache.json", + "mode": "secure", + "location": "OS keyring (service: databricks-cli)", "source": "default" } diff --git a/acceptance/cmd/auth/describe/u2m-plaintext-default/test.toml b/acceptance/cmd/auth/describe/u2m-plaintext-default/test.toml deleted file mode 100644 index 36c0e7e237b..00000000000 --- a/acceptance/cmd/auth/describe/u2m-plaintext-default/test.toml +++ /dev/null @@ -1,3 +0,0 @@ -Ignore = [ - "home" -] diff --git a/acceptance/cmd/auth/describe/u2m-plaintext-default/out.test.toml b/acceptance/cmd/auth/describe/u2m-secure-default/out.test.toml similarity index 100% rename from acceptance/cmd/auth/describe/u2m-plaintext-default/out.test.toml rename to acceptance/cmd/auth/describe/u2m-secure-default/out.test.toml diff --git a/acceptance/cmd/auth/describe/u2m-plaintext-default/output.txt b/acceptance/cmd/auth/describe/u2m-secure-default/output.txt similarity index 78% rename from acceptance/cmd/auth/describe/u2m-plaintext-default/output.txt rename to acceptance/cmd/auth/describe/u2m-secure-default/output.txt index 981244ff8d9..de16f4551db 100644 --- a/acceptance/cmd/auth/describe/u2m-plaintext-default/output.txt +++ b/acceptance/cmd/auth/describe/u2m-secure-default/output.txt @@ -1,8 +1,8 @@ >>> [CLI] auth describe --profile u2m-profile Warn: [hostmetadata] failed to fetch host metadata for https://u2m-profile.databricks.test, will skip for 1m0s -Unable to authenticate: error getting token: cache: token not found -Token storage: plaintext, ~/.databricks/token-cache.json (from default) +Unable to authenticate: error getting token: [KEYRING_LOOKUP_ERROR] +Token storage: secure, OS keyring (service: databricks-cli) (from default) ----- Current configuration: ✓ host: https://u2m-profile.databricks.test (from ./home/.databrickscfg config file) diff --git a/acceptance/cmd/auth/describe/u2m-plaintext-default/script b/acceptance/cmd/auth/describe/u2m-secure-default/script similarity index 100% rename from acceptance/cmd/auth/describe/u2m-plaintext-default/script rename to acceptance/cmd/auth/describe/u2m-secure-default/script diff --git a/acceptance/cmd/auth/describe/u2m-secure-default/test.toml b/acceptance/cmd/auth/describe/u2m-secure-default/test.toml new file mode 100644 index 00000000000..784352740f5 --- /dev/null +++ b/acceptance/cmd/auth/describe/u2m-secure-default/test.toml @@ -0,0 +1,11 @@ +Ignore = [ + "home" +] + +# Normalize the platform-specific keyring lookup error. macOS returns +# cache.ErrNotFound for a clean miss; Linux without D-Bus returns a +# backend-specific error. The point of this test is the resolved storage +# mode, not the lookup outcome. +[[Repls]] +Old = 'Unable to authenticate: error getting token: .*' +New = 'Unable to authenticate: error getting token: [KEYRING_LOOKUP_ERROR]' diff --git a/acceptance/script.prepare b/acceptance/script.prepare index b158ca3c74e..b33141f9493 100644 --- a/acceptance/script.prepare +++ b/acceptance/script.prepare @@ -1,3 +1,9 @@ +# Force plaintext token storage so acceptance tests exercise the file-backed +# cache rather than the OS keyring, which is not reliably reachable in CI. +# Tests that want to exercise the secure path or the resolver default unset +# or override this in their own script.prepare or script. +export DATABRICKS_AUTH_STORAGE=plaintext + errcode() { # Temporarily disable 'set -e' to prevent the script from exiting on error set +e diff --git a/cmd/auth/describe_test.go b/cmd/auth/describe_test.go index 528decf1022..72fee3486bc 100644 --- a/cmd/auth/describe_test.go +++ b/cmd/auth/describe_test.go @@ -240,21 +240,21 @@ func TestResolveTokenStorageInfo(t *testing.T) { want: nil, }, { - name: "databricks-cli with default plaintext", + name: "databricks-cli with default secure", authType: authTypeDatabricksCLI, want: &tokenStorageInfo{ - Mode: "plaintext", - Location: plaintextLocation, + Mode: "secure", + Location: secureLocation, Source: "default", }, }, { - name: "databricks-cli with secure from env", + name: "databricks-cli with plaintext from env", authType: authTypeDatabricksCLI, - envValue: "secure", + envValue: "plaintext", want: &tokenStorageInfo{ - Mode: "secure", - Location: secureLocation, + Mode: "plaintext", + Location: plaintextLocation, Source: "DATABRICKS_AUTH_STORAGE environment variable", }, }, diff --git a/libs/auth/storage/cache.go b/libs/auth/storage/cache.go index d3a5fa85d29..0e3f311dede 100644 --- a/libs/auth/storage/cache.go +++ b/libs/auth/storage/cache.go @@ -59,10 +59,6 @@ func ResolveCache(ctx context.Context, override StorageMode) (cache.TokenCache, // The timeout is ambiguous (locked vs hung); a misdiagnosis fails // the final Store rather than silently downgrading to plaintext. // -// Rules 1 and 2 are dormant today: the resolver default is plaintext, so -// (mode=Secure, explicit=false) is unreachable. They activate when the -// default flips to secure at GA. -// // Login-specific. Read paths (auth token, bundle commands) keep the original // keyring error so they don't silently mint plaintext copies of tokens that // were stored in the keyring on another machine. @@ -120,12 +116,6 @@ func resolveCacheForLoginWith(ctx context.Context, override StorageMode, f cache // resolved mode and whether the user explicitly asked for it. Split out so // tests can drive the (mode, explicit) input space directly without depending // on whatever the resolver's default mode happens to be at any point in time. -// -// Pin-on-success across modes (locking in the first working behavior to -// insulate users from keyring flakiness) is intentionally not implemented -// here. It lands at GA alongside the default flip; pinning before the -// flip would freeze every default user into plaintext and make the flip a -// no-op for them. func applyLoginFallback(ctx context.Context, mode StorageMode, explicit bool, f cacheFactories) (cache.TokenCache, StorageMode, error) { switch mode { case StorageModePlaintext: @@ -168,11 +158,9 @@ func applyLoginFallback(ctx context.Context, mode StorageMode, explicit bool, f // in .databrickscfg so subsequent commands skip the (slow/blocking) keyring // probe and route straight to the file cache. // -// Only called on the (mode=Secure, explicit=false) probe-failure branch. That -// branch is unreachable today (resolver default is plaintext), so this is -// dormant infrastructure: it activates when the default flips to secure -// at GA and lets default-on-broken-keyring users avoid a 3s probe on every -// command. +// Only called on the (mode=Secure, explicit=false) probe-failure branch. +// Persisting the fallback lets default-on-broken-keyring users avoid a 3s +// probe on every command. func persistPlaintextFallback(ctx context.Context) error { configPath := env.Get(ctx, "DATABRICKS_CONFIG_FILE") return databrickscfg.SetConfiguredAuthStorage(ctx, string(StorageModePlaintext), configPath) diff --git a/libs/auth/storage/cache_test.go b/libs/auth/storage/cache_test.go index db059299dc3..21bb487f963 100644 --- a/libs/auth/storage/cache_test.go +++ b/libs/auth/storage/cache_test.go @@ -41,15 +41,15 @@ func hermetic(t *testing.T) { t.Setenv("DATABRICKS_CONFIG_FILE", filepath.Join(t.TempDir(), "databrickscfg")) } -func TestResolveCache_DefaultsToPlaintextFile(t *testing.T) { +func TestResolveCache_DefaultsToSecureKeyring(t *testing.T) { hermetic(t) ctx := t.Context() got, mode, err := resolveCacheWith(ctx, "", fakeFactories(t)) require.NoError(t, err) - assert.Equal(t, StorageModePlaintext, mode) - assert.Equal(t, "file", got.(stubCache).source) + assert.Equal(t, StorageModeSecure, mode) + assert.Equal(t, "keyring", got.(stubCache).source) } func TestResolveCache_OverrideSecureUsesKeyring(t *testing.T) { diff --git a/libs/auth/storage/mode.go b/libs/auth/storage/mode.go index 6dd3c6e5dff..60c285f51ad 100644 --- a/libs/auth/storage/mode.go +++ b/libs/auth/storage/mode.go @@ -1,9 +1,10 @@ // Package storage selects and constructs the CLI's U2M token storage backend. // -// Two modes are supported. Plaintext writes to ~/.databricks/token-cache.json -// with host-key dual-write for older Go SDK versions (v0.61-v0.103); it is the -// resolver default. Secure writes to the OS-native keyring under the profile -// cache key only; it is opt-in pre-GA and slated to become the default at GA. +// Two modes are supported. Secure writes to the OS-native keyring under the +// profile cache key only; it is the resolver default. Plaintext writes to +// ~/.databricks/token-cache.json with host-key dual-write for older Go SDK +// versions (v0.61-v0.103); it is the opt-in fallback for environments where +// the OS keyring is not available. package storage import ( @@ -26,12 +27,15 @@ const ( // StorageModePlaintext writes tokens to ~/.databricks/token-cache.json // and mirrors each token under the legacy host-based cache key for - // older Go SDK versions (v0.61-v0.103). This is the resolver default. + // older Go SDK versions (v0.61-v0.103). Opt-in via DATABRICKS_AUTH_STORAGE + // or [__settings__].auth_storage for environments where the OS keyring + // is not available. StorageModePlaintext StorageMode = "plaintext" // StorageModeSecure writes tokens to the OS-native secure store // (macOS Keychain, Windows Credential Manager, Linux Secret Service) // under the profile cache key only. No host-key entry is written. + // This is the resolver default. StorageModeSecure StorageMode = "secure" ) @@ -101,7 +105,7 @@ func ParseMode(raw string) StorageMode { // 1. override (typically from a command-level flag such as --secure-storage). // 2. DATABRICKS_AUTH_STORAGE env var. // 3. [__settings__].auth_storage in .databrickscfg. -// 4. StorageModePlaintext. +// 4. StorageModeSecure. // // StorageModeUnknown as override means "no flag set; fall through." The // override is trusted to be a valid StorageMode: callers that parse user @@ -138,7 +142,7 @@ func ResolveStorageModeWithSource(ctx context.Context, override StorageMode) (St return mode, StorageSourceConfig, err } - return StorageModePlaintext, StorageSourceDefault, nil + return StorageModeSecure, StorageSourceDefault, nil } func parseFromSource(raw, source string) (StorageMode, error) { diff --git a/libs/auth/storage/mode_test.go b/libs/auth/storage/mode_test.go index 4c6cf0e1614..431cc903801 100644 --- a/libs/auth/storage/mode_test.go +++ b/libs/auth/storage/mode_test.go @@ -41,7 +41,7 @@ func TestResolveStorageMode(t *testing.T) { }{ { name: "default when nothing is set", - want: StorageModePlaintext, + want: StorageModeSecure, }, { name: "override wins over env and config", @@ -63,8 +63,8 @@ func TestResolveStorageMode(t *testing.T) { }, { name: "config sets mode when env and override unset", - configBody: "[__settings__]\nauth_storage = secure\n", - want: StorageModeSecure, + configBody: "[__settings__]\nauth_storage = plaintext\n", + want: StorageModePlaintext, }, { name: "env value is case-insensitive and trimmed", @@ -141,19 +141,19 @@ func TestResolveStorageModeWithSource(t *testing.T) { }{ { name: "default", - wantMode: StorageModePlaintext, + wantMode: StorageModeSecure, wantSource: StorageSourceDefault, }, { name: "override", - override: StorageModeSecure, - wantMode: StorageModeSecure, + override: StorageModePlaintext, + wantMode: StorageModePlaintext, wantSource: StorageSourceOverride, }, { name: "env", - envValue: "secure", - wantMode: StorageModeSecure, + envValue: "plaintext", + wantMode: StorageModePlaintext, wantSource: StorageSourceEnvVar, }, { From be527b05fe2758b5ea69e35fe70df0dda7a637f7 Mon Sep 17 00:00:00 2001 From: simon Date: Tue, 19 May 2026 16:22:20 +0200 Subject: [PATCH 2/4] Pin secure mode after first successful login When the resolver returns secure from the default (no env, no config), login now writes auth_storage = secure to [__settings__] after the keyring Store succeeds. Subsequent invocations see source=Config, so the explicit-secure branch of applyLoginFallback surfaces a transient keyring probe failure as an error instead of silently demoting the user to plaintext. Without this pin, a working secure-storage user could get stranded on the file cache after a single flaky probe. No-op when mode is plaintext (silent fallback already happened) or when the user already chose a mode explicitly. Persistence failures are logged at debug and never block login. Co-authored-by: Isaac --- cmd/auth/login.go | 7 ++- cmd/auth/token.go | 1 + libs/auth/storage/cache.go | 32 +++++++++++++ libs/auth/storage/cache_test.go | 81 +++++++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 1 deletion(-) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index d45a77806fd..415db7df67b 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -295,7 +295,11 @@ a new profile is created. return err } // At this point, an OAuth token has been successfully minted and stored - // in the CLI cache. The rest of the command focuses on: + // in the CLI cache. Pin the resolved storage mode so a transient + // keyring failure on a future login can no longer silently demote a + // working secure-storage user to plaintext. + storage.PinSecureMode(ctx, mode) + // The rest of the command focuses on: // 1. Workspace selection for SPOG hosts (best-effort); // 2. Configuring cluster and serverless; // 3. Saving the profile. @@ -633,6 +637,7 @@ func discoveryLogin(ctx context.Context, in discoveryLoginInputs) error { if err := persistentAuth.Challenge(); err != nil { return discoveryErr("login via login.databricks.com failed", err) } + storage.PinSecureMode(ctx, in.mode) discoveredHost := arg.GetDiscoveredHost() if discoveredHost == "" { diff --git a/cmd/auth/token.go b/cmd/auth/token.go index d7c25ecece3..26fd0a7b047 100644 --- a/cmd/auth/token.go +++ b/cmd/auth/token.go @@ -433,6 +433,7 @@ func runInlineLogin(ctx context.Context, profiler profile.Profiler, tokenCache c if err = persistentAuth.Challenge(); err != nil { return "", nil, err } + storage.PinSecureMode(ctx, mode) clearKeys := oauthLoginClearKeys() clearKeys = append(clearKeys, databrickscfg.ExperimentalIsUnifiedHostKey) diff --git a/libs/auth/storage/cache.go b/libs/auth/storage/cache.go index 0e3f311dede..891747f6e6a 100644 --- a/libs/auth/storage/cache.go +++ b/libs/auth/storage/cache.go @@ -165,3 +165,35 @@ func persistPlaintextFallback(ctx context.Context) error { configPath := env.Get(ctx, "DATABRICKS_CONFIG_FILE") return databrickscfg.SetConfiguredAuthStorage(ctx, string(StorageModePlaintext), configPath) } + +// PinSecureMode persists auth_storage = secure to [__settings__] when the +// user is currently on the secure-from-default path: mode resolved to secure +// without an explicit override, env var, or config setting. +// +// Call this after a successful keyring write (post-Challenge in login). Once +// pinned, subsequent invocations see source=Config and the explicit-secure +// branch of applyLoginFallback returns an error instead of silently demoting +// to plaintext, so a transient keyring probe failure cannot strand a working +// user on the file cache. +// +// No-op when mode is not secure (e.g. silent plaintext fallback already +// happened) or when the user already chose a mode explicitly. Persisting +// failures are logged at debug; pinning is best-effort and must not block +// login. +func PinSecureMode(ctx context.Context, mode StorageMode) { + if mode != StorageModeSecure { + return + } + _, source, err := ResolveStorageModeWithSource(ctx, "") + if err != nil { + log.Debugf(ctx, "pin secure mode: resolve: %v", err) + return + } + if source.Explicit() { + return + } + configPath := env.Get(ctx, "DATABRICKS_CONFIG_FILE") + if err := databrickscfg.SetConfiguredAuthStorage(ctx, string(StorageModeSecure), configPath); err != nil { + log.Debugf(ctx, "pin secure mode: persist: %v", err) + } +} diff --git a/libs/auth/storage/cache_test.go b/libs/auth/storage/cache_test.go index 21bb487f963..e28136b5274 100644 --- a/libs/auth/storage/cache_test.go +++ b/libs/auth/storage/cache_test.go @@ -272,6 +272,87 @@ func TestApplyLoginFallback_ProbeTimeout_StaysOnKeyring(t *testing.T) { } } +func TestPinSecureMode(t *testing.T) { + cases := []struct { + name string + mode StorageMode + envValue string + configBody string + wantWritten string + wantSkipMsg string + }{ + { + name: "secure from default persists secure", + mode: StorageModeSecure, + wantWritten: "secure", + }, + { + name: "plaintext mode is a no-op", + mode: StorageModePlaintext, + wantWritten: "", + }, + { + name: "secure from env is a no-op", + mode: StorageModeSecure, + envValue: "secure", + wantWritten: "", + }, + { + name: "secure from config is a no-op (already pinned)", + mode: StorageModeSecure, + configBody: "[__settings__]\nauth_storage = secure\n", + wantWritten: "secure", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + hermetic(t) + ctx := t.Context() + configPath := env.Get(ctx, "DATABRICKS_CONFIG_FILE") + if tc.configBody != "" { + require.NoError(t, os.WriteFile(configPath, []byte(tc.configBody), 0o600)) + } + if tc.envValue != "" { + ctx = env.Set(ctx, EnvVar, tc.envValue) + } + + PinSecureMode(ctx, tc.mode) + + got, err := databrickscfg.GetConfiguredAuthStorage(ctx, configPath) + require.NoError(t, err) + assert.Equal(t, tc.wantWritten, got) + }) + } +} + +func TestPinSecureMode_IsIdempotent(t *testing.T) { + hermetic(t) + ctx := t.Context() + configPath := env.Get(ctx, "DATABRICKS_CONFIG_FILE") + + PinSecureMode(ctx, StorageModeSecure) + first, err := databrickscfg.GetConfiguredAuthStorage(ctx, configPath) + require.NoError(t, err) + require.Equal(t, "secure", first) + + // Second call should see source=Config and skip the write. + PinSecureMode(ctx, StorageModeSecure) + second, err := databrickscfg.GetConfiguredAuthStorage(ctx, configPath) + require.NoError(t, err) + assert.Equal(t, "secure", second) +} + +func TestPinSecureMode_PersistFailureIsSwallowed(t *testing.T) { + hermetic(t) + ctx := t.Context() + // Point DATABRICKS_CONFIG_FILE at an unwritable path so SetConfiguredAuthStorage fails. + t.Setenv("DATABRICKS_CONFIG_FILE", filepath.Join(t.TempDir(), "no-such-dir", ".databrickscfg")) + + // Must not panic or block; failures are logged at debug. + PinSecureMode(ctx, StorageModeSecure) +} + func TestWrapForOAuthArgument(t *testing.T) { const ( host = "https://example.com" From a7eeb47a4272c78493a17538cc8af0264775625a Mon Sep 17 00:00:00 2001 From: simon Date: Tue, 19 May 2026 16:51:32 +0200 Subject: [PATCH 3/4] Address self-review nits - login.go: move PinSecureMode call out of the existing comment block so the "At this point... / The rest of the command focuses on" narration stays together - cache.go: trim PinSecureMode doc comment and acknowledge that concurrent logins racing the write is benign because both write the same value - cache_test.go: drop the unused wantSkipMsg struct field; strengthen TestPinSecureMode_PersistFailureIsSwallowed to assert no file was written (and that the underlying os.OpenFile failure is the real trigger) - u2m-secure-default test.toml: rephrase the fixture comment to keep internal Go API names out of test config Co-authored-by: Isaac --- .../describe/u2m-secure-default/test.toml | 8 ++++---- cmd/auth/login.go | 11 +++++----- libs/auth/storage/cache.go | 20 ++++++++----------- libs/auth/storage/cache_test.go | 13 +++++++++--- 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/acceptance/cmd/auth/describe/u2m-secure-default/test.toml b/acceptance/cmd/auth/describe/u2m-secure-default/test.toml index 784352740f5..d0d5090e6a5 100644 --- a/acceptance/cmd/auth/describe/u2m-secure-default/test.toml +++ b/acceptance/cmd/auth/describe/u2m-secure-default/test.toml @@ -2,10 +2,10 @@ Ignore = [ "home" ] -# Normalize the platform-specific keyring lookup error. macOS returns -# cache.ErrNotFound for a clean miss; Linux without D-Bus returns a -# backend-specific error. The point of this test is the resolved storage -# mode, not the lookup outcome. +# This test runs against the real OS keyring at Lookup time (no writes). +# macOS produces a clean miss; Linux without a usable D-Bus session bus +# produces a backend error. Normalize both so the assertion stays on the +# resolved storage mode, not the lookup outcome. [[Repls]] Old = 'Unable to authenticate: error getting token: .*' New = 'Unable to authenticate: error getting token: [KEYRING_LOOKUP_ERROR]' diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 415db7df67b..3a290c05e97 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -294,12 +294,13 @@ a new profile is created. if err = persistentAuth.Challenge(); err != nil { return err } - // At this point, an OAuth token has been successfully minted and stored - // in the CLI cache. Pin the resolved storage mode so a transient - // keyring failure on a future login can no longer silently demote a - // working secure-storage user to plaintext. + // Lock secure mode in after a successful keyring write so a later + // transient keyring probe failure cannot silently demote this user + // to plaintext. storage.PinSecureMode(ctx, mode) - // The rest of the command focuses on: + + // At this point, an OAuth token has been successfully minted and stored + // in the CLI cache. The rest of the command focuses on: // 1. Workspace selection for SPOG hosts (best-effort); // 2. Configuring cluster and serverless; // 3. Saving the profile. diff --git a/libs/auth/storage/cache.go b/libs/auth/storage/cache.go index 891747f6e6a..8b146379588 100644 --- a/libs/auth/storage/cache.go +++ b/libs/auth/storage/cache.go @@ -167,19 +167,15 @@ func persistPlaintextFallback(ctx context.Context) error { } // PinSecureMode persists auth_storage = secure to [__settings__] when the -// user is currently on the secure-from-default path: mode resolved to secure -// without an explicit override, env var, or config setting. +// user is currently on the secure-from-default path. Once pinned, subsequent +// invocations see source=Config (explicit), so applyLoginFallback returns an +// error on a transient keyring probe failure instead of silently demoting +// the user to plaintext. // -// Call this after a successful keyring write (post-Challenge in login). Once -// pinned, subsequent invocations see source=Config and the explicit-secure -// branch of applyLoginFallback returns an error instead of silently demoting -// to plaintext, so a transient keyring probe failure cannot strand a working -// user on the file cache. -// -// No-op when mode is not secure (e.g. silent plaintext fallback already -// happened) or when the user already chose a mode explicitly. Persisting -// failures are logged at debug; pinning is best-effort and must not block -// login. +// No-op when mode is not secure or when the user already chose a mode +// explicitly. Best-effort: persistence failures are logged at debug and +// never block login. Concurrent logins racing this write is benign because +// both write the same value. func PinSecureMode(ctx context.Context, mode StorageMode) { if mode != StorageModeSecure { return diff --git a/libs/auth/storage/cache_test.go b/libs/auth/storage/cache_test.go index e28136b5274..32979c00c7b 100644 --- a/libs/auth/storage/cache_test.go +++ b/libs/auth/storage/cache_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io/fs" "os" "path/filepath" "testing" @@ -279,7 +280,6 @@ func TestPinSecureMode(t *testing.T) { envValue string configBody string wantWritten string - wantSkipMsg string }{ { name: "secure from default persists secure", @@ -346,11 +346,18 @@ func TestPinSecureMode_IsIdempotent(t *testing.T) { func TestPinSecureMode_PersistFailureIsSwallowed(t *testing.T) { hermetic(t) ctx := t.Context() - // Point DATABRICKS_CONFIG_FILE at an unwritable path so SetConfiguredAuthStorage fails. - t.Setenv("DATABRICKS_CONFIG_FILE", filepath.Join(t.TempDir(), "no-such-dir", ".databrickscfg")) + // Point DATABRICKS_CONFIG_FILE at a path whose parent does not exist. + // loadOrCreateConfigFile does not mkdir, so the underlying os.OpenFile + // fails and SetConfiguredAuthStorage returns an error. + configPath := filepath.Join(t.TempDir(), "no-such-dir", ".databrickscfg") + t.Setenv("DATABRICKS_CONFIG_FILE", configPath) // Must not panic or block; failures are logged at debug. PinSecureMode(ctx, StorageModeSecure) + + // The persist failure must not have produced any file. + _, err := os.Stat(configPath) + assert.ErrorIs(t, err, fs.ErrNotExist, "no file should have been written") } func TestWrapForOAuthArgument(t *testing.T) { From 1561bfaa9ff0cb2bac1742e161ae071c03aebe5b Mon Sep 17 00:00:00 2001 From: simon Date: Tue, 19 May 2026 17:10:35 +0200 Subject: [PATCH 4/4] Fix linux CI: don't mutate the .databrickscfg fixture during login tests After the default flipped to secure, any test that runs the login command on linux (no D-Bus) hits applyLoginFallback, which silently persists auth_storage = plaintext to whatever DATABRICKS_CONFIG_FILE points at. TestProfileHostCompatibleViaCobra points it at the checked- in cmd/auth/testdata/.databrickscfg fixture, so the test run leaves a dirty working tree and CI's `git diff --exit-code` step fails. Two changes: 1. Move ResolveCacheForLogin in login.go to run after input validation (cluster/serverless mutex + positional-arg check) rather than before. Trivially-invalid commands now fail without probing the keyring, so TestLoginRejectsPositionalArgWithHostFlag / WithProfileFlag no longer hit applyLoginFallback. The "resolve before browser step" property the original comment cared about is preserved: cache resolution still happens before NewPersistentAuth and Challenge. 2. Force plaintext via DATABRICKS_AUTH_STORAGE in TestProfileHostCompatibleViaCobra, which legitimately passes all input validation and reaches the resolver. The test is about flag compatibility, not storage mode; pinning it to plaintext keeps it hermetic. Co-authored-by: Isaac --- cmd/auth/auth_test.go | 5 +++++ cmd/auth/login.go | 19 ++++++++++--------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/cmd/auth/auth_test.go b/cmd/auth/auth_test.go index fc7e5d533d4..13aa916a906 100644 --- a/cmd/auth/auth_test.go +++ b/cmd/auth/auth_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/databricks/cli/cmd/root" + "github.com/databricks/cli/libs/auth/storage" "github.com/databricks/cli/libs/cmdctx" "github.com/databricks/cli/libs/databrickscfg/profile" "github.com/stretchr/testify/assert" @@ -114,6 +115,10 @@ func TestProfileHostConflictTokenViaCobra(t *testing.T) { // NOT with a conflict error). func TestProfileHostCompatibleViaCobra(t *testing.T) { t.Setenv("DATABRICKS_CONFIG_FILE", "./testdata/.databrickscfg") + // Force plaintext so the storage resolver does not probe the OS keyring + // and silently persist auth_storage = plaintext to the checked-in fixture + // on CI runners without a usable keyring. + t.Setenv(storage.EnvVar, "plaintext") ctx := cmdctx.GenerateExecId(t.Context()) cli := root.New(ctx) diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 3a290c05e97..4491b422106 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -144,15 +144,6 @@ a new profile is created. ctx := cmd.Context() profileName := cmd.Flag("profile").Value.String() - // Resolve the cache before the browser step so an unavailable - // keyring surfaces here rather than after OAuth. The probe also - // triggers the OS unlock prompt, which the user can answer during - // OAuth. - tokenCache, mode, err := storage.ResolveCacheForLogin(ctx, "") - if err != nil { - return err - } - // Cluster and Serverless are mutually exclusive. if configureCluster && configureServerless { return errors.New("please either configure serverless or cluster, not both") @@ -178,6 +169,16 @@ a new profile is created. } } + // Resolve the cache before the browser step so an unavailable + // keyring surfaces here rather than after OAuth. The probe also + // triggers the OS unlock prompt, which the user can answer during + // OAuth. Run after input validation so trivially-invalid commands + // fail without probing. + tokenCache, mode, err := storage.ResolveCacheForLogin(ctx, "") + if err != nil { + return err + } + // When interactive and nothing was specified, show a picker that lets // 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