Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions acceptance/acceptance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
} {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,8 @@ auth_type = databricks-cli

[__settings__]
default_profile = test

[agent]
host = [DATABRICKS_URL]
workspace_id = [WORKSPACE_ID]
auth_type = databricks-cli
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
maksim-zuev-db marked this conversation as resolved.
Resolved
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"
8 changes: 8 additions & 0 deletions cmd/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 27 additions & 5 deletions cmd/auth/login_device_code.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
Expand Down Expand Up @@ -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."
Comment thread
maksim-zuev-db marked this conversation as resolved.
Resolved
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.",
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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.`
Comment on lines +308 to +309

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

An agent won't have an interactive terminal, so I don't think this note will work for it. Without a terminal it already gets the two-step flow and never sees this text. If the command does run in a PTY (some IDE agent terminals), the agent usually can't type into a running command, so it waits at the prompt until the login times out.

Could agent detection pick the two-step flow (start, then rerun with --user-code and --confirmation-code) even when stdin is a terminal? Then this variant of the note can go, along with the README line that says agent detection doesn't pick the flow. (If the note stays, "Confirmation code" here duplicates the AskCancellable label below, and .agents/rules/style-guide-go.md asks for a shared constant.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Some agents are able to start commands in a terminal mode, e.g. codex:

I started the command in an interactive terminal (tty: true). When it paused for input, the tool returned a session ID while keeping the
process running.
I then used write_stdin with that session ID to send your code followed by \n, which acts like pressing Enter:
write_stdin({
session_id: 48408,
chars: "XXX\n"
})

cmdio.LogString(ctx, strings.Join(append(approvalInstructions(da, agentNote),
"Once finished, enter the confirmation code provided in your browser.",
), "\n"))
} else {
Expand All @@ -314,15 +330,21 @@ 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,
"",
"And enter the code:",
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
Expand Down
31 changes: 31 additions & 0 deletions cmd/auth/login_device_code_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ func TestValidateDeviceCodeLogin(t *testing.T) {
}

func TestPromptForConfirmationCode(t *testing.T) {
testutil.SetAgent(t, "")
tests := []struct {
name string
input string
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions docs/device-code-login.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions internal/testutil/agent.go
Original file line number Diff line number Diff line change
@@ -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)
}
17 changes: 13 additions & 4 deletions libs/agent/agent_notice.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
30 changes: 30 additions & 0 deletions libs/agent/agent_notice_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
Loading