From 1b1aac3b6713b3102c31263e888ffc2a54fabbce Mon Sep 17 00:00:00 2001 From: Sayt-0 Date: Tue, 7 Jul 2026 11:37:20 +0200 Subject: [PATCH 1/3] feat(environment): add secret stores and a default config env file source Write-side counterparts of the read-side secret providers, for the setup wizard: macOS Keychain (security -i, value over stdin, never in argv), pass (insert -e -f, value over stdin), and an env file at ~/.config/cagent/.env (0600, atomic write, upsert preserving other lines). The config env file becomes a default secret source (config-env-file, between run-secrets and credential-helper) so stored keys resolve on every run without flags and show up in docker agent doctor. A missing file is skipped; a malformed one logs a warning instead of blocking the chain, unlike an explicit --env-from-file which stays fatal. --- pkg/environment/default.go | 33 ++++- pkg/environment/default_test.go | 45 +++++++ pkg/environment/store.go | 216 ++++++++++++++++++++++++++++++++ pkg/environment/store_test.go | 132 +++++++++++++++++++ 4 files changed, 423 insertions(+), 3 deletions(-) create mode 100644 pkg/environment/store.go create mode 100644 pkg/environment/store_test.go diff --git a/pkg/environment/default.go b/pkg/environment/default.go index 8938782762..391e4479c1 100644 --- a/pkg/environment/default.go +++ b/pkg/environment/default.go @@ -1,6 +1,9 @@ package environment import ( + "log/slog" + "os" + "github.com/docker/docker-agent/pkg/paths" "github.com/docker/docker-agent/pkg/userconfig" ) @@ -14,9 +17,9 @@ type Source struct { } // DefaultSources returns the ordered, labeled secret sources that make up the -// default provider chain: OS env, run secrets, credential helper (if -// configured), Docker Desktop, pass, and keychain. Lookup precedence is the -// slice order. +// default provider chain: OS env, run secrets, the docker agent env file +// (/.env, when present), credential helper (if configured), +// Docker Desktop, pass, and keychain. Lookup precedence is the slice order. // // When running inside a Docker sandbox (detected via SANDBOX_VM_ID), a // [SandboxTokenProvider] is prepended so that DOCKER_TOKEN is read from the @@ -41,6 +44,16 @@ func DefaultSources() []Source { Source{Name: "run-secrets", Provider: NewRunSecretsProvider()}, ) + // The docker agent env file (written by `docker agent setup`) resolves + // without any flag. A missing file is the common case and simply skipped; + // a malformed one is reported but never blocks the chain, unlike an + // explicit --env-from-file, so a stray edit cannot lock every command out. + if provider, err := newConfigEnvFileProvider(); err != nil { + slog.Warn("Ignoring unreadable docker agent env file", "path", ConfigEnvFilePath(), "error", err) + } else if provider != nil { + sources = append(sources, Source{Name: "config-env-file", Provider: provider}) + } + // Add credential helper provider if configured if cfg, err := userconfig.Load(); err == nil && cfg.CredentialHelper != nil && cfg.CredentialHelper.Command != "" { sources = append(sources, Source{ @@ -65,6 +78,20 @@ func DefaultSources() []Source { return sources } +// newConfigEnvFileProvider builds the provider for the docker agent env file. +// It returns (nil, nil) when the file does not exist and an error when the +// file exists but cannot be read or parsed. +func newConfigEnvFileProvider() (Provider, error) { + path := ConfigEnvFilePath() + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + return NewEnvFilesProvider([]string{path}) +} + // 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. diff --git a/pkg/environment/default_test.go b/pkg/environment/default_test.go index 21075f1d46..633276a920 100644 --- a/pkg/environment/default_test.go +++ b/pkg/environment/default_test.go @@ -1,6 +1,8 @@ package environment import ( + "os" + "path/filepath" "slices" "testing" @@ -42,3 +44,46 @@ func TestNewDefaultProvider_UsesDefaultSources(t *testing.T) { require.True(t, found) assert.Equal(t, "value", value) } + +func defaultSourceNames() []string { + sources := DefaultSources() + names := make([]string, 0, len(sources)) + for _, source := range sources { + names = append(names, source.Name) + } + return names +} + +func TestDefaultSources_WithoutConfigEnvFile(t *testing.T) { + withTempConfigDir(t) + + assert.NotContains(t, defaultSourceNames(), "config-env-file") +} + +func TestDefaultSources_ReadsConfigEnvFile(t *testing.T) { + dir := withTempConfigDir(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte("MY_TEST_KEY=from-config-env\n"), 0o600)) + + sources := DefaultSources() + + names := defaultSourceNames() + configIdx := slices.Index(names, "config-env-file") + require.GreaterOrEqual(t, configIdx, 0) + + // The file sits below explicit sources (OS env, run secrets) and above + // the OS-level secret managers in lookup precedence. + assert.Greater(t, configIdx, slices.Index(names, "run-secrets")) + assert.Less(t, configIdx, slices.Index(names, "docker-desktop")) + + value, found := sources[configIdx].Provider.Get(t.Context(), "MY_TEST_KEY") + require.True(t, found) + assert.Equal(t, "from-config-env", value) +} + +func TestDefaultSources_SkipsMalformedConfigEnvFile(t *testing.T) { + dir := withTempConfigDir(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte("not a key value line\n"), 0o600)) + + // A stray edit must not lock every command out of the default chain. + assert.NotContains(t, defaultSourceNames(), "config-env-file") +} diff --git a/pkg/environment/store.go b/pkg/environment/store.go new file mode 100644 index 0000000000..41afe68b92 --- /dev/null +++ b/pkg/environment/store.go @@ -0,0 +1,216 @@ +package environment + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "os/user" + "path/filepath" + "runtime" + "strings" + + "github.com/docker/docker-agent/pkg/atomicfile" + "github.com/docker/docker-agent/pkg/paths" +) + +// SecretStore persists a secret so that one of the default secret sources +// ([DefaultSources]) can resolve it on later runs. Implementations are the +// write-side counterparts of the read-side providers: a value stored under a +// name must be returned by the matching provider's Get for that same name. +type SecretStore interface { + // Name identifies the matching read-side source (e.g. "keychain"). + Name() string + // Description is a short human-readable label for interactive pickers. + Description() string + // Store persists value under name, replacing any previous value. + Store(ctx context.Context, name, value string) error +} + +// SecretStores returns the secret stores usable on this machine, most +// preferred first: the OS-native store when its tooling is available +// (macOS Keychain, pass elsewhere), then the docker agent env file, which is +// always available as a plain-file fallback. +func SecretStores() []SecretStore { + var stores []SecretStore + + if runtime.GOOS == "darwin" { + if keychain, err := NewKeychainStore(); err == nil { + stores = append(stores, keychain) + } + } + if pass, err := NewPassStore(); err == nil { + stores = append(stores, pass) + } + + return append(stores, NewConfigEnvFileStore()) +} + +// KeychainStore stores secrets as generic passwords in the macOS keychain, +// where [KeychainProvider] resolves them. +type KeychainStore struct { + binaryPath string +} + +// NewKeychainStore creates a KeychainStore. It verifies that the `security` +// command is available and stores the resolved absolute path. +func NewKeychainStore() (*KeychainStore, error) { + path, err := lookupBinary("security", KeychainNotAvailableError{}) + if err != nil { + return nil, err + } + return &KeychainStore{binaryPath: path}, nil +} + +func (s *KeychainStore) Name() string { return "keychain" } + +func (s *KeychainStore) Description() string { return "macOS Keychain" } + +// Store writes the secret with `security -i` so the value travels over stdin +// and never appears in the process argument list. The fixed account name +// keeps -U updating the same item on re-runs (a different account would +// create a duplicate item instead); [KeychainProvider] looks up by service +// name only, so the account value never affects reads. +func (s *KeychainStore) Store(ctx context.Context, name, value string) error { + command := fmt.Sprintf("add-generic-password -U -a %s -s %s -w %s\n", + securityQuote(keychainAccountName()), securityQuote(name), securityQuote(value)) + + cmd := exec.CommandContext(ctx, s.binaryPath, "-i") + cmd.Stdin = strings.NewReader(command) + var stderr bytes.Buffer + cmd.Stdout = &stderr + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("storing %s in the macOS Keychain: %w: %s", name, err, strings.TrimSpace(stderr.String())) + } + return nil +} + +func keychainAccountName() string { + if u, err := user.Current(); err == nil && u.Username != "" { + return u.Username + } + return "docker-agent" +} + +// securityQuote quotes a token for the `security -i` command parser, which +// splits on whitespace and honours double quotes with backslash escapes. +func securityQuote(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `"`, `\"`) + return `"` + s + `"` +} + +// PassStore stores secrets in the `pass` password manager, where +// [PassProvider] resolves them. +type PassStore struct { + binaryPath string +} + +// NewPassStore creates a PassStore. It verifies that `pass` is available and +// stores the resolved absolute path. +func NewPassStore() (*PassStore, error) { + path, err := lookupBinary("pass", PassNotAvailableError{}) + if err != nil { + return nil, err + } + return &PassStore{binaryPath: path}, nil +} + +func (s *PassStore) Name() string { return "pass" } + +func (s *PassStore) Description() string { return "pass (password manager)" } + +// Store inserts the secret with `pass insert -e -f`, feeding the value over +// stdin (echo mode reads a single line) so it never appears in the process +// argument list. -f replaces any existing entry without prompting. +func (s *PassStore) Store(ctx context.Context, name, value string) error { + cmd := exec.CommandContext(ctx, s.binaryPath, "insert", "-e", "-f", name) + cmd.Stdin = strings.NewReader(value + "\n") + var stderr bytes.Buffer + cmd.Stdout = &stderr + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("storing %s in pass: %w: %s", name, err, strings.TrimSpace(stderr.String())) + } + return nil +} + +// EnvFileStore stores secrets as KEY=value lines in an env file. The file at +// [ConfigEnvFilePath] is read by the default source chain, so values stored +// there resolve on every later run without extra flags. +type EnvFileStore struct { + path string +} + +// NewConfigEnvFileStore creates an EnvFileStore writing to the docker agent +// config env file (see [ConfigEnvFilePath]). +func NewConfigEnvFileStore() *EnvFileStore { + return &EnvFileStore{path: ConfigEnvFilePath()} +} + +func (s *EnvFileStore) Name() string { return "config-env-file" } + +func (s *EnvFileStore) Description() string { + return fmt.Sprintf("docker agent env file (%s, plain text)", s.path) +} + +// Store writes NAME=value to the env file, replacing the line for an existing +// NAME and preserving every other line (comments included). The file is +// created private (0600) in a private directory and written atomically. +func (s *EnvFileStore) Store(_ context.Context, name, value string) error { + if name == "" || strings.ContainsAny(name, "=\n") { + return fmt.Errorf("invalid environment variable name %q", name) + } + if strings.Contains(value, "\n") { + return fmt.Errorf("value for %s cannot contain newlines", name) + } + + if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil { + return fmt.Errorf("creating config directory for %s: %w", s.path, err) + } + + existing, err := os.ReadFile(s.path) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("reading %s: %w", s.path, err) + } + + content := upsertEnvLine(string(existing), name, value) + if err := atomicfile.Write(s.path, strings.NewReader(content), 0o600); err != nil { + return fmt.Errorf("writing %s: %w", s.path, err) + } + return nil +} + +// upsertEnvLine replaces the NAME=... line in content or appends one, +// returning the new file content. Existing lines are preserved verbatim so +// user comments and unrelated variables survive updates. +func upsertEnvLine(content, name, value string) string { + newLine := name + "=" + value + + lines := strings.Split(strings.TrimRight(content, "\n"), "\n") + if len(lines) == 1 && lines[0] == "" { + lines = nil + } + + replaced := false + for i, line := range lines { + key, _, ok := strings.Cut(line, "=") + if ok && strings.TrimSpace(key) == name { + lines[i] = newLine + replaced = true + } + } + if !replaced { + lines = append(lines, newLine) + } + + return strings.Join(lines, "\n") + "\n" +} + +// ConfigEnvFilePath returns the path of the env file that the default secret +// sources read: /.env (e.g. ~/.config/cagent/.env). +func ConfigEnvFilePath() string { + return filepath.Join(paths.GetConfigDir(), ".env") +} diff --git a/pkg/environment/store_test.go b/pkg/environment/store_test.go new file mode 100644 index 0000000000..6bd7f54bc7 --- /dev/null +++ b/pkg/environment/store_test.go @@ -0,0 +1,132 @@ +package environment + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/paths" +) + +// withTempConfigDir points the config dir at a temp directory for the test. +// Not parallel-safe: the override is a package-level global. +func withTempConfigDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + paths.SetConfigDir(dir) + t.Cleanup(func() { paths.SetConfigDir("") }) + return dir +} + +func TestEnvFileStore_CreatesFileAndDirectory(t *testing.T) { + dir := withTempConfigDir(t) + paths.SetConfigDir(filepath.Join(dir, "nested", "cagent")) + + store := NewConfigEnvFileStore() + require.NoError(t, store.Store(t.Context(), "OPENAI_API_KEY", "sk-test")) + + path := filepath.Join(dir, "nested", "cagent", ".env") + content, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, "OPENAI_API_KEY=sk-test\n", string(content)) + + if runtime.GOOS != "windows" { + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + } +} + +func TestEnvFileStore_UpdatesExistingKeyAndPreservesOtherLines(t *testing.T) { + dir := withTempConfigDir(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte( + "# my keys\nOPENAI_API_KEY=old\nANTHROPIC_API_KEY=keep\n"), 0o600)) + + store := NewConfigEnvFileStore() + require.NoError(t, store.Store(t.Context(), "OPENAI_API_KEY", "new")) + + content, err := os.ReadFile(filepath.Join(dir, ".env")) + require.NoError(t, err) + assert.Equal(t, "# my keys\nOPENAI_API_KEY=new\nANTHROPIC_API_KEY=keep\n", string(content)) +} + +func TestEnvFileStore_AppendsNewKey(t *testing.T) { + dir := withTempConfigDir(t) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte("OPENAI_API_KEY=keep\n"), 0o600)) + + store := NewConfigEnvFileStore() + require.NoError(t, store.Store(t.Context(), "MISTRAL_API_KEY", "value")) + + content, err := os.ReadFile(filepath.Join(dir, ".env")) + require.NoError(t, err) + assert.Equal(t, "OPENAI_API_KEY=keep\nMISTRAL_API_KEY=value\n", string(content)) +} + +func TestEnvFileStore_StoredValueIsResolvable(t *testing.T) { + withTempConfigDir(t) + + store := NewConfigEnvFileStore() + require.NoError(t, store.Store(t.Context(), "OPENAI_API_KEY", "sk-round-trip")) + + provider, err := NewEnvFilesProvider([]string{ConfigEnvFilePath()}) + require.NoError(t, err) + value, found := provider.Get(t.Context(), "OPENAI_API_KEY") + require.True(t, found) + assert.Equal(t, "sk-round-trip", value) +} + +func TestEnvFileStore_RejectsInvalidInput(t *testing.T) { + withTempConfigDir(t) + store := NewConfigEnvFileStore() + + require.Error(t, store.Store(t.Context(), "", "value")) + require.Error(t, store.Store(t.Context(), "BAD=NAME", "value")) + require.Error(t, store.Store(t.Context(), "BAD\nNAME", "value")) + require.Error(t, store.Store(t.Context(), "NAME", "multi\nline")) +} + +func TestUpsertEnvLine(t *testing.T) { + t.Parallel() + + assert.Equal(t, "KEY=v\n", upsertEnvLine("", "KEY", "v")) + assert.Equal(t, "KEY=new\n", upsertEnvLine("KEY=old\n", "KEY", "new")) + assert.Equal(t, "OTHER=x\nKEY=v\n", upsertEnvLine("OTHER=x\n", "KEY", "v")) + // A commented-out entry is not the live variable: it is preserved and the + // new value appended. + assert.Equal(t, "#KEY=old\nKEY=v\n", upsertEnvLine("#KEY=old\n", "KEY", "v")) + // Whitespace around the key still identifies the same variable. + assert.Equal(t, "KEY=new\n", upsertEnvLine("KEY =old\n", "KEY", "new")) +} + +func TestSecretStores_AlwaysEndsWithConfigEnvFile(t *testing.T) { + // Not parallel: probes the local system for optional binaries. + stores := SecretStores() + + require.NotEmpty(t, stores) + last := stores[len(stores)-1] + assert.Equal(t, "config-env-file", last.Name()) + + // Store names must match default source names so the wizard's vocabulary + // and the doctor's report never diverge. + sourceNames := map[string]bool{} + for _, source := range DefaultSources() { + sourceNames[source.Name] = true + } + // The config env file source only joins the chain once the file exists. + sourceNames["config-env-file"] = true + for _, store := range stores { + assert.True(t, sourceNames[store.Name()], "store %q has no matching default source", store.Name()) + } +} + +func TestSecurityQuote(t *testing.T) { + t.Parallel() + + assert.Equal(t, `"plain"`, securityQuote("plain")) + assert.Equal(t, `"with space"`, securityQuote("with space")) + assert.Equal(t, `"va\"l\\ue"`, securityQuote(`va"l\ue`)) +} From df96e02892c49afc171b2f12a1455dd9ed06154b Mon Sep 17 00:00:00 2001 From: Sayt-0 Date: Tue, 7 Jul 2026 11:37:29 +0200 Subject: [PATCH 2/3] feat(dmr): export Pull for pre-confirmed model pulls Same pull as the run path (skip when the model exists locally, recover once from a corrupted partial download) but without the interactive confirmation, for callers that already obtained consent such as the setup wizard. --- pkg/model/provider/dmr/pull.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/pkg/model/provider/dmr/pull.go b/pkg/model/provider/dmr/pull.go index 576e7bb449..2aa43b0dba 100644 --- a/pkg/model/provider/dmr/pull.go +++ b/pkg/model/provider/dmr/pull.go @@ -28,6 +28,26 @@ func pullDockerModelIfNeeded(ctx context.Context, model string) error { return err } + return pullWithRecovery(ctx, model) +} + +// Pull pulls a Docker Model Runner model via `docker model pull`, streaming +// progress to the terminal. Unlike the run-path pull it never asks for +// confirmation, so callers that already obtained consent (e.g. the setup +// wizard) can invoke it directly. It skips the pull when the model is already +// available locally and returns a *PullFailedError on failure. +func Pull(ctx context.Context, model string) error { + if modelExists(ctx, model) { + slog.DebugContext(ctx, "Model already exists, skipping pull", "model", model) + return nil + } + + return pullWithRecovery(ctx, model) +} + +// pullWithRecovery runs the pull and recovers once from a corrupted partial +// download. +func pullWithRecovery(ctx context.Context, model string) error { err := runModelPull(ctx, model) if err == nil { return nil From bf3ed99b5b4d383c59237b8742e4d2c48586c2ef Mon Sep 17 00:00:00 2001 From: Sayt-0 Date: Tue, 7 Jul 2026 11:37:40 +0200 Subject: [PATCH 3/3] feat(cli): add docker agent setup wizard, offered when no model is usable Phase 3 of #3442. docker agent setup walks either path interactively: pick a cloud provider, paste its API key (hidden input) and choose where to store it (Keychain, pass, or the docker agent env file), or check Docker Model Runner and pull a local model. Ends with a ready-to-copy docker agent run line. When an interactive run finds no usable model (auto selection fell through, DMR not installed, or missing model credentials), the failure is printed and the wizard offered, decline-able; on success the run is retried once. Non-TTY and --exec runs are unchanged, and DOCKER_AGENT_NO_SETUP=1 / CAGENT_NO_SETUP=1 suppress the offer for scripted terminals. Declining returns a one-line error instead of repeating the full failure text. The no-usable-model errors (AutoModelFallbackError, RequiredEnvError, the first_available variant, doctor's issue line) now name docker agent setup so non-interactive failures point at the same fix. --- cmd/root/doctor.go | 2 +- cmd/root/root.go | 1 + cmd/root/run.go | 5 +- cmd/root/setup.go | 412 +++++++++++++++++++++++++++++++++ cmd/root/setup_test.go | 298 ++++++++++++++++++++++++ docs/features/cli/index.md | 10 + docs/guides/secrets/index.md | 21 +- pkg/config/auto.go | 1 + pkg/config/auto_test.go | 1 + pkg/config/first_available.go | 7 + pkg/environment/errors.go | 1 + pkg/environment/errors_test.go | 1 + 12 files changed, 754 insertions(+), 6 deletions(-) create mode 100644 cmd/root/setup.go create mode 100644 cmd/root/setup_test.go diff --git a/cmd/root/doctor.go b/cmd/root/doctor.go index 9f8123c165..3f88cb446d 100644 --- a/cmd/root/doctor.go +++ b/cmd/root/doctor.go @@ -254,7 +254,7 @@ func (f *doctorFlags) buildReport(ctx context.Context, agentRef string) (*doctor case dmrDown: autoStatus.Usable = false report.Issues = append(report.Issues, fmt.Sprintf( - "no usable model: no provider credential was found and Docker Model Runner is %s; set an API key for one of the providers above (%s) or install Docker Model Runner (%s)", + "no usable model: no provider credential was found and Docker Model Runner is %s; run `docker agent setup`, or set an API key for one of the providers above (%s) or install Docker Model Runner (%s)", describeDMRStatus(report.DMR.Status), environment.SecretsDocsURL, dmrDocsURL)) case !slices.Contains(dmrModels, auto.Model): autoStatus.Note = fmt.Sprintf("not pulled yet; run `docker model pull %s` or let the first run pull it", auto.Model) diff --git a/cmd/root/root.go b/cmd/root/root.go index 834a594710..4c54a46340 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -178,6 +178,7 @@ We collect anonymous usage data to help improve docker agent. To disable: newEvalCmd(), newShareCmd(), newModelsCmd(), + newSetupCmd(), newDoctorCmd(), newDebugCmd(), newAliasCmd(), diff --git a/cmd/root/run.go b/cmd/root/run.go index 77091b91e1..da33cd31a2 100644 --- a/cmd/root/run.go +++ b/cmd/root/run.go @@ -301,7 +301,10 @@ func (f *runExecFlags) runRunCommand(cmd *cobra.Command, args []string) (command out := cli.NewPrinter(cmd.OutOrStdout()) - return f.runOrExec(ctx, out, args, useTUI) + // When an interactive run cannot find any usable model, offer the setup + // wizard instead of leaving the user alone with the error. + err := f.runOrExec(ctx, out, args, useTUI) + return f.offerSetupOnNoModel(ctx, cmd, out, args, useTUI, err) } func (f *runExecFlags) runOrExec(ctx context.Context, out *cli.Printer, args []string, useTUI bool) error { diff --git a/cmd/root/setup.go b/cmd/root/setup.go new file mode 100644 index 0000000000..89e437674f --- /dev/null +++ b/cmd/root/setup.go @@ -0,0 +1,412 @@ +package root + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "strconv" + "strings" + + "github.com/mattn/go-isatty" + "github.com/spf13/cobra" + "golang.org/x/term" + + "github.com/docker/docker-agent/pkg/cli" + "github.com/docker/docker-agent/pkg/config" + "github.com/docker/docker-agent/pkg/environment" + "github.com/docker/docker-agent/pkg/input" + "github.com/docker/docker-agent/pkg/model/provider/dmr" + "github.com/docker/docker-agent/pkg/telemetry" +) + +// errSetupCancelled is returned when the user aborts the wizard (EOF or an +// explicit quit) rather than a step failing. +var errSetupCancelled = errors.New("setup cancelled") + +// errNoUsableModel is the concise error returned when the setup offer is +// declined or cancelled: the full failure was already printed just above the +// offer, so returning the original error would print it twice. +var errNoUsableModel = errors.New("no usable model is configured; run `docker agent setup` or see `docker agent doctor`") + +// setupResult reports what the wizard configured, so the caller that offered +// setup after a failed run can retry with the new credential in place. +type setupResult struct { + // EnvVar and Value are set when the cloud path stored an API key. + EnvVar string + Value string + // Model is set when the local path selected or pulled a DMR model. + Model string +} + +// setupWizard drives the interactive model setup. The function fields are +// seams: production wiring talks to the terminal, the OS secret stores, and +// Docker Model Runner, while tests inject scripted answers and fakes. +// +// in is buffered once at construction: a fresh bufio.Reader per prompt would +// drop the read-ahead it buffered beyond the first line. +type setupWizard struct { + in *bufio.Reader + out io.Writer + + readSecret func(prompt string) (string, error) + stores []environment.SecretStore + dmrLister config.DMRModelLister + pullModel func(ctx context.Context, model string) error +} + +func newSetupCmd() *cobra.Command { + return &cobra.Command{ + Use: "setup", + Short: "Interactively set up a model (API key or local)", + Long: `Set up a model for docker agent, interactively. + +Two paths: + - Cloud provider: pick a provider, paste its API key, and choose where to + store it (OS keychain, pass, or the docker agent env file). + - Local model: check Docker Model Runner and pull a model. No API key needed. + +Ends with the exact command to start chatting. Secret values are stored where +you choose and never printed. Check the result anytime with 'docker agent doctor'.`, + Example: ` docker-agent setup`, + GroupID: "core", + Args: cobra.NoArgs, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) (commandErr error) { + ctx := cmd.Context() + telemetry.TrackCommand(ctx, "setup", args) + defer func() { // do not inline this defer so that commandErr is not resolved early + telemetry.TrackCommandError(ctx, "setup", args, commandErr) + }() + + if !isatty.IsTerminal(os.Stdin.Fd()) || !isatty.IsTerminal(os.Stdout.Fd()) { + return fmt.Errorf("docker agent setup is interactive and needs a terminal\n"+ + "Without one, set a provider API key directly (e.g. export ANTHROPIC_API_KEY=)\n"+ + "or pull a local model with `docker model pull ai/qwen3`.\n"+ + "See %s for every secret source", environment.SecretsDocsURL) + } + + wizard := newTerminalSetupWizard(cmd.InOrStdin(), cmd.OutOrStdout()) + _, err := wizard.run(ctx) + if errors.Is(err, errSetupCancelled) { + fmt.Fprintln(cmd.OutOrStdout(), "Setup cancelled.") + return nil + } + return err + }, + } +} + +// newTerminalSetupWizard wires a wizard to the real terminal, secret stores, +// and Docker Model Runner. +func newTerminalSetupWizard(in io.Reader, out io.Writer) *setupWizard { + return &setupWizard{ + in: bufio.NewReader(in), + out: out, + readSecret: func(prompt string) (string, error) { + fmt.Fprint(out, prompt) + // Read directly from the terminal fd: the key must not echo. + value, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(out) + if err != nil { + return "", fmt.Errorf("reading the API key: %w", err) + } + return string(value), nil + }, + stores: environment.SecretStores(), + dmrLister: dmr.ListModels, + pullModel: dmr.Pull, + } +} + +// run executes the wizard: choose cloud or local, configure it, and print the +// command to start chatting. +func (w *setupWizard) run(ctx context.Context) (*setupResult, error) { + fmt.Fprintln(w.out, "Let's set up a model for docker agent.") + fmt.Fprintln(w.out) + fmt.Fprintln(w.out, "How do you want to run models?") + fmt.Fprintln(w.out, " 1. Cloud provider (needs an API key)") + fmt.Fprintln(w.out, " 2. Local model via Docker Model Runner (no API key)") + + choice, err := w.promptChoice(ctx, 2, 1) + if err != nil { + return nil, err + } + + var result *setupResult + if choice == 1 { + result, err = w.setupCloudProvider(ctx) + } else { + result, err = w.setupLocalModel(ctx) + } + if err != nil { + return nil, err + } + + w.printNextSteps(result) + return result, nil +} + +// setupCloudProvider walks the cloud path: pick a provider, paste its key, +// pick a store, and persist the key there. +func (w *setupWizard) setupCloudProvider(ctx context.Context) (*setupResult, error) { + providers := config.CloudProviderEnvVars() + + fmt.Fprintln(w.out) + fmt.Fprintln(w.out, "Pick a provider:") + for i, p := range providers { + fmt.Fprintf(w.out, " %2d. %-15s (%s)\n", i+1, p.Provider, p.EnvVars[0]) + } + + choice, err := w.promptChoice(ctx, len(providers), 1) + if err != nil { + return nil, err + } + selected := providers[choice-1] + envVar := selected.EnvVars[0] + + key, err := w.promptSecret(ctx, fmt.Sprintf("\nPaste your %s API key (%s, input hidden): ", selected.Provider, envVar)) + if err != nil { + return nil, err + } + + if err := w.storeSecret(ctx, envVar, key); err != nil { + return nil, err + } + + return &setupResult{EnvVar: envVar, Value: key, Model: selected.Provider + "/" + config.DefaultModels[selected.Provider]}, nil +} + +// promptSecret asks for the API key until a non-empty value is entered. +func (w *setupWizard) promptSecret(ctx context.Context, prompt string) (string, error) { + for { + if err := ctx.Err(); err != nil { + return "", err + } + key, err := w.readSecret(prompt) + if err != nil { + return "", err + } + if key = strings.TrimSpace(key); key != "" { + return key, nil + } + fmt.Fprintln(w.out, "The key is empty; paste it or press Ctrl+C to cancel.") + } +} + +// storeSecret asks where to store the key and persists it, re-asking when a +// store fails (e.g. an uninitialized pass store) so the pasted key is not +// lost to a storage hiccup. +func (w *setupWizard) storeSecret(ctx context.Context, envVar, key string) error { + for { + fmt.Fprintln(w.out) + fmt.Fprintf(w.out, "Where should %s be stored?\n", envVar) + for i, store := range w.stores { + fmt.Fprintf(w.out, " %d. %s\n", i+1, store.Description()) + } + + choice, err := w.promptChoice(ctx, len(w.stores), 1) + if err != nil { + return err + } + store := w.stores[choice-1] + + if err := store.Store(ctx, envVar, key); err != nil { + fmt.Fprintf(w.out, "Could not store the key: %v\nPick another location, or press Ctrl+C to cancel.\n", err) + continue + } + + fmt.Fprintf(w.out, "\nStored %s in the %s.\n", envVar, store.Description()) + return nil + } +} + +// setupLocalModel walks the local path: check Docker Model Runner and make +// sure at least one model is pulled. +func (w *setupWizard) setupLocalModel(ctx context.Context) (*setupResult, error) { + fmt.Fprintln(w.out) + fmt.Fprintln(w.out, "Checking Docker Model Runner...") + + models, err := w.dmrLister(ctx) + switch { + case errors.Is(err, dmr.ErrNotInstalled): + return nil, fmt.Errorf("cannot use a local model: Docker Model Runner is not installed.\n"+ + "Install it (%s), then run `docker agent setup` again", dmrDocsURL) + case err != nil: + return nil, fmt.Errorf("cannot use a local model: Docker Model Runner is not reachable: %w\n"+ + "Start it (or install it: %s), then run `docker agent setup` again", err, dmrDocsURL) + } + + if len(models) > 0 { + fmt.Fprintf(w.out, "Docker Model Runner is ready with %d model(s) pulled:\n", len(models)) + for _, m := range models { + fmt.Fprintf(w.out, " - %s\n", m) + } + model, _ := config.PickDMRModel(ctx, config.DefaultModels["dmr"], func(context.Context) ([]string, error) { return models, nil }) + return &setupResult{Model: "dmr/" + model}, nil + } + + defaultModel := config.DefaultModels["dmr"] + fmt.Fprintln(w.out, "Docker Model Runner is reachable but no model is pulled yet.") + fmt.Fprintf(w.out, "Model to pull [%s]: ", defaultModel) + + model, err := w.readLine(ctx) + if err != nil { + return nil, err + } + if model = strings.TrimSpace(model); model == "" { + model = defaultModel + } + + if err := w.pullModel(ctx, model); err != nil { + return nil, err + } + + return &setupResult{Model: "dmr/" + model}, nil +} + +// printNextSteps ends the wizard with ready-to-copy commands. +func (w *setupWizard) printNextSteps(result *setupResult) { + fmt.Fprintln(w.out) + fmt.Fprintln(w.out, "You're all set. Start chatting with:") + fmt.Fprintln(w.out) + fmt.Fprintln(w.out, " docker agent run") + fmt.Fprintln(w.out) + if result.Model != "" { + fmt.Fprintf(w.out, "Or pin the model explicitly: docker agent run --model %s\n", result.Model) + } + fmt.Fprintln(w.out, "Check your setup anytime with `docker agent doctor`.") +} + +// promptChoice reads a 1-based menu choice, re-asking on invalid input. An +// empty answer selects def; EOF cancels the wizard. +func (w *setupWizard) promptChoice(ctx context.Context, n, def int) (int, error) { + for { + fmt.Fprintf(w.out, "Choice [%d]: ", def) + answer, err := w.readLine(ctx) + if err != nil { + return 0, err + } + answer = strings.TrimSpace(answer) + if answer == "" { + return def, nil + } + if choice, err := strconv.Atoi(answer); err == nil && choice >= 1 && choice <= n { + return choice, nil + } + fmt.Fprintf(w.out, "Enter a number between 1 and %d.\n", n) + } +} + +// readLine reads one line of user input, mapping EOF (Ctrl+D, closed stdin) +// and context cancellation (Ctrl+C) to a cancellation. +func (w *setupWizard) readLine(ctx context.Context) (string, error) { + line, err := input.ReadLine(ctx, w.in) + if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) { + return "", errSetupCancelled + } + if err != nil { + return "", err + } + return line, nil +} + +// noSetupOfferEnvVars suppress the automatic setup offer for scripted +// environments driving a real terminal (mirrors the tour's NO_TOUR escape). +var noSetupOfferEnvVars = []string{"DOCKER_AGENT_NO_SETUP", "CAGENT_NO_SETUP"} + +func setupOfferDisabledByEnv(getenv func(string) string) bool { + for _, name := range noSetupOfferEnvVars { + if getenv(name) == "1" { + return true + } + } + return false +} + +// errorIndicatesNoUsableModel reports whether err means "no usable model or +// missing model credentials", the failures the setup wizard fixes. Errors +// that already name their own exact remediation (a failed or declined pull of +// a specific model) are excluded: re-offering a generic wizard on top of them +// would drown the fix they carry. +func errorIndicatesNoUsableModel(err error) bool { + if _, ok := errors.AsType[*config.AutoModelFallbackError](err); ok { + return true + } + if errors.Is(err, dmr.ErrNotInstalled) { + return true + } + if reqErr, ok := errors.AsType[*environment.RequiredEnvError](err); ok { + return reqErr.MissingModelCredentials + } + // Matches errors that self-classify, e.g. the unexported first_available + // variant in pkg/config. + var modelCreds interface{ MissingModelCredentials() bool } + if errors.As(err, &modelCreds) { + return modelCreds.MissingModelCredentials() + } + return false +} + +// shouldOfferSetup reports whether a failed run should offer the setup +// wizard: interactive terminal on both ends, not exec mode, not suppressed +// via environment, and a failure the wizard can actually fix. +func shouldOfferSetup(runErr error, execMode bool, getenv func(string) string) bool { + if runErr == nil || execMode || setupOfferDisabledByEnv(getenv) { + return false + } + if !isatty.IsTerminal(os.Stdin.Fd()) || !isatty.IsTerminal(os.Stdout.Fd()) { + return false + } + return errorIndicatesNoUsableModel(runErr) +} + +// offerSetupOnNoModel completes an interactive run that failed for lack of a +// usable model: it surfaces the failure, offers the setup wizard (decline-able), +// and retries the run once when setup succeeds. In every other case the +// original error is returned unchanged. +func (f *runExecFlags) offerSetupOnNoModel(ctx context.Context, cmd *cobra.Command, out *cli.Printer, args []string, useTUI bool, runErr error) error { + if !shouldOfferSetup(runErr, f.exec, os.Getenv) { + return runErr + } + + errOut := cmd.ErrOrStderr() + fmt.Fprintf(errOut, "%v\n\n", runErr) + fmt.Fprint(errOut, "Run the interactive setup now to configure a model? ([y]es/[n]o): ") + + answer, err := input.ReadLine(ctx, cmd.InOrStdin()) + if err != nil { + fmt.Fprintln(errOut) + return errNoUsableModel + } + answer = strings.TrimSpace(strings.ToLower(answer)) + if answer != "y" && answer != "yes" { + return errNoUsableModel + } + + fmt.Fprintln(cmd.OutOrStdout()) + wizard := newTerminalSetupWizard(cmd.InOrStdin(), cmd.OutOrStdout()) + result, err := wizard.run(ctx) + if errors.Is(err, errSetupCancelled) { + return errNoUsableModel + } + if err != nil { + return err + } + + // The run's env provider chain was built before the wizard stored the key, + // so bridge it into the process environment for the retry. Keychain and + // pass lookups are live either way; the config env file is not, when it + // did not exist at chain construction. + if result.EnvVar != "" { + if err := os.Setenv(result.EnvVar, result.Value); err != nil { + slog.WarnContext(ctx, "Failed to export the stored key for the retry", "env_var", result.EnvVar, "error", err) + } + } + + fmt.Fprintln(cmd.OutOrStdout(), "Retrying with the new configuration...") + return f.runOrExec(ctx, out, args, useTUI) +} diff --git a/cmd/root/setup_test.go b/cmd/root/setup_test.go new file mode 100644 index 0000000000..a898ad59b0 --- /dev/null +++ b/cmd/root/setup_test.go @@ -0,0 +1,298 @@ +package root + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/config" + "github.com/docker/docker-agent/pkg/config/latest" + "github.com/docker/docker-agent/pkg/environment" + "github.com/docker/docker-agent/pkg/model/provider/dmr" +) + +// fakeSecretStore records stored secrets in memory and can be made to fail. +type fakeSecretStore struct { + name string + stored map[string]string + err error +} + +func (s *fakeSecretStore) Name() string { return s.name } +func (s *fakeSecretStore) Description() string { return s.name + " (fake)" } + +func (s *fakeSecretStore) Store(_ context.Context, name, value string) error { + if s.err != nil { + return s.err + } + if s.stored == nil { + s.stored = map[string]string{} + } + s.stored[name] = value + return nil +} + +// newTestWizard builds a wizard fed by scripted answers, returning the output +// buffer. Secrets are answered by keys (one per prompt); DMR state comes from +// dmrModels/dmrErr. +func newTestWizard(answers string, keys []string, stores []environment.SecretStore, dmrModels []string, dmrErr error) (*setupWizard, *bytes.Buffer, *[]string) { + var out bytes.Buffer + var pulled []string + secretCalls := 0 + + wizard := &setupWizard{ + in: bufio.NewReader(strings.NewReader(answers)), + out: &out, + readSecret: func(string) (string, error) { + if secretCalls >= len(keys) { + return "", errors.New("no scripted key left") + } + key := keys[secretCalls] + secretCalls++ + return key, nil + }, + stores: stores, + dmrLister: func(context.Context) ([]string, error) { return dmrModels, dmrErr }, + pullModel: func(_ context.Context, model string) error { + pulled = append(pulled, model) + return nil + }, + } + return wizard, &out, &pulled +} + +func TestSetupWizard_CloudPathStoresKey(t *testing.T) { + t.Parallel() + + store := &fakeSecretStore{name: "keychain"} + // cloud -> provider 1 (anthropic) -> store 1 + wizard, out, _ := newTestWizard("1\n1\n1\n", []string{"sk-cloud-key"}, []environment.SecretStore{store}, nil, nil) + + result, err := wizard.run(t.Context()) + require.NoError(t, err) + + assert.Equal(t, "sk-cloud-key", store.stored["ANTHROPIC_API_KEY"]) + assert.Equal(t, "ANTHROPIC_API_KEY", result.EnvVar) + assert.Equal(t, "sk-cloud-key", result.Value) + assert.Equal(t, "anthropic/claude-sonnet-4-6", result.Model) + + output := out.String() + assert.Contains(t, output, "Stored ANTHROPIC_API_KEY in the keychain (fake).") + assert.Contains(t, output, "docker agent run") + assert.Contains(t, output, "--model anthropic/claude-sonnet-4-6") + assert.Contains(t, output, "docker agent doctor") + assert.NotContains(t, output, "sk-cloud-key", "secret values must never be printed") +} + +func TestSetupWizard_DefaultsSelectCloudAndFirstEntries(t *testing.T) { + t.Parallel() + + store := &fakeSecretStore{name: "keychain"} + // Empty answers take every default: cloud, first provider, first store. + wizard, _, _ := newTestWizard("\n\n\n", []string{"sk-key"}, []environment.SecretStore{store}, nil, nil) + + result, err := wizard.run(t.Context()) + require.NoError(t, err) + + first := config.CloudProviderEnvVars()[0] + assert.Equal(t, first.EnvVars[0], result.EnvVar) + assert.Equal(t, "sk-key", store.stored[first.EnvVars[0]]) +} + +func TestSetupWizard_CloudPathRetriesFailedStore(t *testing.T) { + t.Parallel() + + broken := &fakeSecretStore{name: "pass", err: errors.New("password store is empty")} + working := &fakeSecretStore{name: "config-env-file"} + // cloud -> provider 1 -> store 1 (fails) -> store 2 (succeeds) + wizard, out, _ := newTestWizard("1\n1\n1\n2\n", []string{"sk-key"}, + []environment.SecretStore{broken, working}, nil, nil) + + result, err := wizard.run(t.Context()) + require.NoError(t, err) + + assert.Contains(t, out.String(), "Could not store the key: password store is empty") + assert.Equal(t, "sk-key", working.stored[result.EnvVar]) +} + +func TestSetupWizard_CloudPathReasksOnEmptyKey(t *testing.T) { + t.Parallel() + + store := &fakeSecretStore{name: "keychain"} + wizard, out, _ := newTestWizard("1\n1\n1\n", []string{" ", "sk-key"}, []environment.SecretStore{store}, nil, nil) + + _, err := wizard.run(t.Context()) + require.NoError(t, err) + + assert.Contains(t, out.String(), "The key is empty") + assert.Equal(t, "sk-key", store.stored["ANTHROPIC_API_KEY"]) +} + +func TestSetupWizard_LocalPathWithPulledModels(t *testing.T) { + t.Parallel() + + wizard, out, pulled := newTestWizard("2\n", nil, nil, []string{"ai/smollm2"}, nil) + + result, err := wizard.run(t.Context()) + require.NoError(t, err) + + assert.Empty(t, *pulled, "no pull needed when a model is already available") + assert.Equal(t, "dmr/ai/smollm2", result.Model) + assert.Contains(t, out.String(), "- ai/smollm2") + assert.Contains(t, out.String(), "docker agent run") +} + +func TestSetupWizard_LocalPathPullsDefaultModel(t *testing.T) { + t.Parallel() + + // local -> accept the default model + wizard, out, pulled := newTestWizard("2\n\n", nil, nil, nil, nil) + + result, err := wizard.run(t.Context()) + require.NoError(t, err) + + require.Equal(t, []string{"ai/qwen3:latest"}, *pulled) + assert.Equal(t, "dmr/ai/qwen3:latest", result.Model) + assert.Contains(t, out.String(), "no model is pulled yet") +} + +func TestSetupWizard_LocalPathPullsCustomModel(t *testing.T) { + t.Parallel() + + wizard, _, pulled := newTestWizard("2\nai/llama3.2\n", nil, nil, nil, nil) + + result, err := wizard.run(t.Context()) + require.NoError(t, err) + + require.Equal(t, []string{"ai/llama3.2"}, *pulled) + assert.Equal(t, "dmr/ai/llama3.2", result.Model) +} + +func TestSetupWizard_LocalPathDMRNotInstalled(t *testing.T) { + t.Parallel() + + wizard, _, _ := newTestWizard("2\n", nil, nil, nil, dmr.ErrNotInstalled) + + _, err := wizard.run(t.Context()) + require.Error(t, err) + assert.Contains(t, err.Error(), "not installed") + assert.Contains(t, err.Error(), dmrDocsURL) +} + +func TestSetupWizard_LocalPathDMRUnreachable(t *testing.T) { + t.Parallel() + + wizard, _, _ := newTestWizard("2\n", nil, nil, nil, errors.New("connection refused")) + + _, err := wizard.run(t.Context()) + require.Error(t, err) + assert.Contains(t, err.Error(), "not reachable") + assert.Contains(t, err.Error(), "connection refused") +} + +func TestSetupWizard_InvalidChoiceReasks(t *testing.T) { + t.Parallel() + + wizard, out, pulled := newTestWizard("9\nx\n2\n", nil, nil, []string{"ai/smollm2"}, nil) + + _, err := wizard.run(t.Context()) + require.NoError(t, err) + + assert.Contains(t, out.String(), "Enter a number between 1 and 2.") + assert.Empty(t, *pulled) +} + +func TestSetupWizard_EOFCancels(t *testing.T) { + t.Parallel() + + wizard, _, _ := newTestWizard("", nil, nil, nil, nil) + + _, err := wizard.run(t.Context()) + require.ErrorIs(t, err, errSetupCancelled) +} + +func TestErrorIndicatesNoUsableModel(t *testing.T) { + t.Parallel() + + assert.True(t, errorIndicatesNoUsableModel(&config.AutoModelFallbackError{})) + assert.True(t, errorIndicatesNoUsableModel(fmt.Errorf("loading team: %w", dmr.ErrNotInstalled))) + assert.True(t, errorIndicatesNoUsableModel(&environment.RequiredEnvError{ + Missing: []string{"OPENAI_API_KEY"}, MissingModelCredentials: true, + })) + + // Missing tool secrets are not fixable by the model setup wizard. + assert.False(t, errorIndicatesNoUsableModel(&environment.RequiredEnvError{ + Missing: []string{"GITHUB_PERSONAL_ACCESS_TOKEN"}, + })) + assert.False(t, errorIndicatesNoUsableModel(errors.New("boom"))) + assert.False(t, errorIndicatesNoUsableModel(nil)) + // Pull errors already carry their own exact remediation. + assert.False(t, errorIndicatesNoUsableModel(&dmr.PullFailedError{Model: "ai/qwen3"})) +} + +func TestErrorIndicatesNoUsableModel_FirstAvailableVariant(t *testing.T) { + t.Parallel() + + cfg := &latest.Config{ + Models: map[string]latest.ModelConfig{ + "smart": {FirstAvailable: []string{"openai/gpt-5"}}, + }, + } + err := config.ResolveFirstAvailableModels(t.Context(), cfg, "", environment.NewNoEnvProvider()) + require.Error(t, err) + + assert.True(t, errorIndicatesNoUsableModel(err)) +} + +func TestSetupOfferDisabledByEnv(t *testing.T) { + t.Parallel() + + assert.False(t, setupOfferDisabledByEnv(func(string) string { return "" })) + for _, name := range []string{"DOCKER_AGENT_NO_SETUP", "CAGENT_NO_SETUP"} { + env := func(key string) string { + if key == name { + return "1" + } + return "" + } + assert.True(t, setupOfferDisabledByEnv(env), name) + } +} + +func TestShouldOfferSetup(t *testing.T) { + t.Parallel() + + noEnv := func(string) string { return "" } + modelErr := &config.AutoModelFallbackError{} + + assert.False(t, shouldOfferSetup(nil, false, noEnv)) + assert.False(t, shouldOfferSetup(modelErr, true, noEnv), "exec mode never prompts") + assert.False(t, shouldOfferSetup(modelErr, false, func(string) string { return "1" })) + // The remaining conditions require a real terminal on stdin and stdout, + // which a test process does not have: the guard must say no here. + assert.False(t, shouldOfferSetup(modelErr, false, noEnv)) +} + +func TestSetupCommand_RequiresTerminal(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + cmd := newSetupCmd() + cmd.SilenceErrors = true + cmd.SetIn(strings.NewReader("")) + cmd.SetOut(&buf) + cmd.SetErr(&buf) + cmd.SetArgs(nil) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "needs a terminal") + assert.Contains(t, err.Error(), environment.SecretsDocsURL) +} diff --git a/docs/features/cli/index.md b/docs/features/cli/index.md index 21c727cd24..80c4d38809 100644 --- a/docs/features/cli/index.md +++ b/docs/features/cli/index.md @@ -199,6 +199,16 @@ $ docker agent models --provider openai $ docker agent models --format json | jq ``` +### `docker agent setup` + +Set up a model interactively. Two paths: pick a cloud provider, paste its API key, and choose where to store it (macOS Keychain, `pass`, or the docker agent env file `~/.config/cagent/.env`), or check Docker Model Runner and pull a local model (no API key needed). Ends with the exact command to start chatting. Secret values are never printed. + +The wizard is also offered automatically when an interactive run finds no usable model (decline-able; set `DOCKER_AGENT_NO_SETUP=1` to suppress the offer). + +```bash +$ docker agent setup +``` + ### `docker agent doctor` Diagnose the model and credential setup. Reports which model providers have credentials and where each credential comes from (shell environment, env file, pass, keychain, …), whether Docker Model Runner is reachable and which models are pulled, and which model the `auto` selection would pick. Secret values are never printed. Exits with a non-zero status when an issue would prevent an agent from running, which makes it usable in scripts and CI. diff --git a/docs/guides/secrets/index.md b/docs/guides/secrets/index.md index 596cadcbe6..97b7c137d5 100644 --- a/docs/guides/secrets/index.md +++ b/docs/guides/secrets/index.md @@ -16,10 +16,11 @@ docker-agent needs API keys to talk to model providers (OpenAI, Anthropic, etc.) | --- | --- | --- | | 1 | [Environment variables](#environment-variables) | `export OPENAI_API_KEY=sk-...` | | 2 | [Docker Compose secrets](#docker-compose-secrets) | Files in `/run/secrets/` | -| 3 | [Credential helper](#credential-helper) | Custom command declared in `~/.config/cagent/config.yaml` under `credential_helper:` | -| 4 | [Docker Desktop](#docker-desktop) | Secrets stored by the Docker Desktop backend (no setup on a Desktop install) | -| 5 | [`pass` password manager](#pass-password-manager) | `pass insert OPENAI_API_KEY` | -| 6 | [macOS Keychain](#macos-keychain) | `security add-generic-password` | +| 3 | [docker agent env file](#docker-agent-env-file) | `~/.config/cagent/.env`, written by `docker agent setup` | +| 4 | [Credential helper](#credential-helper) | Custom command declared in `~/.config/cagent/config.yaml` under `credential_helper:` | +| 5 | [Docker Desktop](#docker-desktop) | Secrets stored by the Docker Desktop backend (no setup on a Desktop install) | +| 6 | [`pass` password manager](#pass-password-manager) | `pass insert OPENAI_API_KEY` | +| 7 | [macOS Keychain](#macos-keychain) | `security add-generic-password` | The first provider that has a value wins. You can mix and match — for example, use environment variables for one key and Keychain for another. @@ -84,6 +85,17 @@ The file format supports: > [!IMPORTANT] > Add `.env` to your `.gitignore` to avoid committing secrets to version control. +## docker agent env file + +A `.env` file (same format as above) at `~/.config/cagent/.env` is read automatically on every run — no `--env-from-file` flag needed. It is where [`docker agent setup`](../../features/cli/index.md#docker-agent-setup) stores API keys when you choose the env-file location, and you can edit it by hand: + +```bash +# ~/.config/cagent/.env +OPENAI_API_KEY=sk-... +``` + +The file is created with owner-only permissions (`0600`), but the values are stored in plain text: prefer the OS keychain or `pass` when available. + ## Docker Compose Secrets When running docker-agent in a container with Docker Compose, you can use [Compose secrets](https://docs.docker.com/compose/how-tos/use-secrets/) to inject credentials securely. Compose mounts secrets as files under `/run/secrets/`, and docker-agent reads from this location automatically. @@ -241,6 +253,7 @@ References follow the `op:////` format. Make sure the `op` C | --- | --- | --- | | Environment variables | Quick local development, scripts | Low | | Env files | Team projects, multiple keys | Low | +| docker agent env file | Keys used across all projects, written by `docker agent setup` | Low | | Docker Compose secrets | Containerized deployments, CI/CD | Medium | | `pass` | Linux/macOS, GPG-based workflows | Medium | | macOS Keychain | macOS local development | Low | diff --git a/pkg/config/auto.go b/pkg/config/auto.go index b5fd8568fb..65754caed7 100644 --- a/pkg/config/auto.go +++ b/pkg/config/auto.go @@ -128,6 +128,7 @@ func (e *AutoModelFallbackError) Error() string { } } b.WriteString("No model is currently available.\n\nTo fix this, you can:\n") + b.WriteString(" - Run `docker agent setup` to configure a provider API key or a local model interactively\n") b.WriteString(" - Pull a Docker Model Runner model, e.g. `docker model pull ai/qwen3`\n") b.WriteString(" - Install Docker Model Runner: https://docs.docker.com/ai/model-runner/get-started/\n") b.WriteString(" - Configure an API key for a cloud provider:\n") diff --git a/pkg/config/auto_test.go b/pkg/config/auto_test.go index e199752b7a..e4d71e86d9 100644 --- a/pkg/config/auto_test.go +++ b/pkg/config/auto_test.go @@ -1007,6 +1007,7 @@ func TestAutoModelFallbackError(t *testing.T) { err := &AutoModelFallbackError{} msg := err.Error() assert.Contains(t, msg, "No model is currently available") + assert.Contains(t, msg, "docker agent setup") assert.Contains(t, msg, "docker model pull") assert.Contains(t, msg, "ANTHROPIC_API_KEY") assert.NotContains(t, msg, "Could not initialize") diff --git a/pkg/config/first_available.go b/pkg/config/first_available.go index 4c74df6122..b71a546b33 100644 --- a/pkg/config/first_available.go +++ b/pkg/config/first_available.go @@ -183,10 +183,17 @@ func (e *firstAvailableMissingEnvError) Error() string { } msg.WriteString("\n") msg.WriteString(environment.SecretSourcesHelp(example)) + msg.WriteString("\nOr run `docker agent setup` to configure a provider or local model interactively.\n") return msg.String() } +// MissingModelCredentials reports that the missing variables are model +// credentials: first_available candidates are models by definition. It lets +// callers (e.g. the CLI's setup offer) classify this error without exporting +// the type. +func (e *firstAvailableMissingEnvError) MissingModelCredentials() bool { return true } + // resolveCandidate turns a candidate reference into a ModelConfig. The // reference is first looked up as a named model; otherwise it is parsed as an // inline "provider/model" spec. A candidate that is itself a first_available diff --git a/pkg/environment/errors.go b/pkg/environment/errors.go index 2307dfba6f..d8448af5f1 100644 --- a/pkg/environment/errors.go +++ b/pkg/environment/errors.go @@ -37,6 +37,7 @@ func (e *RequiredEnvError) Error() string { if e.MissingModelCredentials { msg.WriteString("\nNo API key? Run a local model instead: docker agent run --model dmr/ai/qwen3 ...\n(the model is pulled on first use; `docker model ls` shows models already pulled)\n") + msg.WriteString("Or run `docker agent setup` to configure a provider or local model interactively.\n") } return msg.String() diff --git a/pkg/environment/errors_test.go b/pkg/environment/errors_test.go index b49b0ce082..b60358f98e 100644 --- a/pkg/environment/errors_test.go +++ b/pkg/environment/errors_test.go @@ -46,4 +46,5 @@ func TestRequiredEnvError_SuggestsLocalModelForModelCredentials(t *testing.T) { assert.Contains(t, msg, "--model dmr/ai/qwen3") assert.Contains(t, msg, "pulled on first use") assert.Contains(t, msg, "docker model ls") + assert.Contains(t, msg, "docker agent setup") }