From 4a2fc813791fc0a1f1b7c12ac352cf4e0cad88e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20He=CC=81ritier?= Date: Thu, 22 Oct 2026 14:02:29 +0200 Subject: [PATCH 1/4] feat(environment): read DOCKER_AGENT_ENV_-prefixed secret variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DOCKER_AGENT_ENV_ now shadows , so docker-agent can get its own value (e.g. an op:// reference) for variables that other tools such as Claude Code and Codex also read. - One chain constructor, NewChain, serves the default chain and --env-from-file, so the override spans every source; op:// values in --env-from-file are now resolved as well. - An empty override falls back to . - The prefix is a sub-namespace of DOCKER_AGENT_ and settings in that namespace are never looked up as overrides, so can never resolve to DOCKER_AGENT_REMOTE_AUTH_TOKEN. Refs #4702 Signed-off-by: Arnaud Héritier . --- pkg/config/runtime.go | 7 ++- pkg/config/runtime_test.go | 15 +++++ pkg/environment/default.go | 19 +++--- pkg/environment/default_test.go | 10 ++++ pkg/environment/prefixed.go | 46 ++++++++++++++ pkg/environment/prefixed_test.go | 100 +++++++++++++++++++++++++++++++ 6 files changed, 187 insertions(+), 10 deletions(-) create mode 100644 pkg/environment/prefixed.go create mode 100644 pkg/environment/prefixed_test.go diff --git a/pkg/config/runtime.go b/pkg/config/runtime.go index 24f8dea6f0..bb86a21332 100644 --- a/pkg/config/runtime.go +++ b/pkg/config/runtime.go @@ -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) @@ -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...)...) } diff --git a/pkg/config/runtime_test.go b/pkg/config/runtime_test.go index a73d859562..503a07a650 100644 --- a/pkg/config/runtime_test.go +++ b/pkg/config/runtime_test.go @@ -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") +} diff --git a/pkg/environment/default.go b/pkg/environment/default.go index 70c79d1b5f..09f46253a0 100644 --- a/pkg/environment/default.go +++ b/pkg/environment/default.go @@ -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_ shadowing +// 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(NewMultiProvider(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 } diff --git a/pkg/environment/default_test.go b/pkg/environment/default_test.go index a96bdd376d..bf99da7a6e 100644 --- a/pkg/environment/default_test.go +++ b/pkg/environment/default_test.go @@ -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)) diff --git a/pkg/environment/prefixed.go b/pkg/environment/prefixed.go new file mode 100644 index 0000000000..b7bd1799de --- /dev/null +++ b/pkg/environment/prefixed.go @@ -0,0 +1,46 @@ +package environment + +import ( + "context" + "strings" +) + +// EnvOverridePrefix marks a variable that overrides its unprefixed name for +// docker-agent only, e.g. DOCKER_AGENT_ENV_ANTHROPIC_API_KEY over +// ANTHROPIC_API_KEY. It is a sub-namespace of settingsPrefix so overrides never +// collide with docker-agent's own settings. +const EnvOverridePrefix = "DOCKER_AGENT_ENV_" + +const settingsPrefix = "DOCKER_AGENT_" + +// PrefixedProvider decorates another Provider so that DOCKER_AGENT_ENV_ +// shadows . This keeps values other tools cannot read (such as op:// +// references) out of variables those tools share with docker-agent. +type PrefixedProvider struct { + provider Provider +} + +func NewPrefixedProvider(provider Provider) Provider { + return &PrefixedProvider{provider: provider} +} + +// Get skips an empty override so a blank line or `export X=` cannot hide a +// valid . +func (p *PrefixedProvider) Get(ctx context.Context, name string) (string, bool) { + names := LookupNames(name) + for _, candidate := range names[:len(names)-1] { + if value, found := p.provider.Get(ctx, candidate); found && value != "" { + return value, true + } + } + return p.provider.Get(ctx, name) +} + +// LookupNames returns the names checked for name, highest precedence first; +// the last one is always name itself. +func LookupNames(name string) []string { + if strings.HasPrefix(name, settingsPrefix) { + return []string{name} + } + return []string{EnvOverridePrefix + name, name} +} diff --git a/pkg/environment/prefixed_test.go b/pkg/environment/prefixed_test.go new file mode 100644 index 0000000000..8af86a0b0c --- /dev/null +++ b/pkg/environment/prefixed_test.go @@ -0,0 +1,100 @@ +package environment + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +type recordingProvider struct { + Provider + + looked []string +} + +func (p *recordingProvider) Get(ctx context.Context, name string) (string, bool) { + p.looked = append(p.looked, name) + return p.Provider.Get(ctx, name) +} + +func TestPrefixedProvider_Get(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + stored map[string]string + lookup string + wantValue string + wantFound bool + wantLooked []string + }{ + { + name: "plain name only", + stored: map[string]string{"API_KEY": "plain"}, + lookup: "API_KEY", + wantValue: "plain", + wantFound: true, + wantLooked: []string{"DOCKER_AGENT_ENV_API_KEY", "API_KEY"}, + }, + { + name: "override only", + stored: map[string]string{"DOCKER_AGENT_ENV_API_KEY": "override"}, + lookup: "API_KEY", + wantValue: "override", + wantFound: true, + wantLooked: []string{"DOCKER_AGENT_ENV_API_KEY"}, + }, + { + name: "override wins over plain name", + stored: map[string]string{"API_KEY": "plain", "DOCKER_AGENT_ENV_API_KEY": "override"}, + lookup: "API_KEY", + wantValue: "override", + wantFound: true, + wantLooked: []string{"DOCKER_AGENT_ENV_API_KEY"}, + }, + { + name: "empty override falls back to plain name", + stored: map[string]string{"API_KEY": "plain", "DOCKER_AGENT_ENV_API_KEY": ""}, + lookup: "API_KEY", + wantValue: "plain", + wantFound: true, + wantLooked: []string{"DOCKER_AGENT_ENV_API_KEY", "API_KEY"}, + }, + { + name: "docker-agent settings are never overridden", + stored: map[string]string{"DOCKER_AGENT_NO_TOKEN_EXCHANGE": "1"}, + lookup: "DOCKER_AGENT_NO_TOKEN_EXCHANGE", + wantValue: "1", + wantFound: true, + wantLooked: []string{"DOCKER_AGENT_NO_TOKEN_EXCHANGE"}, + }, + { + name: "settings namespace does not leak into plain names", + stored: map[string]string{"DOCKER_AGENT_REMOTE_AUTH_TOKEN": "internal"}, + lookup: "REMOTE_AUTH_TOKEN", + wantFound: false, + wantLooked: []string{"DOCKER_AGENT_ENV_REMOTE_AUTH_TOKEN", "REMOTE_AUTH_TOKEN"}, + }, + { + name: "neither name set", + stored: map[string]string{}, + lookup: "API_KEY", + wantFound: false, + wantLooked: []string{"DOCKER_AGENT_ENV_API_KEY", "API_KEY"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + inner := &recordingProvider{Provider: NewMapEnvProvider(tt.stored)} + value, found := NewPrefixedProvider(inner).Get(t.Context(), tt.lookup) + + assert.Equal(t, tt.wantValue, value) + assert.Equal(t, tt.wantFound, found) + assert.Equal(t, tt.wantLooked, inner.looked) + }) + } +} From a5f5420a93d4fbbd09467706f705ff55733238ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20He=CC=81ritier?= Date: Thu, 22 Oct 2026 14:02:29 +0200 Subject: [PATCH 2/4] feat(doctor): report credentials found under DOCKER_AGENT_ENV_ names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doctor's lookup env honours the override, so auto-selection and agent-file checks match the provider table. findSource checks the override across all sources first, mirroring the run-time chain. env_var stays the canonical name; the override name is reported as found_as. Refs #4702 Signed-off-by: Arnaud Héritier --- cmd/root/doctor.go | 57 ++++++++++++++++++----------- cmd/root/doctor_test.go | 79 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 114 insertions(+), 22 deletions(-) diff --git a/cmd/root/doctor.go b/cmd/root/doctor.go index 4644ce1f45..b247a78ea2 100644 --- a/cmd/root/doctor.go +++ b/cmd/root/doctor.go @@ -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_. + FoundAs string `json:"found_as,omitempty"` } type doctorDMRStatus struct { @@ -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_. + FoundAs string `json:"found_as,omitempty"` } type doctorFlags struct { @@ -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.NewMultiProvider(environment.SourceProviders(sources)...)) report := &doctorReport{ModelsGateway: f.runConfig.ModelsGateway} @@ -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 } } @@ -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`)") @@ -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) } @@ -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_ +// shadows 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 + } } } - return "", false + return "", "", false +} + +func sourceLabel(source, foundAs string) string { + if foundAs == "" { + return source + } + return source + " (" + foundAs + ")" } func describeDMRStatus(status string) string { @@ -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)) } @@ -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) } diff --git a/cmd/root/doctor_test.go b/cmd/root/doctor_test.go index 94f4a4318b..b17bccf0fe 100644 --- a/cmd/root/doctor_test.go +++ b/cmd/root/doctor_test.go @@ -7,6 +7,7 @@ import ( "errors" "os" "path/filepath" + "slices" "testing" "github.com/docker/cli/cli" @@ -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() @@ -647,6 +664,49 @@ 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_SkipsSourcesWithoutValue(t *testing.T) { t.Parallel() @@ -656,14 +716,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") From 494ee8bd019efb15aaa9a225d778bb3ec651aff8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20He=CC=81ritier?= Date: Thu, 22 Oct 2026 14:02:30 +0200 Subject: [PATCH 3/4] docs(secrets): document DOCKER_AGENT_ENV_-prefixed variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #4702 Signed-off-by: Arnaud Héritier --- docs/guides/secrets/index.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/guides/secrets/index.md b/docs/guides/secrets/index.md index 204aba3f8c..37520940e5 100644 --- a/docs/guides/secrets/index.md +++ b/docs/guides/secrets/index.md @@ -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_` before ``, 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_` wins over `` 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, so `export DOCKER_AGENT_ENV_ANTHROPIC_API_KEY=` falls back 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`: From f630e5b333ec6ad060e501076340ea890ec0d8a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arnaud=20He=CC=81ritier?= Date: Thu, 22 Oct 2026 20:53:00 +0200 Subject: [PATCH 4/4] fix(environment): an empty override does not hide overrides in later sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PrefixedProvider read overrides through a MultiProvider, which stops at the first source that defines the variable, even when empty. An empty shell DOCKER_AGENT_ENV_X therefore hid a DOCKER_AGENT_ENV_X in the docker-agent env file, while doctor reported the latter as found. PrefixedProvider now takes the sources and skips empty overrides source by source, matching doctor and the documented behavior. Signed-off-by: Arnaud Héritier --- cmd/root/doctor.go | 2 +- cmd/root/doctor_test.go | 17 +++++++++++++++++ docs/guides/secrets/index.md | 2 +- pkg/environment/default.go | 2 +- pkg/environment/prefixed.go | 20 ++++++++++++-------- pkg/environment/prefixed_test.go | 11 +++++++++++ 6 files changed, 43 insertions(+), 11 deletions(-) diff --git a/cmd/root/doctor.go b/cmd/root/doctor.go index b247a78ea2..0fef1b28f9 100644 --- a/cmd/root/doctor.go +++ b/cmd/root/doctor.go @@ -219,7 +219,7 @@ func (f *doctorFlags) buildReport(ctx context.Context, agentRef string) (*doctor // the provider table, the auto-selection result, and the agent-file checks // can never disagree with each other. No 1Password wrapper: doctor reports // presence and must not trigger `op` prompts. - env := environment.NewPrefixedProvider(environment.NewMultiProvider(environment.SourceProviders(sources)...)) + env := environment.NewPrefixedProvider(environment.SourceProviders(sources)...) report := &doctorReport{ModelsGateway: f.runConfig.ModelsGateway} diff --git a/cmd/root/doctor_test.go b/cmd/root/doctor_test.go index b17bccf0fe..8b930f4753 100644 --- a/cmd/root/doctor_test.go +++ b/cmd/root/doctor_test.go @@ -707,6 +707,23 @@ func TestFindSource_EmptyOverrideFallsBack(t *testing.T) { 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() diff --git a/docs/guides/secrets/index.md b/docs/guides/secrets/index.md index 37520940e5..f0f37f453e 100644 --- a/docs/guides/secrets/index.md +++ b/docs/guides/secrets/index.md @@ -64,7 +64,7 @@ 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_` wins over `` 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, so `export DOCKER_AGENT_ENV_ANTHROPIC_API_KEY=` falls back to `ANTHROPIC_API_KEY`. +- 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. diff --git a/pkg/environment/default.go b/pkg/environment/default.go index 09f46253a0..b43a0b2225 100644 --- a/pkg/environment/default.go +++ b/pkg/environment/default.go @@ -82,7 +82,7 @@ func NewDefaultProvider() Provider { // 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(NewMultiProvider(providers...))) + return NewOnePasswordProvider(NewPrefixedProvider(providers...)) } // SourceProviders returns the providers of sources, in order. diff --git a/pkg/environment/prefixed.go b/pkg/environment/prefixed.go index b7bd1799de..851ab168f2 100644 --- a/pkg/environment/prefixed.go +++ b/pkg/environment/prefixed.go @@ -17,23 +17,27 @@ const settingsPrefix = "DOCKER_AGENT_" // shadows . This keeps values other tools cannot read (such as op:// // references) out of variables those tools share with docker-agent. type PrefixedProvider struct { - provider Provider + providers []Provider + chain Provider } -func NewPrefixedProvider(provider Provider) Provider { - return &PrefixedProvider{provider: provider} +// NewPrefixedProvider looks providers up in order, overrides first. +func NewPrefixedProvider(providers ...Provider) Provider { + return &PrefixedProvider{providers: providers, chain: NewMultiProvider(providers...)} } -// Get skips an empty override so a blank line or `export X=` cannot hide a -// valid . +// Get skips empty overrides source by source, so a blank line or `export X=` +// hides neither an override in a later source nor a valid . func (p *PrefixedProvider) Get(ctx context.Context, name string) (string, bool) { names := LookupNames(name) for _, candidate := range names[:len(names)-1] { - if value, found := p.provider.Get(ctx, candidate); found && value != "" { - return value, true + for _, provider := range p.providers { + if value, found := provider.Get(ctx, candidate); found && value != "" { + return value, true + } } } - return p.provider.Get(ctx, name) + return p.chain.Get(ctx, name) } // LookupNames returns the names checked for name, highest precedence first; diff --git a/pkg/environment/prefixed_test.go b/pkg/environment/prefixed_test.go index 8af86a0b0c..c754f34ae5 100644 --- a/pkg/environment/prefixed_test.go +++ b/pkg/environment/prefixed_test.go @@ -98,3 +98,14 @@ func TestPrefixedProvider_Get(t *testing.T) { }) } } + +func TestPrefixedProvider_EmptyOverrideDoesNotHideLaterSources(t *testing.T) { + t.Parallel() + + shell := NewMapEnvProvider(map[string]string{"DOCKER_AGENT_ENV_API_KEY": ""}) + envFile := NewMapEnvProvider(map[string]string{"DOCKER_AGENT_ENV_API_KEY": "override"}) + + value, found := NewPrefixedProvider(shell, envFile).Get(t.Context(), "API_KEY") + assert.True(t, found) + assert.Equal(t, "override", value) +}