Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
6a6817a
feat(desktop): prefer the secrets engine for Docker Hub authentication
Benehiko Oct 7, 2026
23a5839
docs(desktop): shorten secrets engine comments
Benehiko Oct 7, 2026
fa9c3e5
fix(desktop): warn once when the secrets engine fails
Benehiko Oct 7, 2026
eeb402e
fix(desktop): share secrets engine lookups and leave callers time to …
Benehiko Oct 8, 2026
07865e1
fix(desktop): warn when the secrets engine is there but can't be reached
Benehiko Oct 8, 2026
d0069b0
fix(desktop): name the account of the token in use
Benehiko Oct 8, 2026
ba26fc8
feat(desktop): read staging sessions from the secrets engine on staging
Benehiko Oct 8, 2026
a381ab6
fix(desktop): detect a missing secrets engine with ErrSecretsEngineNo…
Benehiko Oct 8, 2026
35a6eca
feat(desktop): log the secrets engine SDK's hint on connection failures
Benehiko Oct 12, 2026
1cad218
docs(desktop): trim secrets engine comments to one-line godocs
Benehiko Oct 12, 2026
1682280
refactor(desktop): hold the secrets engine client in a struct
Benehiko Oct 12, 2026
86219f1
refactor(desktop): pick the staging realms where the engine client is…
Benehiko Oct 12, 2026
2182659
refactor(desktop): build the secrets engine only where the SDK does
Benehiko Oct 12, 2026
9877a52
fix(desktop): log secrets engine failures to the current default logger
Benehiko Oct 12, 2026
fd8ad7e
feat(auth): show why the secrets engine supplied no token in debug auth
Benehiko Oct 12, 2026
539e7c2
fix(auth): show the secrets engine's reason, socket and hint on separ…
Benehiko Oct 12, 2026
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
59 changes: 57 additions & 2 deletions cmd/root/debug_auth.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package root

import (
"cmp"
"encoding/json"
"errors"
"fmt"
"io"
"time"

secretsengine "github.com/docker/secrets-engine/client"
"github.com/golang-jwt/jwt/v5"
"github.com/spf13/cobra"

Expand All @@ -24,6 +27,22 @@ type authInfo struct {
Expired bool `json:"expired"`
Username string `json:"username,omitempty"`
Email string `json:"email,omitempty"`

SecretsEngine *secretsEngineInfo `json:"secrets_engine,omitempty"`
}

// secretsEngineInfo says why the secrets engine supplied no token, with the SDK's reason, socket and hint.
type secretsEngineInfo struct {
Error string `json:"error"`
Reason string `json:"reason,omitempty"`
Socket string `json:"socket,omitempty"`
Hint string `json:"hint,omitempty"`
}

// noTokenInfo is the JSON output when no token is found.
type noTokenInfo struct {
Error string `json:"error"`
SecretsEngine *secretsEngineInfo `json:"secrets_engine,omitempty"`
}

func newDebugAuthCmd() *cobra.Command {
Expand All @@ -43,13 +62,16 @@ func newDebugAuthCmd() *cobra.Command {
w := cmd.OutOrStdout()

token, source := desktop.GetTokenWithSource(ctx)
engine := newSecretsEngineInfo(desktop.SecretsEngineError(), token != "")
if token == "" {
if jsonOutput {
return json.NewEncoder(w).Encode(map[string]string{
"error": "no token found (is Docker Desktop running, or are you logged in with `docker login`?)",
return json.NewEncoder(w).Encode(noTokenInfo{
Error: "no token found (is Docker Desktop running, or are you logged in with `docker login`?)",
SecretsEngine: engine,
})
}
fmt.Fprintln(w, "No token found. Is Docker Desktop running, or are you logged in with `docker login`?")
printSecretsEngineText(w, engine)
return nil
}

Expand All @@ -58,6 +80,7 @@ func newDebugAuthCmd() *cobra.Command {
return fmt.Errorf("failed to parse JWT: %w", err)
}
info.Source = string(source)
info.SecretsEngine = engine

userInfo := desktop.GetUserInfo(ctx)
info.Username = userInfo.Username
Expand Down Expand Up @@ -141,4 +164,36 @@ func printAuthInfoText(w io.Writer, info *authInfo) {
} else {
fmt.Fprintln(w, "Status: ✅ Valid")
}

printSecretsEngineText(w, info.SecretsEngine)
}

// newSecretsEngineInfo describes why the secrets engine failed, leaving out "not running" when another source supplied a token.
func newSecretsEngineInfo(err error, tokenFound bool) *secretsEngineInfo {
if err == nil || (tokenFound && errors.Is(err, secretsengine.ErrSecretsEngineNotRunning)) {
return nil
}
info := &secretsEngineInfo{Error: err.Error()}
if connErr, ok := errors.AsType[*secretsengine.ConnectError](err); ok {
if connErr.Reason != secretsengine.ReasonUnknown {
info.Reason = connErr.Reason.String()
}
info.Socket = connErr.SocketPath
info.Hint = connErr.Hint()
}
return info
}

func printSecretsEngineText(w io.Writer, info *secretsEngineInfo) {
if info == nil {
return
}
fmt.Fprintln(w)
fmt.Fprintf(w, "Secrets engine: %s\n", cmp.Or(info.Reason, info.Error))
if info.Socket != "" {
fmt.Fprintf(w, "Socket: %s\n", info.Socket)
}
if info.Hint != "" {
fmt.Fprintf(w, "Hint: %s\n", info.Hint)
}
}
81 changes: 81 additions & 0 deletions cmd/root/debug_auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"testing"
"time"

secretsengine "github.com/docker/secrets-engine/client"
"github.com/docker/secrets-engine/x/api"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -89,6 +92,84 @@ func TestPrintAuthInfoText(t *testing.T) {
assert.Contains(t, output, "✅ Valid")
}

func TestPrintAuthInfoText_SecretsEngine(t *testing.T) {
t.Parallel()

info := &authInfo{
Token: "eyJhbGciOiJIUzI1NiJ9.xxxxxxxxxxxx.yyyyyyyy1234567890",
SecretsEngine: &secretsEngineInfo{
Error: "retrieve default account metadata: permission denied connecting to the secrets engine at /run/engine.sock: dial failed",
Reason: "permission denied",
Socket: "/run/engine.sock",
Hint: "Grant access and retry.",
},
}

var buf bytes.Buffer
printAuthInfoText(&buf, info)

assert.Contains(t, buf.String(), "✅ Valid\n\nSecrets engine: permission denied\nSocket: /run/engine.sock\nHint: Grant access and retry.\n")
}

func TestPrintSecretsEngineText_WithoutReason(t *testing.T) {
t.Parallel()

var buf bytes.Buffer
printSecretsEngineText(&buf, &secretsEngineInfo{Error: "retrieve default account metadata: access denied"})

assert.Equal(t, "\nSecrets engine: retrieve default account metadata: access denied\n", buf.String())
}

func TestNewSecretsEngineInfo(t *testing.T) {
t.Parallel()

notRunning := sdkConnectError(secretsengine.ReasonNotRunning)
denied := sdkConnectError(secretsengine.ReasonPermissionDenied)
require.NotEmpty(t, notRunning.Hint())
require.NotEmpty(t, denied.Hint())

unknown := sdkConnectError(secretsengine.ReasonUnknown)
wrappedDenied := fmt.Errorf("retrieve default account metadata: %w", denied)
accessDenied := fmt.Errorf("retrieve default account metadata: %w", errors.New("access denied"))
socket := api.DesktopSocketPath()

for _, tt := range []struct {
name string
err error
tokenFound bool
want *secretsEngineInfo
}{
{name: "the engine answered"},
{name: "not running with a token from elsewhere", err: notRunning, tokenFound: true},
{
name: "not running without a token",
err: notRunning,
want: &secretsEngineInfo{Error: notRunning.Error(), Reason: secretsengine.ReasonNotRunning.String(), Socket: socket, Hint: notRunning.Hint()},
},
{
name: "permission denied with a token from elsewhere",
err: wrappedDenied,
tokenFound: true,
want: &secretsEngineInfo{Error: wrappedDenied.Error(), Reason: secretsengine.ReasonPermissionDenied.String(), Socket: socket, Hint: denied.Hint()},
},
{
name: "an unknown connection failure",
err: unknown,
want: &secretsEngineInfo{Error: unknown.Error(), Socket: socket, Hint: unknown.Hint()},
},
{name: "not a connection failure", err: accessDenied, want: &secretsEngineInfo{Error: accessDenied.Error()}},
} {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, newSecretsEngineInfo(tt.err, tt.tokenFound))
})
}
}

func sdkConnectError(reason secretsengine.ConnectReason) *secretsengine.ConnectError {
return &secretsengine.ConnectError{Reason: reason, SocketPath: api.DesktopSocketPath(), Err: errors.New("dial failed")}
}

func TestPrintAuthInfoText_Expired(t *testing.T) {
t.Parallel()

Expand Down
2 changes: 1 addition & 1 deletion docs/configuration/overview/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ See [Provider Credentials](../../providers/overview/index.md#provider-credential
| `DOCKER_AGENT_HIDE_TELEMETRY_BANNER`| Set to `1` to suppress the first-run telemetry notice. |
| `DOCKER_AGENT_AUTO_UPDATE` | Set to a truthy value (`1`, `true`, `yes`, `on`) to let standalone release binaries self-update before running. See [Optional Self-Updates](../../getting-started/installation/index.md#optional-self-updates). |
| `DOCKER_AGENT_NO_TOKEN_EXCHANGE` | Set to `1` to stop Docker Agent from exchanging the access token stored by `docker login` for a Docker token. See [Docker authentication](../../guides/secrets/index.md#docker-authentication). |
| `DOCKER_AGENT_HUB_LOGIN_URL` | Point the token exchange at a Docker staging environment. Ignored unless it is an HTTPS `docker.com` URL. |
| `DOCKER_AGENT_HUB_LOGIN_URL` | Point the token exchange at a Docker staging environment. Ignored unless it is an HTTPS `docker.com` URL. A staging host, such as `hub-stage.docker.com`, also makes Docker Agent read staging sessions from the Docker secrets engine. |
| `DOCKER_AGENT_DISABLE_DESKTOP_PROXY` | Set to a truthy value (`1`, `true`, `yes`, `on`) to bypass Docker Desktop's PAC adapter per request and restore standard `HTTP_PROXY`/`HTTPS_PROXY`/`ALL_PROXY`/`NO_PROXY` routing. |

> [!NOTE]
Expand Down
4 changes: 3 additions & 1 deletion docs/features/cli/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -797,7 +797,9 @@ $ docker agent debug oauth login agent.yaml github
>
> The text output of `debug auth` truncates the token to a short preview, but `--json` includes the complete, unredacted JWT in its `token` field. Never paste `debug auth --json` output into logs, issue trackers, or bug reports — anyone with that token can act as you against Docker. Use the plain-text output (or redact the `token` field yourself) when sharing diagnostic output.

The `Source` field says where the token came from: `docker desktop`, or `minted from the stored access token` when it was obtained by exchanging the access token `docker login` stored. See [Docker authentication](../../guides/secrets/index.md#docker-authentication).
The `Source` field says where the token came from: `docker desktop secrets engine` when it was read from the secrets engine Docker Desktop serves, `docker desktop` when Docker Desktop's backend served it, or `minted from the stored access token` when it was obtained by exchanging the access token `docker login` stored. See [Docker authentication](../../guides/secrets/index.md#docker-authentication).

When the secrets engine couldn't be read, `Secrets engine` says why, `Socket` which socket was tried, and `Hint` how to fix it. The JSON output has them under `secrets_engine` as `reason`, `socket` and `hint`, along with the full `error`. A secrets engine that isn't running is only reported when no token was found, since machines without Docker Desktop don't have one.

The `config`, `toolsets`, `tool`, `skills`, and `title` subcommands also accept [runtime configuration flags](#runtime-configuration-flags) (`--working-dir`, `--models-gateway`, …); `title` additionally accepts `--model` to override the model used to resolve the config before generating the title.

Expand Down
11 changes: 10 additions & 1 deletion docs/guides/secrets/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,16 @@ On machines where Docker Desktop is installed, Docker Agent queries Docker Deskt

## Docker Authentication

Routing model traffic through the [Docker models gateway](../../configuration/models/index.md) needs a Docker token. Docker Desktop hands out one that is valid for 15 minutes and cannot be renewed by Docker Agent, so when Desktop has nothing usable to offer — it is signed out, not running, or its own refresh is stuck — Docker Agent exchanges the long-lived access token that `docker login` left in your credential store for a fresh Docker token, the same exchange `docker login` itself performs. Signing in with `docker login` is therefore enough; Docker Desktop is not required.
Routing model traffic through the [Docker models gateway](../../configuration/models/index.md) needs a Docker token. A `DOCKER_TOKEN` you provide yourself — an environment variable, a Compose secret, an env file or a credential helper, as is common in CI — always takes precedence, and none of the sources below are consulted.

Otherwise, Docker Agent tries these sources in order and uses the first token that is not expired or about to expire:

1. **The Docker secrets engine**, which Docker Desktop serves on a local socket and which holds the session of the account signed in to Desktop. It is skipped when it is unavailable (Docker Desktop is not installed or not running, or predates the engine) or holds no session. A request waits for the engine for at most five seconds, and never more than half of its own time limit, so the other sources still get a turn. An engine that fails or does not answer is skipped for the next 30 seconds. Production sessions are read unless `DOCKER_AGENT_HUB_LOGIN_URL` points at a Docker Hub staging host, such as `hub-stage.docker.com`; staging sessions are read instead. Docker Agent cannot tell which environment Docker Desktop is signed in to, so set that variable when Desktop is signed in to staging.
2. **Docker Desktop's backend.**
3. **The access token `docker login` stored.** Docker Agent exchanges the long-lived access token in your credential store for a fresh Docker token, the same exchange `docker login` itself performs. Signing in with `docker login` is therefore enough; Docker Desktop is not required.
4. **A refresh of Docker Desktop's session**, as a last resort.

The tokens Docker Desktop hands out are valid for 15 minutes, and only Desktop can renew them. That is why the exchange exists: it keeps Docker Agent working when Desktop has nothing usable to offer because it is signed out, not running, or its own refresh is stuck. Tokens are looked up again as they near expiry, so a long-running session keeps working.

Only Docker access tokens are exchanged — the `dckr_…` secrets `docker login` stores — never an account password, and the exchange goes to Docker Hub over HTTPS. The resulting bearer token is cached in a private file under Docker Agent's cache directory so sibling processes reuse it instead of minting their own, and it stops being used within seconds of a `docker logout` or an account switch. Run `docker agent debug auth` to see which token is in use and where it came from.

Expand Down
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ require (
github.com/docker/cli v29.8.1+incompatible
github.com/docker/go-units v0.5.0
github.com/docker/portcullis v1.1.0
github.com/docker/secrets-engine/client v0.2.0
github.com/docker/secrets-engine/x v0.8.3
github.com/dop251/goja v0.0.0-20260917113740-793a2a65c13b
github.com/expr-lang/expr v1.17.8
github.com/fatih/color v1.19.0
Expand Down Expand Up @@ -106,6 +108,7 @@ require (
cloud.google.com/go/auth v0.23.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
connectrpc.com/connect v1.19.1 // indirect
dario.cat/mergo v1.0.2 // indirect
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14=
connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs=
Expand Down Expand Up @@ -180,6 +182,10 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/docker/portcullis v1.1.0 h1:c45WCRlsQfRxX3hLBqsaKI5/AbA+mdiNNgyeSl4bQZg=
github.com/docker/portcullis v1.1.0/go.mod h1:Mi8911/f4iLlXzAPVR/FFU9Hflze+7PDoxVzyQqHzGs=
github.com/docker/secrets-engine/client v0.2.0 h1:69Q63N5VDbXbxPPgm0LjUtRJ4O41q4cQeFpUxsHZUJo=
github.com/docker/secrets-engine/client v0.2.0/go.mod h1:dL3y9wh+HVHMTInwOOmAmjai6oARC8T+se30RnWgrLs=
github.com/docker/secrets-engine/x v0.8.3 h1:0rt91D54Ah6vI96qbc0Qcaxbkd0n36QAkQm+2614FzA=
github.com/docker/secrets-engine/x v0.8.3/go.mod h1:+s6rb56pq1B376jzzkGqZreFJ3mUtkz+kaYlMuj7/Qs=
github.com/dop251/goja v0.0.0-20260917113740-793a2a65c13b h1:UMDLDHFR1Chu3qnsPNCrVxq0lZgG6JqHpLL5+iqfSkw=
github.com/dop251/goja v0.0.0-20260917113740-793a2a65c13b/go.mod h1:u8yZRUavu+N4EnFFy6J5fVtjE7lEcZ2YyV2GcBXY9c8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
Expand Down
36 changes: 15 additions & 21 deletions pkg/desktop/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ type DockerHubInfo struct {
type Source string

const (
SourceNone Source = "none"
SourceDesktop Source = "docker desktop"
SourceMinted Source = "minted from the stored access token"
SourceNone Source = "none"
SourceSecretsEngine Source = "docker desktop secrets engine"
SourceDesktop Source = "docker desktop"
SourceMinted Source = "minted from the stored access token"
)

// mintToken exchanges the stored access token for a fresh one. A var so tests
Expand Down Expand Up @@ -58,18 +59,16 @@ func GetToken(ctx context.Context) string {
return token
}

// GetTokenWithSource returns the user's Docker access token and where it came
// from. Docker Desktop's newer auth stack (auth v2) serves whatever its
// in-memory token source holds and never refreshes on GET, so a stuck
// background refresher makes it return the same expired JWT forever — or
// nothing at all when its read-time refresh failed. When that happens we mint
// a token ourselves from the access token `docker login` stored, and only then
// fall back to nudging Desktop.
// GetTokenWithSource returns the user's Docker access token and where it came from.
func GetTokenWithSource(ctx context.Context) (string, Source) {
if token, source, ok := cached(); ok {
return token, source
}

if token, ok := secretsEngineToken(ctx); ok {
return token, SourceSecretsEngine
Comment thread
Benehiko marked this conversation as resolved.
}

token, err := fetchToken(ctx)
if err == nil && usable(token) && remember(token, SourceDesktop) {
return token, SourceDesktop
Expand Down Expand Up @@ -217,20 +216,15 @@ func expiresIn(token string) string {
return time.Until(exp).Round(time.Second).String()
}

// GetUserInfo returns the signed-in account. Docker Desktop knows it best, but
// it is not always around: the token itself carries the same information.
// GetUserInfo returns the account of the token [GetToken] serves, else Docker Desktop's.
func GetUserInfo(ctx context.Context) DockerHubInfo {
var info DockerHubInfo
_ = ClientBackend.Get(ctx, "/registry/info", &info)
if info.Username != "" {
return info
if identity, ok := hubauth.IdentityFromToken(GetToken(ctx)); ok {
return DockerHubInfo{Username: identity.Username, Email: identity.Email}
}

identity, ok := hubauth.IdentityFromToken(GetToken(ctx))
if !ok {
return info
}
return DockerHubInfo{Username: identity.Username, Email: identity.Email}
var info DockerHubInfo
_ = ClientBackend.Get(ctx, "/registry/info", &info)
return info
}

func fetchToken(ctx context.Context) (string, error) {
Expand Down
Loading
Loading