diff --git a/acceptance/acceptance_test.go b/acceptance/acceptance_test.go index 29f1ccde3a3..34dd52f5cb9 100644 --- a/acceptance/acceptance_test.go +++ b/acceptance/acceptance_test.go @@ -275,6 +275,7 @@ func testAccept(t *testing.T, inprocessMode bool, selectedTests []string, skipTo "KIRO", "OPENCLAW_SHELL", "OPENCODE", + "PI_CODING_AGENT", "VSCODE_AGENT", "WINDSURF_AGENT", } { diff --git a/acceptance/cmd/auth/login/device-code/non-interactive/errors/output.txt b/acceptance/cmd/auth/login/device-code/non-interactive/errors/output.txt index d299e0be717..b763d0b0028 100644 --- a/acceptance/cmd/auth/login/device-code/non-interactive/errors/output.txt +++ b/acceptance/cmd/auth/login/device-code/non-interactive/errors/output.txt @@ -62,6 +62,12 @@ Error: no pending device code login for host [DATABRICKS_URL]. Run the same comm >>> musterr [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code --user-code WDJB-0001 --confirmation-code AAAA-AAAA Error: token exchange: Invalid confirmation code (error code: invalid_request). Check the confirmation code and approve the login if you haven't, then rerun this command. Run the same command without --user-code and --confirmation-code to start a new login if it still fails +>>> AGENT=test-agent musterr [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code --user-code WDJB-0001 --confirmation-code AAAA-AAAA +Error: token exchange: Invalid confirmation code (error code: invalid_request). Check the confirmation code and approve the login if you haven't, then rerun this command. Run the same command without --user-code and --confirmation-code to start a new login if it still fails + +Note for AI agents (test-agent): ask the user to check the confirmation code +and to approve the login if they haven't. Do not approve it yourself + === Rerun with the right confirmation code >>> [CLI] auth login --host [DATABRICKS_URL] --profile test --use-device-code --user-code wdjb-0001 --confirmation-code ftfa-qqky @@ -102,6 +108,7 @@ Error: the login for profile test was started with different scopes. Complete it >>> print_requests.py --oneline //oidc/v1/device/auth //oidc/v1/token {"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=AAAAAAAA&device_code=device-code-1&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"} +{"method": "POST", "path": "/oidc/v1/token", "raw_body": "client_id=databricks-cli&confirmation_code=AAAAAAAA&device_code=device-code-1&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"} {"method": "POST", "path": "/oidc/v1/token", "raw_body": "client_id=databricks-cli&confirmation_code=FTFAQQKY&device_code=device-code-1&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-2&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"} diff --git a/acceptance/cmd/auth/login/device-code/non-interactive/errors/script b/acceptance/cmd/auth/login/device-code/non-interactive/errors/script index 02d972a67ee..00984e93321 100644 --- a/acceptance/cmd/auth/login/device-code/non-interactive/errors/script +++ b/acceptance/cmd/auth/login/device-code/non-interactive/errors/script @@ -39,6 +39,7 @@ start_login LOG.wrong-code $CLI auth login --host $DATABRICKS_HOST --profile tes title "Wrong confirmation code\n" trace musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code --user-code WDJB-0001 --confirmation-code AAAA-AAAA +trace AGENT=test-agent musterr $CLI auth login --host $DATABRICKS_HOST --profile test --use-device-code --user-code WDJB-0001 --confirmation-code AAAA-AAAA # A wrong confirmation code leaves the device code valid, so the pending # login is kept. 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 93d79138488..234e8e2ab3a 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-0004 + +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-0004 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-0004 --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 69f9a349da6..635fb3533f2 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-0003 --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-0004 --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 acc3b50178a..33ea55d1f00 100644 --- a/cmd/auth/README.md +++ b/cmd/auth/README.md @@ -120,6 +120,14 @@ 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. When the +server rejects the confirmation code of the second step, the error gets a note +that tells the agent to ask the user to approve the login, not to approve it +itself. + 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 ddbc2709d94..4346bf61404 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, @@ -198,7 +204,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.", @@ -247,7 +254,14 @@ func (l nonInteractiveDeviceCodeLogin) complete(ctx context.Context, userCode, c case err == nil: l.deletePendingLogin(ctx) case errors.Is(err, u2m.ErrInvalidConfirmationCode): - return nil, fmt.Errorf("%w. Check the confirmation code and approve the login if you haven't, then rerun this command. %s if it still fails", err, startNewLoginHint) + err = fmt.Errorf("%w. Check the confirmation code and approve the login if you haven't, then rerun this command. %s if it still fails", err, startNewLoginHint) + // The agent runs this step, so the error above is addressed to it. + // Only the user may approve the login. + if note := agent.AgentNote("ask the user to check the confirmation code\n" + + "and to approve the login if they haven't. Do not approve it yourself"); note != "" { + err = fmt.Errorf("%w\n\n%s", err, note) + } + return nil, err case errors.Is(err, u2m.ErrDeviceCodeRateLimited): return nil, fmt.Errorf("%w. Rerun this command later. %s if it still fails", err, startNewLoginHint) case errors.Is(err, u2m.ErrInvalidDeviceCode): @@ -291,7 +305,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 { @@ -314,8 +330,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, "", @@ -323,6 +341,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 a55292e5420..1a3a7c2f713 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 97338a8a8ea..fff366167f4 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..249f575e3f3 --- /dev/null +++ b/internal/testutil/agent.go @@ -0,0 +1,14 @@ +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) + 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()) +}