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
57 changes: 37 additions & 20 deletions cmd/root/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ type doctorProviderStatus struct {
Found bool `json:"found"`
EnvVar string `json:"env_var,omitempty"`
Source string `json:"source,omitempty"`
// FoundAs is set when the value came from DOCKER_AGENT_ENV_<EnvVar>.
FoundAs string `json:"found_as,omitempty"`
}

type doctorDMRStatus struct {
Expand Down Expand Up @@ -97,6 +99,8 @@ type doctorEnvRequirement struct {
RequiredBy string `json:"required_by"`
Found bool `json:"found"`
Source string `json:"source,omitempty"`
// FoundAs is set when the value came from DOCKER_AGENT_ENV_<EnvVar>.
FoundAs string `json:"found_as,omitempty"`
}

type doctorFlags struct {
Expand Down Expand Up @@ -213,12 +217,9 @@ func (f *doctorFlags) buildReport(ctx context.Context, agentRef string) (*doctor

// Every lookup in the report goes through the same labeled source chain so
// the provider table, the auto-selection result, and the agent-file checks
// can never disagree with each other.
providers := make([]environment.Provider, 0, len(sources))
for _, source := range sources {
providers = append(providers, source.Provider)
}
env := environment.NewMultiProvider(providers...)
// can never disagree with each other. No 1Password wrapper: doctor reports
// presence and must not trigger `op` prompts.
env := environment.NewPrefixedProvider(environment.SourceProviders(sources)...)

report := &doctorReport{ModelsGateway: f.runConfig.ModelsGateway}

Expand All @@ -238,8 +239,11 @@ func (f *doctorFlags) buildReport(ctx context.Context, agentRef string) (*doctor
for _, p := range config.CloudProviderEnvVars() {
status := doctorProviderStatus{Provider: p.Provider, EnvVars: p.EnvVars}
for _, name := range p.EnvVars {
if source, ok := findSource(ctx, sources, name); ok {
if source, foundAs, ok := findSource(ctx, sources, name); ok {
status.Found, status.EnvVar, status.Source = true, name, source
if foundAs != name {
status.FoundAs = foundAs
}
break
}
}
Expand Down Expand Up @@ -314,7 +318,7 @@ func (f *doctorFlags) buildReport(ctx context.Context, agentRef string) (*doctor
// Mirrors the run-time preflight: the Docker AI Gateway authenticates
// with the Docker Desktop JWT, not per-provider API keys.
if environment.IsDockerDomainURL(f.runConfig.ModelsGateway) {
if _, ok := findSource(ctx, sources, environment.DockerDesktopTokenEnv); !ok {
if _, _, ok := findSource(ctx, sources, environment.DockerDesktopTokenEnv); !ok {
autoStatus.Usable = false
autoIssues = append(autoIssues,
"the models gateway requires a Docker sign-in and no DOCKER_TOKEN was found; sign in to Docker Desktop or run `docker login` (check with `docker agent debug auth`)")
Expand Down Expand Up @@ -404,9 +408,12 @@ func (f *doctorFlags) checkAgentFile(ctx context.Context, ref string, cfg *lates
var missing []string
for _, name := range slices.Sorted(maps.Keys(requiredBy)) {
requirement := doctorEnvRequirement{EnvVar: name, RequiredBy: strings.Join(requiredBy[name], ", ")}
if source, ok := findSource(ctx, sources, name); ok {
if source, foundAs, ok := findSource(ctx, sources, name); ok {
requirement.Found = true
requirement.Source = source
if foundAs != name {
requirement.FoundAs = foundAs
}
} else {
missing = append(missing, name)
}
Expand Down Expand Up @@ -524,17 +531,27 @@ func providerCredentialHint(provider, envVar string) string {
return "set " + envVar
}

// findSource returns the name of the first secret source that supplies a
// non-empty value for the variable. Empty values are skipped so a source that
// merely defines the variable (e.g. an env file with `KEY=`) is not reported
// as supplying a credential.
func findSource(ctx context.Context, sources []environment.Source, name string) (string, bool) {
for _, source := range sources {
if value, ok := source.Provider.Get(ctx, name); ok && value != "" {
return source.Name, true
// findSource returns the first secret source that supplies a non-empty value
// for the variable, and the name it was found under (DOCKER_AGENT_ENV_<NAME>
// shadows <NAME> across all sources, as in the run-time chain). Empty values
// are skipped so a source that merely defines the variable (e.g. an env file
// with `KEY=`) is not reported as supplying a credential.
func findSource(ctx context.Context, sources []environment.Source, name string) (source, foundAs string, found bool) {
for _, candidate := range environment.LookupNames(name) {
for _, s := range sources {
if value, ok := s.Provider.Get(ctx, candidate); ok && value != "" {
return s.Name, candidate, true
}
Comment on lines +542 to +544

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch, fixed in fff6189, but on the runtime side rather than in doctor: the docs say an empty override is ignored, so the empty shell value should not hide the env-file override. PrefixedProvider now takes the sources and skips empty overrides source by source, which is what findSource already did. --env-from-file goes through the same NewChain, so it is covered too.

Regression tests: TestPrefixedProvider_EmptyOverrideDoesNotHideLaterSources (runtime) and TestFindSource_EmptyOverrideDoesNotHideLaterOverride, which also asserts doctor and the run-time chain agree.

}
}
return "", false
return "", "", false
}

func sourceLabel(source, foundAs string) string {
if foundAs == "" {
return source
}
return source + " (" + foundAs + ")"
}

func describeDMRStatus(status string) string {
Expand All @@ -557,7 +574,7 @@ func printDoctorReport(w io.Writer, report *doctorReport) {
fmt.Fprintln(tw, " PROVIDER\tSTATUS\tCREDENTIAL\tSOURCE")
for _, p := range report.Providers {
if p.Found {
fmt.Fprintf(tw, " %s\tfound\t%s\t%s\n", p.Provider, p.EnvVar, p.Source)
fmt.Fprintf(tw, " %s\tfound\t%s\t%s\n", p.Provider, p.EnvVar, sourceLabel(p.Source, p.FoundAs))
} else {
fmt.Fprintf(tw, " %s\tnot set\t%s\t-\n", p.Provider, credentialCandidates(p.EnvVars))
}
Expand Down Expand Up @@ -605,7 +622,7 @@ func printDoctorReport(w io.Writer, report *doctorReport) {
fmt.Fprintln(tw, " ENV VAR\tREQUIRED BY\tSTATUS\tSOURCE")
for _, r := range af.Requirements {
if r.Found {
fmt.Fprintf(tw, " %s\t%s\tfound\t%s\n", r.EnvVar, r.RequiredBy, r.Source)
fmt.Fprintf(tw, " %s\t%s\tfound\t%s\n", r.EnvVar, r.RequiredBy, sourceLabel(r.Source, r.FoundAs))
} else {
fmt.Fprintf(tw, " %s\t%s\tnot set\t-\n", r.EnvVar, r.RequiredBy)
}
Expand Down
96 changes: 94 additions & 2 deletions cmd/root/doctor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"errors"
"os"
"path/filepath"
"slices"
"testing"

"github.com/docker/cli/cli"
Expand Down Expand Up @@ -374,6 +375,22 @@ func TestDoctorCommand_AgentFileVarsSatisfied(t *testing.T) {
assert.Contains(t, output, "No issues found.")
}

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

path := writeDoctorAgentFile(t)

output, err := executeDoctor(t, []string{path}, withDoctorTestEnv(
map[string]string{"DOCKER_AGENT_ENV_OPENAI_API_KEY": "key"},
[]string{"ai/qwen3:latest"}, nil,
))

require.NoError(t, err)
assert.Regexp(t, `OPENAI_API_KEY\s+models\s+found\s+environment \(DOCKER_AGENT_ENV_OPENAI_API_KEY\)`, output)
assert.Regexp(t, `openai\s+found\s+OPENAI_API_KEY\s+environment \(DOCKER_AGENT_ENV_OPENAI_API_KEY\)`, output)
assert.Contains(t, output, "No issues found.")
}

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

Expand Down Expand Up @@ -647,6 +664,66 @@ func TestCredentialCandidates(t *testing.T) {
assert.Equal(t, "GOOGLE_API_KEY (+2 more)", credentialCandidates([]string{"GOOGLE_API_KEY", "GEMINI_API_KEY", "GOOGLE_GENAI_USE_VERTEXAI"}))
}

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

output, err := executeDoctor(t, nil, withDoctorTestEnv(
map[string]string{"DOCKER_AGENT_ENV_ANTHROPIC_API_KEY": "key"},
nil, errors.New("connection refused"),
))

require.NoError(t, err)
assert.Contains(t, output, "auto -> anthropic/claude-sonnet-5")
}

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

output, err := executeDoctor(t, []string{"--json"}, withDoctorTestEnv(
map[string]string{"DOCKER_AGENT_ENV_ANTHROPIC_API_KEY": "key"},
[]string{"ai/qwen3:latest"}, nil,
))

require.NoError(t, err)
var report doctorReport
require.NoError(t, json.Unmarshal([]byte(output), &report))
idx := slices.IndexFunc(report.Providers, func(p doctorProviderStatus) bool { return p.Provider == "anthropic" })
require.GreaterOrEqual(t, idx, 0)
assert.Equal(t, "ANTHROPIC_API_KEY", report.Providers[idx].EnvVar)
assert.Equal(t, "DOCKER_AGENT_ENV_ANTHROPIC_API_KEY", report.Providers[idx].FoundAs)
}

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

sources := []environment.Source{
{Name: "environment", Provider: environment.NewMapEnvProvider(map[string]string{"KEY": "plain"})},
{Name: "config-env-file", Provider: environment.NewMapEnvProvider(map[string]string{"DOCKER_AGENT_ENV_KEY": ""})},
}

source, foundAs, found := findSource(t.Context(), sources, "KEY")
require.True(t, found)
assert.Equal(t, "environment", source)
assert.Equal(t, "KEY", foundAs)
}

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

sources := []environment.Source{
{Name: "environment", Provider: environment.NewMapEnvProvider(map[string]string{"DOCKER_AGENT_ENV_KEY": ""})},
{Name: "config-env-file", Provider: environment.NewMapEnvProvider(map[string]string{"DOCKER_AGENT_ENV_KEY": "override"})},
}

source, foundAs, found := findSource(t.Context(), sources, "KEY")
require.True(t, found)
assert.Equal(t, "config-env-file", source)
assert.Equal(t, "DOCKER_AGENT_ENV_KEY", foundAs)

value, _ := environment.NewPrefixedProvider(environment.SourceProviders(sources)...).Get(t.Context(), "KEY")
assert.Equal(t, "override", value, "doctor and the run-time chain must agree")
}

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

Expand All @@ -656,14 +733,29 @@ func TestFindSource_SkipsSourcesWithoutValue(t *testing.T) {
{Name: "filled", Provider: environment.NewMapEnvProvider(map[string]string{"KEY": "value"})},
}

source, found := findSource(t.Context(), sources, "KEY")
source, foundAs, found := findSource(t.Context(), sources, "KEY")
require.True(t, found)
assert.Equal(t, "filled", source)
assert.Equal(t, "KEY", foundAs)

_, found = findSource(t.Context(), sources, "OTHER")
_, _, found = findSource(t.Context(), sources, "OTHER")
assert.False(t, found)
}

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

sources := []environment.Source{
{Name: "environment", Provider: environment.NewMapEnvProvider(map[string]string{"KEY": "plain"})},
{Name: "config-env-file", Provider: environment.NewMapEnvProvider(map[string]string{"DOCKER_AGENT_ENV_KEY": "prefixed"})},
}

source, foundAs, found := findSource(t.Context(), sources, "KEY")
require.True(t, found)
assert.Equal(t, "config-env-file", source)
assert.Equal(t, "DOCKER_AGENT_ENV_KEY", foundAs)
}

func TestDoctorEvaluatorGlobalProvider(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "agent.yaml")
Expand Down
20 changes: 20 additions & 0 deletions docs/guides/secrets/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,26 @@ toolsets:
GITHUB_PERSONAL_ACCESS_TOKEN: $GITHUB_PERSONAL_ACCESS_TOKEN
```

### Docker Agent-only variables

Other tools read the same variables: Claude Code reads `ANTHROPIC_API_KEY`, Codex reads `OPENAI_API_KEY`. To give Docker Agent its own value, prefix the name with `DOCKER_AGENT_ENV_`. Docker Agent looks up `DOCKER_AGENT_ENV_<NAME>` before `<NAME>`, and other tools ignore it:

```bash
export ANTHROPIC_API_KEY=sk-ant-... # used by other tools
export DOCKER_AGENT_ENV_ANTHROPIC_API_KEY="op://Personal/Anthropic/api-key" # used by Docker Agent
docker agent run agent.yaml
```

- The prefix works for every variable Docker Agent resolves through its secret chain: provider API keys, `${VAR}` references in MCP `env`, `DOCKER_TOKEN`. Voice transcription in the TUI still reads `OPENAI_API_KEY` directly.
- `DOCKER_AGENT_ENV_<NAME>` wins over `<NAME>` across all [sources](#overview) and `--env-from-file`: an override in the Docker Agent env file beats a plain shell export.
- An empty override is ignored: `export DOCKER_AGENT_ENV_ANTHROPIC_API_KEY=` falls back to an override in a later source, then to `ANTHROPIC_API_KEY`.
- Docker Agent's own settings (`DOCKER_AGENT_*`) are never overridden this way.
- The value can be a [1Password reference](#1password-references).
- `docker agent doctor` shows which name supplied each credential.

> [!NOTE]
> With `--sandbox`, the provider API keys that the sandbox proxy injects itself (such as `OPENAI_API_KEY` and `ANTHROPIC_API_KEY`) are not read through Docker Agent, so neither the prefix nor `op://` references apply to them. Other variables are resolved on the host, prefix included, and forwarded under their plain name.

## Env Files

For convenience, you can store secrets in a `.env` file and pass it to Docker Agent with `--env-from-file`:
Expand Down
7 changes: 4 additions & 3 deletions pkg/config/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,8 @@ func (runConfig *RuntimeConfig) EnvFilesError() error {
}

func (runConfig *RuntimeConfig) computedEnvProvider() environment.Provider {
defaultEnv := environment.NewDefaultProvider()
sources := environment.SourceProviders(environment.DefaultSources())
defaultEnv := environment.NewChain(sources...)

// Make env file paths absolute relative to the working directory.
absolutePaths, err := environment.AbsolutePaths(runConfig.WorkingDir, runConfig.EnvFiles)
Expand All @@ -205,6 +206,6 @@ func (runConfig *RuntimeConfig) computedEnvProvider() environment.Provider {
return defaultEnv
}

// Update the env provider to include env files
return environment.NewMultiProvider(envFilesProviders, defaultEnv)
// Env files join the same chain so overrides apply across all sources.
return environment.NewChain(append([]environment.Provider{envFilesProviders}, sources...)...)
}
15 changes: 15 additions & 0 deletions pkg/config/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,18 @@ func TestEnvFilesError(t *testing.T) {
require.Error(t, rc.Clone().EnvFilesError())
})
}

func TestEnvProvider_OverrideSpansEnvFilesAndDefaultChain(t *testing.T) {
envFile := filepath.Join(t.TempDir(), "agent.env")
require.NoError(t, os.WriteFile(envFile, []byte(
"OVERRIDE_TEST_A=from-file\nDOCKER_AGENT_ENV_OVERRIDE_TEST_B=from-file\n"), 0o600))
t.Setenv("DOCKER_AGENT_ENV_OVERRIDE_TEST_A", "from-shell")
t.Setenv("OVERRIDE_TEST_B", "from-shell")

env := (&RuntimeConfig{EnvFiles: []string{envFile}}).EnvProvider()

a, _ := env.Get(t.Context(), "OVERRIDE_TEST_A")
assert.Equal(t, "from-shell", a, "a shell override beats a plain name in --env-from-file")
b, _ := env.Get(t.Context(), "OVERRIDE_TEST_B")
assert.Equal(t, "from-file", b, "an override in --env-from-file beats a plain shell export")
}
19 changes: 12 additions & 7 deletions pkg/environment/default.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,17 +74,22 @@ func newConfigEnvFileProvider() (Provider, error) {
}

// NewDefaultProvider creates a provider chain from [DefaultSources].
// The whole chain is wrapped so that values shaped like "op://..." are resolved
// as 1Password secret references through the `op` CLI.
func NewDefaultProvider() Provider {
sources := DefaultSources()
return NewChain(SourceProviders(DefaultSources())...)
}

// NewChain looks providers up in order, with DOCKER_AGENT_ENV_<NAME> shadowing
// <NAME> across all of them, and resolves "op://" values through the 1Password
// CLI. The 1Password wrapper is outermost so an override can be a reference.
func NewChain(providers ...Provider) Provider {
return NewOnePasswordProvider(NewPrefixedProvider(providers...))
}

// SourceProviders returns the providers of sources, in order.
func SourceProviders(sources []Source) []Provider {
providers := make([]Provider, 0, len(sources))
for _, source := range sources {
providers = append(providers, source.Provider)
}

// Resolve any "op://" secret references through the 1Password CLI,
// regardless of which provider returned the value.
return NewOnePasswordProvider(NewMultiProvider(providers...))
return providers
}
10 changes: 10 additions & 0 deletions pkg/environment/default_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ func TestNewDefaultProvider_UsesDefaultSources(t *testing.T) {
assert.Equal(t, "value", value)
}

func TestNewDefaultProvider_PrefixedNameWinsAcrossSources(t *testing.T) {
dir := withTempConfigDir(t)
require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte("DOCKER_AGENT_ENV_MY_TEST_KEY=from-config-env\n"), 0o600))
t.Setenv("MY_TEST_KEY", "from-shell")

value, found := NewDefaultProvider().Get(t.Context(), "MY_TEST_KEY")
require.True(t, found)
assert.Equal(t, "from-config-env", value)
}

func defaultSourceNames() []string {
sources := DefaultSources()
names := make([]string, 0, len(sources))
Expand Down
Loading
Loading