diff --git a/acceptance/cmd/auth/login/device-code/non-interactive/nominal/out.databrickscfg b/acceptance/cmd/auth/login/device-code/non-interactive/nominal/out.databrickscfg index cf14685849c..063dca2ee54 100644 --- a/acceptance/cmd/auth/login/device-code/non-interactive/nominal/out.databrickscfg +++ b/acceptance/cmd/auth/login/device-code/non-interactive/nominal/out.databrickscfg @@ -8,3 +8,8 @@ auth_type = databricks-cli [__settings__] default_profile = test + +[agent] +host = [DATABRICKS_URL] +workspace_id = [WORKSPACE_ID] +auth_type = databricks-cli diff --git a/acceptance/cmd/auth/login/device-code/non-interactive/nominal/output.txt b/acceptance/cmd/auth/login/device-code/non-interactive/nominal/output.txt index 5a70dc2fd0c..9ffa6ba797e 100644 --- a/acceptance/cmd/auth/login/device-code/non-interactive/nominal/output.txt +++ b/acceptance/cmd/auth/login/device-code/non-interactive/nominal/output.txt @@ -91,3 +91,23 @@ Profile test was successfully saved {"method": "POST", "path": "/oidc/v1/token", "raw_body": "client_id=databricks-cli&confirmation_code=FTFAQQKY&device_code=device-code-2&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"} {"method": "POST", "path": "/oidc/v1/device/auth", "raw_body": "client_id=databricks-cli&scope=offline_access+all-apis"} {"method": "POST", "path": "/oidc/v1/token", "raw_body": "client_id=databricks-cli&confirmation_code=FTFAQQKY&device_code=device-code-3&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"} + +=== An AI agent gets notes on passing the login on to the user + +>>> AGENT=test-agent [CLI] auth login --host [DATABRICKS_URL] --profile agent --use-device-code +Using a browser on another device, visit: +[DATABRICKS_URL]/device/verification + +And enter the code: +WDJB-MJHT + +Note for AI agents (test-agent): give the URL and the code above to the user. +Do not open the URL or approve the login yourself: the user must approve it. +Ask the user for the confirmation code their browser shows. + +Once finished, complete the login by running the same command with +--user-code WDJB-MJHT and --confirmation-code set to the confirmation code +provided in your browser. + +>>> AGENT=test-agent [CLI] auth login --host [DATABRICKS_URL] --profile agent --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY +Profile agent was successfully saved diff --git a/acceptance/cmd/auth/login/device-code/non-interactive/nominal/script b/acceptance/cmd/auth/login/device-code/non-interactive/nominal/script index 2f00d282b27..7996fda4427 100644 --- a/acceptance/cmd/auth/login/device-code/non-interactive/nominal/script +++ b/acceptance/cmd/auth/login/device-code/non-interactive/nominal/script @@ -31,5 +31,9 @@ trace $CLI auth login --profile test --use-device-code --user-code WDJB-MJHT --c trace cache_keys trace print_requests.py --oneline //oidc/v1/device/auth //oidc/v1/token +title "An AI agent gets notes on passing the login on to the user\n" +trace AGENT=test-agent $CLI auth login --host $DATABRICKS_HOST --profile agent --use-device-code +trace AGENT=test-agent $CLI auth login --host $DATABRICKS_HOST --profile agent --use-device-code --user-code WDJB-MJHT --confirmation-code FTFA-QQKY + # Track the .databrickscfg file that was created to surface changes. mv "./home/.databrickscfg" "./out.databrickscfg" diff --git a/cmd/auth/README.md b/cmd/auth/README.md index a1d56b7cc5f..3194021f5dd 100644 --- a/cmd/auth/README.md +++ b/cmd/auth/README.md @@ -120,6 +120,11 @@ in two steps instead: flags exchanges the pending device code instead of requesting a new one, and the rest of the login runs as usual. +Agent detection (`useragent.AgentProvider()`) does not pick the flow. It only +adds a note after the user code, in both flows, that tells the agent to pass +the login on to the user. In the flow that reads the confirmation code from +stdin, the note also tells the agent to enter the code at the prompt. + The pending login is a token store entry with `PendingLogin` set instead of a token. Its key is the login's cache key (the profile name, or the host without a profile) with a `#device-code` suffix, so the target's current token keeps diff --git a/cmd/auth/login_device_code.go b/cmd/auth/login_device_code.go index a2228c5d15d..08d3936295a 100644 --- a/cmd/auth/login_device_code.go +++ b/cmd/auth/login_device_code.go @@ -7,6 +7,7 @@ import ( "slices" "strings" + "github.com/databricks/cli/libs/agent" "github.com/databricks/cli/libs/auth/storage" "github.com/databricks/cli/libs/auth/u2m" "github.com/databricks/cli/libs/cmdio" @@ -30,6 +31,11 @@ const pendingLoginKeySuffix = "#device-code" // that cannot be completed. const startNewLoginHint = "Run the same command without --user-code and --confirmation-code to start a new login" +// deviceCodeAgentNote starts the note that tells an AI agent to pass the login +// on to the user. +const deviceCodeAgentNote = "give the URL and the code above to the user.\n" + + "Do not open the URL or approve the login yourself: the user must approve it.\n" + // deviceCodeIncompatibleFlags lists flags that device code login cannot honor. var deviceCodeIncompatibleFlags = []string{ // Resource indicators are sent on the browser authorization request, @@ -183,7 +189,8 @@ func (l nonInteractiveDeviceCodeLogin) start(ctx context.Context) error { if err != nil { return fmt.Errorf("store pending login: %w", err) } - cmdio.LogString(ctx, strings.Join(append(approvalInstructions(*da), + agentNote := deviceCodeAgentNote + "Ask the user for the confirmation code their browser shows." + cmdio.LogString(ctx, strings.Join(append(approvalInstructions(*da, agentNote), "Once finished, complete the login by running the same command with", "--user-code "+formatCode(da.UserCode)+" and --confirmation-code set to the confirmation code", "provided in your browser.", @@ -267,7 +274,9 @@ func sameCode(a, b string) bool { // and asks again. func promptForConfirmationCode(ctx context.Context, da u2m.DeviceAuthorization, lastErr error, attemptsLeft int) (string, error) { if lastErr == nil { - cmdio.LogString(ctx, strings.Join(append(approvalInstructions(da), + agentNote := deviceCodeAgentNote + "Ask the user for the confirmation code their browser shows, and enter it\n" + + `at the "Confirmation code" prompt of this command.` + cmdio.LogString(ctx, strings.Join(append(approvalInstructions(da, agentNote), "Once finished, enter the confirmation code provided in your browser.", ), "\n")) } else { @@ -290,8 +299,10 @@ func promptForConfirmationCode(ctx context.Context, da u2m.DeviceAuthorization, // approvalInstructions returns the lines that show the user where to approve // the login. Both flows print them before saying how to complete the login. -func approvalInstructions(da u2m.DeviceAuthorization) []string { - return []string{ +// An AI agent also gets a note to pass the login on to the user, whether or +// not it runs the command in a terminal. +func approvalInstructions(da u2m.DeviceAuthorization, agentNote string) []string { + lines := []string{ "Using a browser on another device, visit:", da.VerificationURI, "", @@ -299,6 +310,10 @@ func approvalInstructions(da u2m.DeviceAuthorization) []string { formatCode(da.UserCode), "", } + if note := agent.AgentNote(agentNote); note != "" { + lines = append(lines, note, "") + } + return lines } // formatCode displays an 8-character device code as two groups of four diff --git a/cmd/auth/login_device_code_test.go b/cmd/auth/login_device_code_test.go index f0fddf1e063..5987c0eaf46 100644 --- a/cmd/auth/login_device_code_test.go +++ b/cmd/auth/login_device_code_test.go @@ -94,6 +94,7 @@ func TestValidateDeviceCodeLogin(t *testing.T) { } func TestPromptForConfirmationCode(t *testing.T) { + testutil.SetAgent(t, "") tests := []struct { name string input string @@ -134,6 +135,36 @@ func TestPromptForConfirmationCode(t *testing.T) { } } +// An AI agent that runs the login in a terminal is told to enter the +// confirmation code at the prompt. +func TestPromptForConfirmationCode_Agent(t *testing.T) { + testutil.SetAgent(t, "test-agent") + var stderr bytes.Buffer + ctx := cmdio.InContext(t.Context(), cmdio.NewIO(t.Context(), flags.OutputText, strings.NewReader("FTFA-QQKY\n"), &bytes.Buffer{}, &stderr, "", "")) + + code, err := promptForConfirmationCode(ctx, u2m.DeviceAuthorization{ + UserCode: "WDJBMJHT", + VerificationURI: "https://workspace.test/device/verification", + }, nil, 3) + require.NoError(t, err) + assert.Equal(t, "FTFA-QQKY", code) + assert.True(t, strings.HasPrefix(stderr.String(), strings.Join([]string{ + "Using a browser on another device, visit:", + "https://workspace.test/device/verification", + "", + "And enter the code:", + "WDJB-MJHT", + "", + "Note for AI agents (test-agent): give the URL and the code above to the user.", + "Do not open the URL or approve the login yourself: the user must approve it.", + "Ask the user for the confirmation code their browser shows, and enter it", + `at the "Confirmation code" prompt of this command.`, + "", + "Once finished, enter the confirmation code provided in your browser.", + "Confirmation code: ", + }, "\n")), stderr.String()) +} + func TestPromptForConfirmationCode_Retry(t *testing.T) { tests := []struct { attemptsLeft int diff --git a/docs/device-code-login.md b/docs/device-code-login.md index 27e16ff37eb..6fabf6343f2 100644 --- a/docs/device-code-login.md +++ b/docs/device-code-login.md @@ -85,6 +85,20 @@ Profile my-profile was successfully saved confirmation code. It saves the profile and the token. Without `--profile`, it only stores the token for the host. +When the CLI detects an AI agent, it also prints a note after the code, with +or without a terminal. The note tells the agent to pass the URL and the code +on to you, not to approve the login itself, and to ask you for the +confirmation code: + +```console +Note for AI agents (claude-code): give the URL and the code above to the user. +Do not open the URL or approve the login yourself: the user must approve it. +Ask the user for the confirmation code their browser shows. +``` + +In a terminal, the note also tells the agent to enter the confirmation code +at the `Confirmation code` prompt of the running command. + The pending login stays on your machine, so a confirmation code that ends up somewhere else, such as an agent's conversation, cannot complete the login on another machine. diff --git a/internal/testutil/agent.go b/internal/testutil/agent.go new file mode 100644 index 00000000000..5fe469202ae --- /dev/null +++ b/internal/testutil/agent.go @@ -0,0 +1,16 @@ +package testutil + +import "github.com/databricks/databricks-sdk-go/useragent" + +// SetAgent makes useragent.AgentProvider report agent, or no agent if agent +// is empty. It clears the environment and the SDK's cached agent, so the agent +// running the tests does not leak into them. +// Note: use of this function is incompatible with parallel execution. +func SetAgent(t TestingT, agent string) { + NullEnvironment(t) + if agent != "" { + t.Setenv("AGENT", agent) + } + useragent.ClearCache() + t.Cleanup(useragent.ClearCache) +} diff --git a/libs/agent/agent_notice.go b/libs/agent/agent_notice.go index 22f63b9d5de..251e7982535 100644 --- a/libs/agent/agent_notice.go +++ b/libs/agent/agent_notice.go @@ -9,12 +9,21 @@ import ( // AgentNotice returns a notice string to append to error messages when an AI // agent is detected. Returns an empty string for non-agent callers. func AgentNotice() string { + note := AgentNote("do not retry with the flag suggested above\n" + + "unless the user has explicitly approved it. The flag bypasses a safety check\n" + + "and the operation may be irreversible.") + if note == "" { + return "" + } + return "\n\n" + note +} + +// AgentNote returns text as a note addressed to the detected AI agent. Returns +// an empty string for non-agent callers. +func AgentNote(text string) string { name := useragent.AgentProvider() if name == "" { return "" } - return fmt.Sprintf("\n\nNote for AI agents (%s): do not retry with the flag suggested above\n"+ - "unless the user has explicitly approved it. The flag bypasses a safety check\n"+ - "and the operation may be irreversible.", - name) + return fmt.Sprintf("Note for AI agents (%s): %s", name, text) } diff --git a/libs/agent/agent_notice_test.go b/libs/agent/agent_notice_test.go new file mode 100644 index 00000000000..62a3a70a8ec --- /dev/null +++ b/libs/agent/agent_notice_test.go @@ -0,0 +1,30 @@ +package agent + +import ( + "testing" + + "github.com/databricks/cli/internal/testutil" + "github.com/stretchr/testify/assert" +) + +func TestAgentNote(t *testing.T) { + testutil.SetAgent(t, "test-agent") + assert.Equal(t, "Note for AI agents (test-agent): ask the user.", AgentNote("ask the user.")) +} + +func TestAgentNoteWithoutAgent(t *testing.T) { + testutil.SetAgent(t, "") + assert.Empty(t, AgentNote("ask the user.")) +} + +func TestAgentNotice(t *testing.T) { + testutil.SetAgent(t, "test-agent") + assert.Equal(t, "\n\nNote for AI agents (test-agent): do not retry with the flag suggested above\n"+ + "unless the user has explicitly approved it. The flag bypasses a safety check\n"+ + "and the operation may be irreversible.", AgentNotice()) +} + +func TestAgentNoticeWithoutAgent(t *testing.T) { + testutil.SetAgent(t, "") + assert.Empty(t, AgentNotice()) +}