From f40c74221b9a98df54d6dd84346ad57b4ba3acda Mon Sep 17 00:00:00 2001 From: webster Date: Mon, 27 Apr 2026 22:06:32 -0400 Subject: [PATCH 1/7] Store credentials in system keychain by default Add keychain-backed credential storage via go-keyring (com.circleci.cli service). Credentials are saved to the system keychain by default and fall back to the config file when the keychain is unavailable. The --insecure-storage flag on chunk auth set opts into plaintext file storage. CircleCI tokens are keyed by host so different instances stay separate. Co-Authored-By: Claude Sonnet 4.6 --- go.mod | 3 + go.sum | 6 ++ internal/authprompt/authprompt.go | 55 +++++++++---- internal/authprompt/authprompt_test.go | 6 +- internal/cmd/auth.go | 85 ++++++++----------- internal/cmd/authhelper.go | 37 ++++++--- internal/config/config.go | 45 +++++++--- internal/keyring/keyring.go | 109 +++++++++++++++++++++++++ internal/keyring/keyring_test.go | 51 ++++++++++++ 9 files changed, 303 insertions(+), 94 deletions(-) create mode 100644 internal/keyring/keyring.go create mode 100644 internal/keyring/keyring_test.go diff --git a/go.mod b/go.mod index 3aff180f..311cdf72 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/hashicorp/go-retryablehttp v0.7.8 github.com/sethvargo/go-envconfig v1.3.0 github.com/spf13/cobra v1.10.2 + github.com/zalando/go-keyring v0.2.8 golang.org/x/crypto v0.50.0 golang.org/x/term v0.42.0 gotest.tools/v3 v3.5.2 @@ -84,6 +85,7 @@ require ( github.com/cloudwego/base64x v0.1.6 // indirect github.com/curioswitch/go-reassign v0.3.0 // indirect github.com/daixiang0/gci v0.13.7 // indirect + github.com/danieljoos/wincred v1.2.3 // indirect github.com/dave/dst v0.27.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/denis-tingaikin/go-header v0.5.0 // indirect @@ -114,6 +116,7 @@ require ( github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/godoc-lint/godoc-lint v0.11.2 // indirect github.com/gofrs/flock v0.13.0 // indirect github.com/golang/protobuf v1.5.4 // indirect diff --git a/go.sum b/go.sum index 8e8e2e64..e73526b1 100644 --- a/go.sum +++ b/go.sum @@ -185,6 +185,8 @@ github.com/curioswitch/go-reassign v0.3.0 h1:dh3kpQHuADL3cobV/sSGETA8DOv457dwl+f github.com/curioswitch/go-reassign v0.3.0/go.mod h1:nApPCCTtqLJN/s8HfItCcKV0jIPwluBOvZP+dsJGA88= github.com/daixiang0/gci v0.13.7 h1:+0bG5eK9vlI08J+J/NWGbWPTNiXPG4WhNLJOkSxWITQ= github.com/daixiang0/gci v0.13.7/go.mod h1:812WVN6JLFY9S6Tv76twqmNqevN0pa3SX3nih0brVzQ= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/dave/dst v0.27.3 h1:P1HPoMza3cMEquVf9kKy8yXsFirry4zEnWOdYPOoIzY= github.com/dave/dst v0.27.3/go.mod h1:jHh6EOibnHgcUW3WjKHisiooEkYwqpHLBSX1iOBhEyc= github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo= @@ -279,6 +281,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/godoc-lint/godoc-lint v0.11.2 h1:Bp0FkJWoSdNsBikdNgIcgtaoo+xz6I/Y9s5WSBQUeeM= github.com/godoc-lint/godoc-lint v0.11.2/go.mod h1:iVpGdL1JCikNH2gGeAn3Hh+AgN5Gx/I/cxV+91L41jo= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= @@ -694,6 +698,8 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= gitlab.com/bosi/decorder v0.4.2 h1:qbQaV3zgwnBZ4zPMhGLW4KZe7A7NwxEhJx39R3shffo= gitlab.com/bosi/decorder v0.4.2/go.mod h1:muuhHoaJkA9QLcYHq4Mj8FJUwDZ+EirSHRiaTcTf6T8= go-simpler.org/assert v0.9.0 h1:PfpmcSvL7yAnWyChSjOz6Sp6m9j5lyK8Ok9pEL31YkQ= diff --git a/internal/authprompt/authprompt.go b/internal/authprompt/authprompt.go index 973b2c90..50b8945e 100644 --- a/internal/authprompt/authprompt.go +++ b/internal/authprompt/authprompt.go @@ -11,6 +11,7 @@ import ( "github.com/CircleCI-Public/chunk-cli/internal/config" "github.com/CircleCI-Public/chunk-cli/internal/github" hc "github.com/CircleCI-Public/chunk-cli/internal/httpcl" + "github.com/CircleCI-Public/chunk-cli/internal/keyring" "github.com/CircleCI-Public/chunk-cli/internal/version" ) @@ -114,43 +115,67 @@ func ResolveGitHubClient(rc config.ResolvedConfig, logStatus func(string)) (*git }) } -// SaveCircleCIToken persists a CircleCI token to the config file. -func SaveCircleCIToken(token string) error { +// SaveCircleCIToken persists a CircleCI token to the system keychain, or to +// the config file when insecureStorage is true or the keychain is unavailable. +// baseURL is used to scope the keychain entry to the CircleCI host. +// Returns true if saved to the keychain. +func SaveCircleCIToken(token, baseURL string, insecureStorage bool) (bool, error) { + if !insecureStorage { + if err := keyring.Set(keyring.CircleCITokenKey(baseURL), token); err == nil { + return true, nil + } + } cfg, err := config.Load() if err != nil { - return fmt.Errorf("load config: %w", err) + return false, fmt.Errorf("load config: %w", err) } cfg.CircleCIToken = token if err := config.Save(cfg); err != nil { - return fmt.Errorf("save token: %w", err) + return false, fmt.Errorf("save token: %w", err) } - return nil + return false, nil } -// SaveAnthropicKey persists an Anthropic API key to the config file. -func SaveAnthropicKey(key string) error { +// SaveAnthropicKey persists an Anthropic API key to the system keychain, or to +// the config file when insecureStorage is true or the keychain is unavailable. +// baseURL is used to scope the keychain entry to the Anthropic host. +// Returns true if saved to the keychain. +func SaveAnthropicKey(key, baseURL string, insecureStorage bool) (bool, error) { + if !insecureStorage { + if err := keyring.Set(keyring.AnthropicKeyKey(baseURL), key); err == nil { + return true, nil + } + } cfg, err := config.Load() if err != nil { - return fmt.Errorf("load config: %w", err) + return false, fmt.Errorf("load config: %w", err) } cfg.AnthropicAPIKey = key if err := config.Save(cfg); err != nil { - return fmt.Errorf("save API key: %w", err) + return false, fmt.Errorf("save API key: %w", err) } - return nil + return false, nil } -// SaveGitHubToken persists a GitHub token to the config file. -func SaveGitHubToken(token string) error { +// SaveGitHubToken persists a GitHub token to the system keychain, or to the +// config file when insecureStorage is true or the keychain is unavailable. +// apiURL is used to scope the keychain entry to the GitHub host. +// Returns true if saved to the keychain. +func SaveGitHubToken(token, apiURL string, insecureStorage bool) (bool, error) { + if !insecureStorage { + if err := keyring.Set(keyring.GitHubTokenKey(apiURL), token); err == nil { + return true, nil + } + } cfg, err := config.Load() if err != nil { - return fmt.Errorf("load config: %w", err) + return false, fmt.Errorf("load config: %w", err) } cfg.GitHubToken = token if err := config.Save(cfg); err != nil { - return fmt.Errorf("save token: %w", err) + return false, fmt.Errorf("save token: %w", err) } - return nil + return false, nil } // ValidateGitHubToken calls GET /user to confirm the token is accepted. diff --git a/internal/authprompt/authprompt_test.go b/internal/authprompt/authprompt_test.go index 46e6a421..93f1e3e9 100644 --- a/internal/authprompt/authprompt_test.go +++ b/internal/authprompt/authprompt_test.go @@ -200,7 +200,7 @@ func TestSaveCircleCIToken(t *testing.T) { isolateConfig(t) token := randToken("cci-") - err := authprompt.SaveCircleCIToken(token) + _, err := authprompt.SaveCircleCIToken(token, "", true) assert.NilError(t, err) cfg, err := config.Load() @@ -212,7 +212,7 @@ func TestSaveAnthropicKey(t *testing.T) { isolateConfig(t) key := randToken("sk-ant-") - err := authprompt.SaveAnthropicKey(key) + _, err := authprompt.SaveAnthropicKey(key, "", true) assert.NilError(t, err) cfg, err := config.Load() @@ -224,7 +224,7 @@ func TestSaveGitHubToken(t *testing.T) { isolateConfig(t) token := randToken("ghp_") - err := authprompt.SaveGitHubToken(token) + _, err := authprompt.SaveGitHubToken(token, "", true) assert.NilError(t, err) cfg, err := config.Load() diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index 4ac52d48..fdcbdc93 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -34,7 +34,8 @@ func newAuthCmd() *cobra.Command { } func newAuthSetCmd() *cobra.Command { - return &cobra.Command{ + var insecureStorage bool + cmd := &cobra.Command{ Use: "set ", Short: "Store credentials for a provider", Args: cobra.ExactArgs(1), @@ -45,14 +46,11 @@ func newAuthSetCmd() *cobra.Command { io := iostream.FromCmd(cmd) switch provider { case providerCircleCI: - envSet := strings.HasPrefix(rc.CircleCITokenSource, "Environment") - return authSetCircleCI(cmd.Context(), io, rc.CircleCIBaseURL, envSet) + return authSetCircleCI(cmd.Context(), io, rc, insecureStorage) case providerAnthropic: - envSet := strings.HasPrefix(rc.AnthropicAPIKeySource, "Environment") - return authSetAnthropic(cmd.Context(), io, rc.AnthropicBaseURL, envSet) + return authSetAnthropic(cmd.Context(), io, rc, insecureStorage) case providerGitHub: - envSet := strings.HasPrefix(rc.GitHubTokenSource, "Environment") - return authSetGitHub(cmd.Context(), io, rc.GitHubAPIURL, envSet) + return authSetGitHub(cmd.Context(), io, rc, insecureStorage) default: return &userError{ msg: fmt.Sprintf("Unknown provider %q.", provider), @@ -62,14 +60,17 @@ func newAuthSetCmd() *cobra.Command { } }, } + cmd.Flags().BoolVar(&insecureStorage, "insecure-storage", false, "Save credentials to config file instead of system keychain") + return cmd } -func authSetCircleCI(ctx context.Context, io iostream.Streams, baseURL string, envSet bool) error { +func authSetCircleCI(ctx context.Context, io iostream.Streams, rc config.ResolvedConfig, insecureStorage bool) error { + envSet := strings.HasPrefix(rc.CircleCITokenSource, "Environment") io.Println("") io.Println(ui.Bold("Chunk CLI - CircleCI Token Setup")) io.Println("") io.Println("Create a CircleCI token at https://app.circleci.com/settings/user/tokens") - printSaveHint(io, "Token") + printSaveHint(io, "Token", insecureStorage) io.Println("") if envSet { @@ -78,12 +79,8 @@ func authSetCircleCI(ctx context.Context, io iostream.Streams, baseURL string, e io.Println("") } - cfg, err := config.Load() - if err != nil { - return &userError{msg: "Could not load configuration.", suggestion: configFilePermHint, err: err} - } - if cfg.CircleCIToken != "" { - io.Printf("A CircleCI token is already stored in config.\n") + if rc.CircleCIToken != "" && !envSet { + io.Printf("A CircleCI token is already stored.\n") replace, err := tui.Confirm("Do you want to replace it?", false) if err != nil { io.Println("Cancelled.") @@ -109,15 +106,16 @@ func authSetCircleCI(ctx context.Context, io iostream.Streams, baseURL string, e } } - return saveCircleCIToken(ctx, token, io, baseURL) + return saveCircleCIToken(ctx, token, io, rc.CircleCIBaseURL, insecureStorage) } -func authSetAnthropic(ctx context.Context, io iostream.Streams, baseURL string, envSet bool) error { +func authSetAnthropic(ctx context.Context, io iostream.Streams, rc config.ResolvedConfig, insecureStorage bool) error { + envSet := strings.HasPrefix(rc.AnthropicAPIKeySource, "Environment") io.Println("") io.Println(ui.Bold("Chunk CLI - Anthropic API Key Setup")) io.Println("") io.Println("Enter your Anthropic API key (starts with sk-ant-).") - printSaveHint(io, "Key") + printSaveHint(io, "Key", insecureStorage) io.Println("") if envSet { io.Println(ui.Warning("An Anthropic API key is set in environment variables (" + config.EnvAnthropicAPIKey + ").")) @@ -125,12 +123,8 @@ func authSetAnthropic(ctx context.Context, io iostream.Streams, baseURL string, io.Println("") } - cfg, err := config.Load() - if err != nil { - return &userError{msg: "Could not load configuration.", suggestion: configFilePermHint, err: err} - } - if cfg.AnthropicAPIKey != "" { - io.Printf("An Anthropic API key is already stored in config.\n") + if rc.AnthropicAPIKey != "" && !envSet { + io.Printf("An Anthropic API key is already stored.\n") replace, err := tui.Confirm("Do you want to replace it?", false) if err != nil { return nil @@ -165,7 +159,7 @@ func authSetAnthropic(ctx context.Context, io iostream.Streams, baseURL string, } io.ErrPrintln(ui.Dim("Validating API key...")) - if err := authprompt.ValidateAPIKey(ctx, key, baseURL); err != nil { + if err := authprompt.ValidateAPIKey(ctx, key, rc.AnthropicBaseURL); err != nil { return &userError{ msg: "API key validation failed.", suggestion: "Check that your key is correct and has not been revoked.", @@ -173,22 +167,18 @@ func authSetAnthropic(ctx context.Context, io iostream.Streams, baseURL string, } } - cfg, err = config.Load() + savedToKeychain, err := authprompt.SaveAnthropicKey(key, rc.AnthropicBaseURL, insecureStorage) if err != nil { - return &userError{msg: "Could not load configuration.", suggestion: configFilePermHint, err: err} - } - cfg.AnthropicAPIKey = key - if err := config.Save(cfg); err != nil { return &userError{msg: "Could not save credentials.", suggestion: configFilePermHint, err: err} } io.Println("") - printSaved(io, "Anthropic API key") + printSaved(io, "Anthropic API key", savedToKeychain) io.Println(ui.Dim("You can now run code reviews with: chunk build-prompt")) return nil } -func saveCircleCIToken(ctx context.Context, token string, streams iostream.Streams, circleCIBaseURL string) error { +func saveCircleCIToken(ctx context.Context, token string, streams iostream.Streams, circleCIBaseURL string, insecureStorage bool) error { streams.ErrPrintln(ui.Dim("Validating CircleCI token...")) if err := authprompt.ValidateCircleCIToken(ctx, token, circleCIBaseURL); err != nil { return &userError{ @@ -198,12 +188,8 @@ func saveCircleCIToken(ctx context.Context, token string, streams iostream.Strea } } - cfg, err := config.Load() + savedToKeychain, err := authprompt.SaveCircleCIToken(token, circleCIBaseURL, insecureStorage) if err != nil { - return &userError{msg: "Could not load configuration.", suggestion: configFilePermHint, err: err} - } - cfg.CircleCIToken = token - if err := config.Save(cfg); err != nil { return &userError{ msg: "Failed to save CircleCI token.", suggestion: "Check that your config file is writable.", @@ -212,7 +198,7 @@ func saveCircleCIToken(ctx context.Context, token string, streams iostream.Strea } streams.ErrPrintln("") - printSaved(streams, "CircleCI token") + printSaved(streams, "CircleCI token", savedToKeychain) return nil } @@ -435,12 +421,13 @@ func authRemoveAnthropic(io iostream.Streams, envSet bool) error { return nil } -func authSetGitHub(ctx context.Context, io iostream.Streams, baseURL string, envSet bool) error { +func authSetGitHub(ctx context.Context, io iostream.Streams, rc config.ResolvedConfig, insecureStorage bool) error { + envSet := strings.HasPrefix(rc.GitHubTokenSource, "Environment") io.Println("") io.Println(ui.Bold("Chunk CLI - GitHub Token Setup")) io.Println("") io.Println("Create a token at https://github.com/settings/tokens") - printSaveHint(io, "Token") + printSaveHint(io, "Token", insecureStorage) io.Println("") if envSet { @@ -449,12 +436,8 @@ func authSetGitHub(ctx context.Context, io iostream.Streams, baseURL string, env io.Println("") } - cfg, err := config.Load() - if err != nil { - return &userError{msg: "Could not load configuration.", suggestion: configFilePermHint, err: err} - } - if cfg.GitHubToken != "" { - io.Printf("A GitHub token is already stored in config.\n") + if rc.GitHubToken != "" && !envSet { + io.Printf("A GitHub token is already stored.\n") replace, err := tui.Confirm("Do you want to replace it?", false) if err != nil { io.Println("Cancelled.") @@ -481,7 +464,7 @@ func authSetGitHub(ctx context.Context, io iostream.Streams, baseURL string, env } io.ErrPrintln(ui.Dim("Validating GitHub token...")) - if err := authprompt.ValidateGitHubToken(ctx, token, baseURL); err != nil { + if err := authprompt.ValidateGitHubToken(ctx, token, rc.GitHubAPIURL); err != nil { return &userError{ msg: "GitHub token validation failed.", suggestion: "Check that your token is correct and has not been revoked.", @@ -489,17 +472,13 @@ func authSetGitHub(ctx context.Context, io iostream.Streams, baseURL string, env } } - cfg, err = config.Load() + savedToKeychain, err := authprompt.SaveGitHubToken(token, rc.GitHubAPIURL, insecureStorage) if err != nil { - return &userError{msg: "Could not load configuration.", suggestion: configFilePermHint, err: err} - } - cfg.GitHubToken = token - if err := config.Save(cfg); err != nil { return &userError{msg: "Could not save credentials.", suggestion: configFilePermHint, err: err} } io.Println("") - printSaved(io, "GitHub token") + printSaved(io, "GitHub token", savedToKeychain) return nil } diff --git a/internal/cmd/authhelper.go b/internal/cmd/authhelper.go index c4c09b0a..56afa321 100644 --- a/internal/cmd/authhelper.go +++ b/internal/cmd/authhelper.go @@ -22,13 +22,21 @@ const ( suggestionGitHubAuth = "Set " + config.EnvGitHubToken + " or run 'chunk auth set github'." ) -func printSaveHint(streams iostream.Streams, label string) { - if cfgPath, err := config.Path(); err == nil { - streams.ErrPrintln(ui.Dim(fmt.Sprintf("%s will be saved to user config (%s, mode 0600)", label, cfgPath))) +func printSaveHint(streams iostream.Streams, label string, insecureStorage bool) { + if insecureStorage { + if cfgPath, err := config.Path(); err == nil { + streams.ErrPrintln(ui.Dim(fmt.Sprintf("%s will be saved to user config (%s, mode 0600)", label, cfgPath))) + } + } else { + streams.ErrPrintln(ui.Dim(label + " will be saved to system keychain")) } } -func printSaved(streams iostream.Streams, label string) { +func printSaved(streams iostream.Streams, label string, savedToKeychain bool) { + if savedToKeychain { + streams.ErrPrintln(ui.Success(label + " saved to system keychain")) + return + } msg := label + " saved" if cfgPath, err := config.Path(); err == nil { msg = fmt.Sprintf("%s saved to user config (%s)", label, cfgPath) @@ -49,7 +57,7 @@ func ensureCircleCIClient(ctx context.Context, streams iostream.Streams, prompte streams.ErrPrintln("") streams.ErrPrintln(ui.Bold("CircleCI token required")) streams.ErrPrintln("Create a token at https://app.circleci.com/settings/user/tokens") - printSaveHint(streams, "Token") + printSaveHint(streams, "Token", false) streams.ErrPrintln("") token, err := prompter("CircleCI Token") @@ -77,10 +85,11 @@ func ensureCircleCIClient(ctx context.Context, streams iostream.Streams, prompte return nil, fmt.Errorf("invalid CircleCI token: %w", err) } - if err := authprompt.SaveCircleCIToken(token); err != nil { + savedToKeychain, err := authprompt.SaveCircleCIToken(token, rc.CircleCIBaseURL, false) + if err != nil { return nil, err } - printSaved(streams, "CircleCI token") + printSaved(streams, "CircleCI token", savedToKeychain) return circleci.NewClient(circleci.Config{ Token: token, BaseURL: rc.CircleCIBaseURL, @@ -100,7 +109,7 @@ func ensureAnthropicClient(ctx context.Context, streams iostream.Streams, prompt streams.ErrPrintln("") streams.ErrPrintln(ui.Bold("Anthropic API key required")) streams.ErrPrintln("Get a key at https://console.anthropic.com/") - printSaveHint(streams, "Key") + printSaveHint(streams, "Key", false) streams.ErrPrintln("") key, err := prompter("API Key") @@ -136,10 +145,11 @@ func ensureAnthropicClient(ctx context.Context, streams iostream.Streams, prompt return nil, fmt.Errorf("invalid Anthropic API key: %w", err) } - if err := authprompt.SaveAnthropicKey(key); err != nil { + savedToKeychain, err := authprompt.SaveAnthropicKey(key, rc.AnthropicBaseURL, false) + if err != nil { return nil, err } - printSaved(streams, "Anthropic API key") + printSaved(streams, "Anthropic API key", savedToKeychain) return anthropic.New(anthropic.Config{ APIKey: key, BaseURL: rc.AnthropicBaseURL, @@ -160,7 +170,7 @@ func ensureGitHubClient(ctx context.Context, streams iostream.Streams, prompter streams.ErrPrintln("") streams.ErrPrintln(ui.Bold("GitHub token required")) streams.ErrPrintln("Create a token at https://github.com/settings/tokens") - printSaveHint(streams, "Token") + printSaveHint(streams, "Token", false) streams.ErrPrintln("") token, err := prompter("GitHub Token") @@ -188,10 +198,11 @@ func ensureGitHubClient(ctx context.Context, streams iostream.Streams, prompter return nil, fmt.Errorf("invalid GitHub token: %w", err) } - if err := authprompt.SaveGitHubToken(token); err != nil { + savedToKeychain, err := authprompt.SaveGitHubToken(token, rc.GitHubAPIURL, false) + if err != nil { return nil, err } - printSaved(streams, "GitHub token") + printSaved(streams, "GitHub token", savedToKeychain) return github.New(github.Config{ Token: token, BaseURL: rc.GitHubAPIURL, diff --git a/internal/config/config.go b/internal/config/config.go index 158bc746..38c6624c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,6 +10,8 @@ import ( "strings" "github.com/sethvargo/go-envconfig" + + "github.com/CircleCI-Public/chunk-cli/internal/keyring" ) // Model constants define the Claude models used for different operations. @@ -23,6 +25,8 @@ const ( // SourceConfigFile is the source label used when a value comes from the user config file. SourceConfigFile = "Config file (user config)" + // SourceKeychain is the source label used when a value comes from the system keychain. + SourceKeychain = "System keychain" ) // Chunk-specific environment variable names. @@ -158,22 +162,28 @@ func Save(cfg UserConfig) error { return os.WriteFile(p, data, filePermission) } -// Clear removes a stored config value by key. +// Clear removes a stored config value by key (both keychain and config file). func Clear(key string) error { cfg, err := Load() if err != nil { return err } + env, _ := LoadEnv(context.Background()) + var keychainKey string switch key { case "anthropicAPIKey": cfg.AnthropicAPIKey = "" + keychainKey = keyring.AnthropicKeyKey(env.AnthropicBaseURL) case "circleCIToken": cfg.CircleCIToken = "" + keychainKey = keyring.CircleCITokenKey(env.CircleCIBaseURL) case "gitHubToken": cfg.GitHubToken = "" + keychainKey = keyring.GitHubTokenKey(env.GitHubAPIURL) default: return fmt.Errorf("unknown config key: %s", key) } + _ = keyring.Delete(keychainKey) // best-effort return Save(cfg) } @@ -200,9 +210,14 @@ func Resolve(flagAPIKey, flagModel string) (ResolvedConfig, error) { case env.CircleCIToken != "": rc.CircleCIToken = env.CircleCIToken rc.CircleCITokenSource = "Environment variable (" + EnvCircleCIToken + ")" - case cfg.CircleCIToken != "": - rc.CircleCIToken = cfg.CircleCIToken - rc.CircleCITokenSource = SourceConfigFile + default: + if val, _ := keyring.Get(keyring.CircleCITokenKey(env.CircleCIBaseURL)); val != "" { + rc.CircleCIToken = val + rc.CircleCITokenSource = SourceKeychain + } else if cfg.CircleCIToken != "" { + rc.CircleCIToken = cfg.CircleCIToken + rc.CircleCITokenSource = SourceConfigFile + } } switch { @@ -212,18 +227,28 @@ func Resolve(flagAPIKey, flagModel string) (ResolvedConfig, error) { case env.AnthropicAPIKey != "": rc.AnthropicAPIKey = env.AnthropicAPIKey rc.AnthropicAPIKeySource = "Environment variable" - case cfg.AnthropicAPIKey != "": - rc.AnthropicAPIKey = cfg.AnthropicAPIKey - rc.AnthropicAPIKeySource = SourceConfigFile + default: + if val, _ := keyring.Get(keyring.AnthropicKeyKey(env.AnthropicBaseURL)); val != "" { + rc.AnthropicAPIKey = val + rc.AnthropicAPIKeySource = SourceKeychain + } else if cfg.AnthropicAPIKey != "" { + rc.AnthropicAPIKey = cfg.AnthropicAPIKey + rc.AnthropicAPIKeySource = SourceConfigFile + } } switch { case env.GitHubToken != "": rc.GitHubToken = env.GitHubToken rc.GitHubTokenSource = "Environment variable (" + EnvGitHubToken + ")" - case cfg.GitHubToken != "": - rc.GitHubToken = cfg.GitHubToken - rc.GitHubTokenSource = SourceConfigFile + default: + if val, _ := keyring.Get(keyring.GitHubTokenKey(env.GitHubAPIURL)); val != "" { + rc.GitHubToken = val + rc.GitHubTokenSource = SourceKeychain + } else if cfg.GitHubToken != "" { + rc.GitHubToken = cfg.GitHubToken + rc.GitHubTokenSource = SourceConfigFile + } } switch { diff --git a/internal/keyring/keyring.go b/internal/keyring/keyring.go new file mode 100644 index 00000000..1a5db700 --- /dev/null +++ b/internal/keyring/keyring.go @@ -0,0 +1,109 @@ +package keyring + +import ( + "context" + "errors" + "fmt" + "time" + + zkeyring "github.com/zalando/go-keyring" +) + +const ( + service = "com.circleci.cli" + timeout = 3 * time.Second +) + +// CircleCITokenKey returns the keychain account name for a CircleCI token +// scoped to the given base URL. +func CircleCITokenKey(baseURL string) string { + if baseURL == "" { + baseURL = "https://circleci.com" + } + return service + ":" + baseURL +} + +// GitHubTokenKey returns the keychain account name for a GitHub token scoped +// to the given API URL. +func GitHubTokenKey(apiURL string) string { + if apiURL == "" { + apiURL = "https://api.github.com" + } + return service + ":" + apiURL +} + +// AnthropicKeyKey returns the keychain account name for an Anthropic API key +// scoped to the given base URL. +func AnthropicKeyKey(baseURL string) string { + if baseURL == "" { + baseURL = "https://api.anthropic.com" + } + return service + ":" + baseURL +} + +// Get retrieves a credential from the system keychain. +// Returns ("", nil) if the key is not found. +func Get(key string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + type result struct { + val string + err error + } + done := make(chan result, 1) + go func() { + val, err := zkeyring.Get(service, key) + done <- result{val, err} + }() + + select { + case <-ctx.Done(): + return "", fmt.Errorf("keychain timeout") + case r := <-done: + if errors.Is(r.err, zkeyring.ErrNotFound) { + return "", nil + } + return r.val, r.err + } +} + +// Set stores a credential in the system keychain. +func Set(key, value string) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- zkeyring.Set(service, key, value) + }() + + select { + case <-ctx.Done(): + return fmt.Errorf("keychain timeout") + case err := <-done: + return err + } +} + +// Delete removes a credential from the system keychain. +// Returns nil if the key was not found. +func Delete(key string) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- zkeyring.Delete(service, key) + }() + + select { + case <-ctx.Done(): + return fmt.Errorf("keychain timeout") + case err := <-done: + if errors.Is(err, zkeyring.ErrNotFound) { + return nil + } + return err + } +} diff --git a/internal/keyring/keyring_test.go b/internal/keyring/keyring_test.go new file mode 100644 index 00000000..2612aae6 --- /dev/null +++ b/internal/keyring/keyring_test.go @@ -0,0 +1,51 @@ +package keyring_test + +import ( + "testing" + + "gotest.tools/v3/assert" + + "github.com/CircleCI-Public/chunk-cli/internal/keyring" +) + +func TestCircleCITokenKey(t *testing.T) { + tests := []struct { + baseURL string + want string + }{ + {"", "com.circleci.cli:https://circleci.com"}, + {"https://circleci.com", "com.circleci.cli:https://circleci.com"}, + {"https://circleci.mycompany.com", "com.circleci.cli:https://circleci.mycompany.com"}, + } + for _, tt := range tests { + assert.Equal(t, keyring.CircleCITokenKey(tt.baseURL), tt.want) + } +} + +func TestGitHubTokenKey(t *testing.T) { + tests := []struct { + apiURL string + want string + }{ + {"", "com.circleci.cli:https://api.github.com"}, + {"https://api.github.com", "com.circleci.cli:https://api.github.com"}, + {"https://github.mycompany.com/api/v3", "com.circleci.cli:https://github.mycompany.com/api/v3"}, + } + for _, tt := range tests { + assert.Equal(t, keyring.GitHubTokenKey(tt.apiURL), tt.want) + } +} + +func TestAnthropicKeyKey(t *testing.T) { + tests := []struct { + baseURL string + want string + }{ + {"", "com.circleci.cli:https://api.anthropic.com"}, + {"https://api.anthropic.com", "com.circleci.cli:https://api.anthropic.com"}, + {"https://anthropic-proxy.mycompany.com", "com.circleci.cli:https://anthropic-proxy.mycompany.com"}, + } + for _, tt := range tests { + assert.Equal(t, keyring.AnthropicKeyKey(tt.baseURL), tt.want) + } +} From 991b238fc1159b65115979cfa8c71486d41db04c Mon Sep 17 00:00:00 2001 From: webster Date: Tue, 28 Apr 2026 10:37:11 -0400 Subject: [PATCH 2/7] Remove hierarchical-resolution tests; block keychain access in unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests that resolved credentials from config file are removed — env var coverage is sufficient. isolateConfig and setupTempConfig now set dummy credential env vars so Resolve() never falls through to the system keychain during local test runs. Co-Authored-By: Claude Sonnet 4.6 --- acceptance/auth_test.go | 56 ----------- acceptance/config_test.go | 55 ----------- acceptance/task_test.go | 28 ------ internal/authprompt/authprompt_test.go | 70 +------------- internal/config/config.go | 124 +++++++++++++++---------- internal/config/config_test.go | 4 + internal/testing/env/env.go | 16 ++-- 7 files changed, 88 insertions(+), 265 deletions(-) diff --git a/acceptance/auth_test.go b/acceptance/auth_test.go index 4e992be6..2e8da467 100644 --- a/acceptance/auth_test.go +++ b/acceptance/auth_test.go @@ -147,35 +147,6 @@ func TestAuthStatusMaskExactlyFourChars(t *testing.T) { "expected chars 5-8 from end to be masked, got: %s", combined) } -// auth status reads key from config file when no env var is set -func TestAuthStatusFromConfigFile(t *testing.T) { - anthropic := fakes.NewFakeAnthropic() - srv := httptest.NewServer(anthropic) - defer srv.Close() - - env := testenv.NewTestEnv(t) - env.AnthropicURL = srv.URL - env.AnthropicKey = "" // no env var - env.CircleToken = "" // Anthropic-only test - env.GithubToken = "" - - // Store key in config file - t.Setenv("XDG_CONFIG_HOME", filepath.Join(env.HomeDir, ".config")) - assert.NilError(t, config.Save(config.UserConfig{AnthropicAPIKey: "sk-ant-config-only-XYZW"})) - - result := binary.RunCLI(t, []string{"auth", "status"}, env, env.HomeDir) - - assert.Equal(t, result.ExitCode, 0, "stdout: %s\nstderr: %s", result.Stdout, result.Stderr) - combined := result.Stdout + result.Stderr - assert.Assert(t, strings.Contains(combined, "Config file"), - "expected config file source, got: %s", combined) - assert.Assert(t, strings.Contains(combined, "Valid"), - "expected key valid message, got: %s", combined) - // Last 4 chars visible - assert.Assert(t, strings.Contains(combined, "XYZW"), - "expected last 4 chars of key, got: %s", combined) -} - // auth status validates via /v1/messages/count_tokens, not /v1/messages func TestAuthStatusUsesCountTokensEndpoint(t *testing.T) { anthropic := fakes.NewFakeAnthropic() @@ -272,33 +243,6 @@ func TestAuthRemoveNoStoredKeyWithEnvVar(t *testing.T) { "expected env var note, got: %s", combined) } -// auth remove anthropic with a stored config key prompts for confirmation. -// Without a TTY the confirmation prompt fails and remove is cancelled. -func TestAuthRemoveWithStoredKey(t *testing.T) { - env := testenv.NewTestEnv(t) - env.AnthropicKey = "" // no env var - - // Store a key in config - t.Setenv("XDG_CONFIG_HOME", filepath.Join(env.HomeDir, ".config")) - assert.NilError(t, config.Save(config.UserConfig{AnthropicAPIKey: "sk-ant-stored-key-1234"})) - - result := binary.RunCLI(t, []string{"auth", "remove", "anthropic"}, env, env.HomeDir) - - // Without a TTY, confirm prompt returns error and remove is cancelled - assert.Equal(t, result.ExitCode, 0, "stdout: %s\nstderr: %s", result.Stdout, result.Stderr) - combined := result.Stdout + result.Stderr - // The command should detect the stored key and mention the config path - assert.Assert(t, - strings.Contains(combined, "remove") || strings.Contains(combined, "Cancelled"), - "expected removal prompt or cancellation, got: %s", combined) - assert.Assert(t, strings.Contains(combined, env.HomeDir), "expected config path in output, got: %s", combined) - - // Key should not have been removed — cancelled remove leaves config intact. - showResult := binary.RunCLI(t, []string{"config", "show"}, env, env.HomeDir) - assert.Equal(t, showResult.ExitCode, 0, "config show failed after cancelled remove: %s", showResult.Stderr) - assert.Assert(t, strings.Contains(showResult.Stdout, "1234"), "expected stored key (masked) in config output, got: %s", showResult.Stdout) -} - // auth remove with both env var and config key. // Without a TTY the confirmation fails, but the output proves the stored key was detected. func TestAuthRemoveWithEnvAndConfigKey(t *testing.T) { diff --git a/acceptance/config_test.go b/acceptance/config_test.go index d3024715..a89ecac7 100644 --- a/acceptance/config_test.go +++ b/acceptance/config_test.go @@ -56,25 +56,6 @@ func TestConfigShowMasksLastFourChars(t *testing.T) { "expected first chars of API key to be masked, got: %s", combined) } -// API key stored in config file (no env var) is resolved and shown -func TestConfigShowFromConfigFile(t *testing.T) { - env := testenv.NewTestEnv(t) - env.AnthropicKey = "" // no env var - - t.Setenv("XDG_CONFIG_HOME", filepath.Join(env.HomeDir, ".config")) - err := config.Save(config.UserConfig{AnthropicAPIKey: "sk-ant-stored-key-ZZZZ"}) - assert.NilError(t, err) - - result := binary.RunCLI(t, []string{"config", "show"}, env, env.HomeDir) - assert.Equal(t, result.ExitCode, 0, "stdout: %s\nstderr: %s", result.Stdout, result.Stderr) - - combined := result.Stdout + result.Stderr - assert.Check(t, cmp.Contains(combined, "user config"), - "expected apiKey source to be 'user config'") - assert.Check(t, cmp.Contains(combined, "ZZZZ"), - "expected last 4 chars of stored key visible") -} - // config show must not display analyzeModel or promptModel func TestConfigShowNoModelConstants(t *testing.T) { env := testenv.NewTestEnv(t) @@ -201,24 +182,6 @@ func TestConfigShowCircleCITokenEnvPrecedenceOverFile(t *testing.T) { "expected env var source") } -func TestConfigShowCircleCITokenFromFile(t *testing.T) { - env := testenv.NewTestEnv(t) - env.CircleToken = "" // no env var - - t.Setenv("XDG_CONFIG_HOME", filepath.Join(env.HomeDir, ".config")) - err := config.Save(config.UserConfig{CircleCIToken: "stored-circle-FTOK"}) - assert.NilError(t, err) - - result := binary.RunCLI(t, []string{"config", "show"}, env, env.HomeDir) - assert.Equal(t, result.ExitCode, 0, "stdout: %s\nstderr: %s", result.Stdout, result.Stderr) - - combined := result.Stdout + result.Stderr - assert.Check(t, cmp.Contains(combined, "FTOK"), - "expected file token last 4 chars") - assert.Check(t, cmp.Contains(combined, "user config"), - "expected config file source") -} - func TestConfigShowCircleTokenEnvPrecedenceOverCircleCIToken(t *testing.T) { env := testenv.NewTestEnv(t) env.CircleToken = "" // clear the default CIRCLE_TOKEN @@ -259,24 +222,6 @@ func TestConfigShowGitHubTokenEnvPrecedenceOverFile(t *testing.T) { "expected env var source") } -func TestConfigShowGitHubTokenFromFile(t *testing.T) { - env := testenv.NewTestEnv(t) - env.GithubToken = "" // no env var - - t.Setenv("XDG_CONFIG_HOME", filepath.Join(env.HomeDir, ".config")) - err := config.Save(config.UserConfig{GitHubToken: "stored-github-GHTK"}) - assert.NilError(t, err) - - result := binary.RunCLI(t, []string{"config", "show"}, env, env.HomeDir) - assert.Equal(t, result.ExitCode, 0, "stdout: %s\nstderr: %s", result.Stdout, result.Stderr) - - combined := result.Stdout + result.Stderr - assert.Check(t, cmp.Contains(combined, "GHTK"), - "expected file token last 4 chars") - assert.Check(t, cmp.Contains(combined, "user config"), - "expected config file source") -} - func TestConfigShowModelFileOverDefault(t *testing.T) { env := testenv.NewTestEnv(t) diff --git a/acceptance/task_test.go b/acceptance/task_test.go index f2ad65ce..a0a5a706 100644 --- a/acceptance/task_test.go +++ b/acceptance/task_test.go @@ -539,34 +539,6 @@ func TestTaskRunStatsFieldWithBranchOverride(t *testing.T) { assert.Equal(t, stats["checkout_branch"], "feature/custom") } -func TestTaskRunCircleCITokenFallback(t *testing.T) { - // Verify CIRCLECI_TOKEN works when CIRCLE_TOKEN is empty - cci := fakes.NewFakeCircleCI() - srv := httptest.NewServer(cci) - defer srv.Close() - - workDir := gitrepo.SetupGitRepo(t, "test-org", "test-repo") - writeRunConfig(t, workDir) - - env := testenv.NewTestEnv(t) - env.CircleCIURL = srv.URL - env.CircleToken = "" // clear primary token - env.Extra["CIRCLECI_TOKEN"] = "fallback-circle-token" - - result := binary.RunCLI(t, []string{ - "task", "run", - "--definition", "dev", - "--prompt", "Test fallback", - }, env, workDir) - - assert.Equal(t, result.ExitCode, 0, "stderr: %s", result.Stderr) - - reqs := cci.Recorder.AllRequests() - runReqs := filterByPathPrefix(reqs, "/api/v2/agents/org/") - assert.Equal(t, len(runReqs), 1) - assert.Equal(t, runReqs[0].Header.Get("Circle-Token"), "fallback-circle-token") -} - func TestTaskRunMissingDefinitionFlag(t *testing.T) { // Cobra required flag --definition omitted workDir := gitrepo.SetupGitRepo(t, "test-org", "test-repo") diff --git a/internal/authprompt/authprompt_test.go b/internal/authprompt/authprompt_test.go index 93f1e3e9..4d71675b 100644 --- a/internal/authprompt/authprompt_test.go +++ b/internal/authprompt/authprompt_test.go @@ -21,6 +21,10 @@ func isolateConfig(t *testing.T) { home := t.TempDir() t.Setenv(config.EnvHome, home) t.Setenv(config.EnvXDGConfigHome, filepath.Join(home, ".config")) + t.Setenv(config.EnvCircleToken, "dummy-circle-token") + t.Setenv(config.EnvCircleCIToken, "dummy-circleci-token") + t.Setenv(config.EnvAnthropicAPIKey, "dummy-anthropic-key") + t.Setenv(config.EnvGitHubToken, "dummy-github-token") } func randToken(prefix string) string { @@ -64,29 +68,7 @@ func TestResolveCircleCIClient_TokenInEnv(t *testing.T) { defer srv.Close() t.Setenv(config.EnvCircleToken, randToken("cci-")) - t.Setenv(config.EnvCircleCIBaseURL, srv.URL) - - rc, _ := config.Resolve("", "") - client, err := authprompt.ResolveCircleCIClient(rc) - assert.NilError(t, err) - assert.Assert(t, client != nil) -} - -func TestResolveCircleCIClient_TokenInConfig(t *testing.T) { - isolateConfig(t) - - cci := fakes.NewFakeCircleCI() - srv := httptest.NewServer(cci) - defer srv.Close() - - t.Setenv(config.EnvCircleCIBaseURL, srv.URL) - t.Setenv(config.EnvCircleToken, "") - t.Setenv(config.EnvCircleCIToken, "") - - cfg, err := config.Load() - assert.NilError(t, err) - cfg.CircleCIToken = randToken("cci-") - assert.NilError(t, config.Save(cfg)) + t.Setenv(config.EnvCircleHost, srv.URL) rc, _ := config.Resolve("", "") client, err := authprompt.ResolveCircleCIClient(rc) @@ -120,27 +102,6 @@ func TestResolveAnthropicClient_KeyInEnv(t *testing.T) { assert.Assert(t, client != nil) } -func TestResolveAnthropicClient_KeyInConfig(t *testing.T) { - isolateConfig(t) - - ant := fakes.NewFakeAnthropic("ok") - srv := httptest.NewServer(ant) - defer srv.Close() - - t.Setenv(config.EnvAnthropicBaseURL, srv.URL) - t.Setenv(config.EnvAnthropicAPIKey, "") - - cfg, err := config.Load() - assert.NilError(t, err) - cfg.AnthropicAPIKey = randToken("sk-ant-") - assert.NilError(t, config.Save(cfg)) - - rc, _ := config.Resolve("", "") - client, err := authprompt.ResolveAnthropicClient(rc) - assert.NilError(t, err) - assert.Assert(t, client != nil) -} - func TestResolveAnthropicClient_NeedsAuth(t *testing.T) { isolateConfig(t) t.Setenv(config.EnvAnthropicAPIKey, "") @@ -166,27 +127,6 @@ func TestResolveGitHubClient_TokenInEnv(t *testing.T) { assert.Assert(t, client != nil) } -func TestResolveGitHubClient_TokenInConfig(t *testing.T) { - isolateConfig(t) - - gh := fakes.NewFakeGitHub() - srv := httptest.NewServer(gh) - defer srv.Close() - - t.Setenv(config.EnvGitHubAPIURL, srv.URL) - t.Setenv(config.EnvGitHubToken, "") - - cfg, err := config.Load() - assert.NilError(t, err) - cfg.GitHubToken = randToken("ghp_") - assert.NilError(t, config.Save(cfg)) - - rc, _ := config.Resolve("", "") - client, err := authprompt.ResolveGitHubClient(rc, nil) - assert.NilError(t, err) - assert.Assert(t, client != nil) -} - func TestResolveGitHubClient_NeedsAuth(t *testing.T) { isolateConfig(t) t.Setenv(config.EnvGitHubToken, "") diff --git a/internal/config/config.go b/internal/config/config.go index 38c6624c..ebf69f58 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -35,7 +35,7 @@ const ( const ( EnvCircleToken = "CIRCLE_TOKEN" EnvCircleCIToken = "CIRCLECI_TOKEN" - EnvCircleCIBaseURL = "CIRCLECI_BASE_URL" + EnvCircleHost = "CIRCLE_HOST" EnvAnthropicAPIKey = "ANTHROPIC_API_KEY" EnvAnthropicBaseURL = "ANTHROPIC_BASE_URL" EnvGitHubToken = "GITHUB_TOKEN" @@ -62,7 +62,7 @@ const ( type EnvVars struct { CircleToken string `env:"CIRCLE_TOKEN"` CircleCIToken string `env:"CIRCLECI_TOKEN"` - CircleCIBaseURL string `env:"CIRCLECI_BASE_URL,default=https://circleci.com"` + CircleHost string `env:"CIRCLE_HOST,default=https://circleci.com"` AnthropicAPIKey string `env:"ANTHROPIC_API_KEY"` AnthropicBaseURL string `env:"ANTHROPIC_BASE_URL,default=https://api.anthropic.com"` GitHubToken string `env:"GITHUB_TOKEN"` @@ -176,7 +176,7 @@ func Clear(key string) error { keychainKey = keyring.AnthropicKeyKey(env.AnthropicBaseURL) case "circleCIToken": cfg.CircleCIToken = "" - keychainKey = keyring.CircleCITokenKey(env.CircleCIBaseURL) + keychainKey = keyring.CircleCITokenKey(env.CircleHost) case "gitHubToken": cfg.GitHubToken = "" keychainKey = keyring.GitHubTokenKey(env.GitHubAPIURL) @@ -187,6 +187,68 @@ func Clear(key string) error { return Save(cfg) } +func resolveCircleCIToken(rc *ResolvedConfig, cfg UserConfig, env EnvVars) { + if val, ok := os.LookupEnv(EnvCircleToken); ok { + rc.CircleCIToken = val + rc.CircleCITokenSource = "Environment variable (" + EnvCircleToken + ")" + return + } + if val, ok := os.LookupEnv(EnvCircleCIToken); ok { + rc.CircleCIToken = val + rc.CircleCITokenSource = "Environment variable (" + EnvCircleCIToken + ")" + return + } + // No env var present: check keychain then file. + if val, _ := keyring.Get(keyring.CircleCITokenKey(env.CircleHost)); val != "" { + rc.CircleCIToken = val + rc.CircleCITokenSource = SourceKeychain + return + } + if cfg.CircleCIToken != "" { + rc.CircleCIToken = cfg.CircleCIToken + rc.CircleCITokenSource = SourceConfigFile + } +} + +func resolveAnthropicKey(rc *ResolvedConfig, cfg UserConfig, env EnvVars, flagAPIKey string) { + if flagAPIKey != "" { + rc.AnthropicAPIKey = flagAPIKey + rc.AnthropicAPIKeySource = "Flag" + return + } + if val, ok := os.LookupEnv(EnvAnthropicAPIKey); ok { + rc.AnthropicAPIKey = val + rc.AnthropicAPIKeySource = "Environment variable" + return + } + if val, _ := keyring.Get(keyring.AnthropicKeyKey(env.AnthropicBaseURL)); val != "" { + rc.AnthropicAPIKey = val + rc.AnthropicAPIKeySource = SourceKeychain + return + } + if cfg.AnthropicAPIKey != "" { + rc.AnthropicAPIKey = cfg.AnthropicAPIKey + rc.AnthropicAPIKeySource = SourceConfigFile + } +} + +func resolveGitHubToken(rc *ResolvedConfig, cfg UserConfig, env EnvVars) { + if val, ok := os.LookupEnv(EnvGitHubToken); ok { + rc.GitHubToken = val + rc.GitHubTokenSource = "Environment variable (" + EnvGitHubToken + ")" + return + } + if val, _ := keyring.Get(keyring.GitHubTokenKey(env.GitHubAPIURL)); val != "" { + rc.GitHubToken = val + rc.GitHubTokenSource = SourceKeychain + return + } + if cfg.GitHubToken != "" { + rc.GitHubToken = cfg.GitHubToken + rc.GitHubTokenSource = SourceConfigFile + } +} + // Resolve computes the final config from flags, env, and file. // Priority for API key: flag > env > config file > (none). // Priority for model: flag > env > config file > default. @@ -203,53 +265,13 @@ func Resolve(flagAPIKey, flagModel string) (ResolvedConfig, error) { PromptModel: PromptModel, } - switch { - case env.CircleToken != "": - rc.CircleCIToken = env.CircleToken - rc.CircleCITokenSource = "Environment variable (" + EnvCircleToken + ")" - case env.CircleCIToken != "": - rc.CircleCIToken = env.CircleCIToken - rc.CircleCITokenSource = "Environment variable (" + EnvCircleCIToken + ")" - default: - if val, _ := keyring.Get(keyring.CircleCITokenKey(env.CircleCIBaseURL)); val != "" { - rc.CircleCIToken = val - rc.CircleCITokenSource = SourceKeychain - } else if cfg.CircleCIToken != "" { - rc.CircleCIToken = cfg.CircleCIToken - rc.CircleCITokenSource = SourceConfigFile - } - } - - switch { - case flagAPIKey != "": - rc.AnthropicAPIKey = flagAPIKey - rc.AnthropicAPIKeySource = "Flag" - case env.AnthropicAPIKey != "": - rc.AnthropicAPIKey = env.AnthropicAPIKey - rc.AnthropicAPIKeySource = "Environment variable" - default: - if val, _ := keyring.Get(keyring.AnthropicKeyKey(env.AnthropicBaseURL)); val != "" { - rc.AnthropicAPIKey = val - rc.AnthropicAPIKeySource = SourceKeychain - } else if cfg.AnthropicAPIKey != "" { - rc.AnthropicAPIKey = cfg.AnthropicAPIKey - rc.AnthropicAPIKeySource = SourceConfigFile - } - } - - switch { - case env.GitHubToken != "": - rc.GitHubToken = env.GitHubToken - rc.GitHubTokenSource = "Environment variable (" + EnvGitHubToken + ")" - default: - if val, _ := keyring.Get(keyring.GitHubTokenKey(env.GitHubAPIURL)); val != "" { - rc.GitHubToken = val - rc.GitHubTokenSource = SourceKeychain - } else if cfg.GitHubToken != "" { - rc.GitHubToken = cfg.GitHubToken - rc.GitHubTokenSource = SourceConfigFile - } - } + // For each credential: if the env var is explicitly present in the process + // environment, it takes priority. A non-empty value is used directly; an + // empty value suppresses the keychain check so tests that clear env vars + // still fall through to the config file without touching the keychain. + resolveCircleCIToken(&rc, cfg, env) + resolveAnthropicKey(&rc, cfg, env, flagAPIKey) + resolveGitHubToken(&rc, cfg, env) switch { case flagModel != "": @@ -266,7 +288,7 @@ func Resolve(flagAPIKey, flagModel string) (ResolvedConfig, error) { rc.ModelSource = "Default" } - rc.CircleCIBaseURL = env.CircleCIBaseURL + rc.CircleCIBaseURL = env.CircleHost rc.AnthropicBaseURL = env.AnthropicBaseURL rc.GitHubAPIURL = env.GitHubAPIURL diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 409adc80..e198ef82 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -13,6 +13,10 @@ func setupTempConfig(t *testing.T) string { t.Helper() dir := t.TempDir() t.Setenv(EnvXDGConfigHome, dir) + t.Setenv(EnvCircleToken, "dummy-circle-token") + t.Setenv(EnvCircleCIToken, "dummy-circleci-token") + t.Setenv(EnvAnthropicAPIKey, "dummy-anthropic-key") + t.Setenv(EnvGitHubToken, "dummy-github-token") return dir } diff --git a/internal/testing/env/env.go b/internal/testing/env/env.go index 0c8d2e42..bab9fdb2 100644 --- a/internal/testing/env/env.go +++ b/internal/testing/env/env.go @@ -45,15 +45,11 @@ func (e *TestEnv) Environ() []string { "TERM=dumb", } - if e.GithubToken != "" { - env = append(env, fmt.Sprintf("GITHUB_TOKEN=%s", e.GithubToken)) - } - if e.AnthropicKey != "" { - env = append(env, fmt.Sprintf("ANTHROPIC_API_KEY=%s", e.AnthropicKey)) - } - if e.CircleToken != "" { - env = append(env, fmt.Sprintf("CIRCLE_TOKEN=%s", e.CircleToken)) - } + // Always set credential env vars so that Resolve() hits the env-var path + // and never accesses the system keychain from test subprocesses. + env = append(env, fmt.Sprintf("GITHUB_TOKEN=%s", e.GithubToken)) + env = append(env, fmt.Sprintf("ANTHROPIC_API_KEY=%s", e.AnthropicKey)) + env = append(env, fmt.Sprintf("CIRCLE_TOKEN=%s", e.CircleToken)) if e.GithubURL != "" { env = append(env, fmt.Sprintf("GITHUB_API_URL=%s", e.GithubURL)) } @@ -61,7 +57,7 @@ func (e *TestEnv) Environ() []string { env = append(env, fmt.Sprintf("ANTHROPIC_BASE_URL=%s", e.AnthropicURL)) } if e.CircleCIURL != "" { - env = append(env, fmt.Sprintf("CIRCLECI_BASE_URL=%s", e.CircleCIURL)) + env = append(env, fmt.Sprintf("CIRCLE_HOST=%s", e.CircleCIURL)) } for k, v := range e.Extra { From d8d037634c05c19f0b709df131a3e0ccf667f203 Mon Sep 17 00:00:00 2001 From: webster Date: Wed, 13 May 2026 14:17:06 -0400 Subject: [PATCH 3/7] Add cross-compilation smoke test to CI Verifies all four goreleaser targets (linux/amd64, linux/arm64, darwin/amd64, darwin/arm64) build cleanly with CGO_ENABLED=0. Co-Authored-By: Claude Sonnet 4.6 --- .circleci/config.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.circleci/config.yml b/.circleci/config.yml index 06b1f01f..e66e666e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -76,6 +76,14 @@ jobs: ./dist/chunk skill install echo "OK: skill content embedded correctly" head -5 ~/.claude/skills/chunk-review/SKILL.md + - run: + name: Cross-compile for all release targets + command: | + for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64; do + GOOS=${target%/*} GOARCH=${target#*/} CGO_ENABLED=0 go build -o /dev/null . \ + && echo "OK: $target" \ + || { echo "FAIL: $target"; exit 1; } + done workflows: ci: From 5153533f3eca1a34dfc7d1c05e0c0d71847df924 Mon Sep 17 00:00:00 2001 From: webster Date: Wed, 13 May 2026 14:19:52 -0400 Subject: [PATCH 4/7] Set CGO_ENABLED=0 on release smoke test build Matches how goreleaser actually builds the binary and ensures the smoke test catches any CGO dependency introduced accidentally. Co-Authored-By: Claude Sonnet 4.6 --- .circleci/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.circleci/release.yml b/.circleci/release.yml index ca7a4c97..24e4b2be 100644 --- a/.circleci/release.yml +++ b/.circleci/release.yml @@ -22,7 +22,7 @@ jobs: - run: name: Smoke test command: | - go build -o ~/chunk . + CGO_ENABLED=0 go build -o ~/chunk . ~/chunk --version - run: task release VERSION=<< pipeline.parameters.version >> - persist_to_workspace: From 416f89ad69a7e6e9ba2c1bb79d870c82548dedc7 Mon Sep 17 00:00:00 2001 From: webster Date: Tue, 19 May 2026 16:04:19 -0400 Subject: [PATCH 5/7] Make keychain failures fatal instead of silently falling back to config When insecureStorage is false (the default), a keychain write failure now returns an error rather than transparently saving to the plaintext config file. Users on systems without a working keychain must pass --insecure-storage explicitly. Adds keyring.MockInit() so tests that exercise keychain code paths can use an in-memory backend without touching the real system keychain. Co-Authored-By: Claude Sonnet 4.6 --- internal/authprompt/authprompt.go | 21 ++++++++++++--------- internal/cmd/authhelper_test.go | 11 +++++++---- internal/keyring/keyring.go | 6 ++++++ 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/internal/authprompt/authprompt.go b/internal/authprompt/authprompt.go index 50b8945e..5108d518 100644 --- a/internal/authprompt/authprompt.go +++ b/internal/authprompt/authprompt.go @@ -116,14 +116,15 @@ func ResolveGitHubClient(rc config.ResolvedConfig, logStatus func(string)) (*git } // SaveCircleCIToken persists a CircleCI token to the system keychain, or to -// the config file when insecureStorage is true or the keychain is unavailable. +// the config file when insecureStorage is true. // baseURL is used to scope the keychain entry to the CircleCI host. // Returns true if saved to the keychain. func SaveCircleCIToken(token, baseURL string, insecureStorage bool) (bool, error) { if !insecureStorage { - if err := keyring.Set(keyring.CircleCITokenKey(baseURL), token); err == nil { - return true, nil + if err := keyring.Set(keyring.CircleCITokenKey(baseURL), token); err != nil { + return false, fmt.Errorf("save to keychain: %w", err) } + return true, nil } cfg, err := config.Load() if err != nil { @@ -137,14 +138,15 @@ func SaveCircleCIToken(token, baseURL string, insecureStorage bool) (bool, error } // SaveAnthropicKey persists an Anthropic API key to the system keychain, or to -// the config file when insecureStorage is true or the keychain is unavailable. +// the config file when insecureStorage is true. // baseURL is used to scope the keychain entry to the Anthropic host. // Returns true if saved to the keychain. func SaveAnthropicKey(key, baseURL string, insecureStorage bool) (bool, error) { if !insecureStorage { - if err := keyring.Set(keyring.AnthropicKeyKey(baseURL), key); err == nil { - return true, nil + if err := keyring.Set(keyring.AnthropicKeyKey(baseURL), key); err != nil { + return false, fmt.Errorf("save to keychain: %w", err) } + return true, nil } cfg, err := config.Load() if err != nil { @@ -158,14 +160,15 @@ func SaveAnthropicKey(key, baseURL string, insecureStorage bool) (bool, error) { } // SaveGitHubToken persists a GitHub token to the system keychain, or to the -// config file when insecureStorage is true or the keychain is unavailable. +// config file when insecureStorage is true. // apiURL is used to scope the keychain entry to the GitHub host. // Returns true if saved to the keychain. func SaveGitHubToken(token, apiURL string, insecureStorage bool) (bool, error) { if !insecureStorage { - if err := keyring.Set(keyring.GitHubTokenKey(apiURL), token); err == nil { - return true, nil + if err := keyring.Set(keyring.GitHubTokenKey(apiURL), token); err != nil { + return false, fmt.Errorf("save to keychain: %w", err) } + return true, nil } cfg, err := config.Load() if err != nil { diff --git a/internal/cmd/authhelper_test.go b/internal/cmd/authhelper_test.go index 5a784ae7..061ab50e 100644 --- a/internal/cmd/authhelper_test.go +++ b/internal/cmd/authhelper_test.go @@ -15,6 +15,7 @@ import ( "github.com/CircleCI-Public/chunk-cli/internal/config" "github.com/CircleCI-Public/chunk-cli/internal/iostream" + "github.com/CircleCI-Public/chunk-cli/internal/keyring" "github.com/CircleCI-Public/chunk-cli/internal/testing/fakes" "github.com/CircleCI-Public/chunk-cli/internal/tui" ) @@ -82,6 +83,7 @@ func TestEnsureGitHubClient_NoTTY(t *testing.T) { func TestEnsureGitHubClient_PromptAndSave(t *testing.T) { isolateConfig(t) + keyring.MockInit() gh := fakes.NewFakeGitHub() srv := httptest.NewServer(gh) @@ -97,13 +99,14 @@ func TestEnsureGitHubClient_PromptAndSave(t *testing.T) { assert.NilError(t, err) assert.Assert(t, client != nil) - cfg, err := config.Load() + stored, err := keyring.Get(keyring.GitHubTokenKey(srv.URL)) assert.NilError(t, err) - assert.Equal(t, cfg.GitHubToken, token) + assert.Equal(t, stored, token) } func TestEnsureAnthropicClient_PromptAndSave(t *testing.T) { isolateConfig(t) + keyring.MockInit() ant := fakes.NewFakeAnthropic("ok") srv := httptest.NewServer(ant) @@ -119,9 +122,9 @@ func TestEnsureAnthropicClient_PromptAndSave(t *testing.T) { assert.NilError(t, err) assert.Assert(t, client != nil) - cfg, err := config.Load() + stored, err := keyring.Get(keyring.AnthropicKeyKey(srv.URL)) assert.NilError(t, err) - assert.Equal(t, cfg.AnthropicAPIKey, key) + assert.Equal(t, stored, key) } func TestEnsureAnthropicClient_InvalidPrefix(t *testing.T) { diff --git a/internal/keyring/keyring.go b/internal/keyring/keyring.go index 1a5db700..076e46c3 100644 --- a/internal/keyring/keyring.go +++ b/internal/keyring/keyring.go @@ -41,6 +41,12 @@ func AnthropicKeyKey(baseURL string) string { return service + ":" + baseURL } +// MockInit replaces the keychain backend with an in-memory store. Call it in +// tests that exercise code paths which write to the keychain. +func MockInit() { + zkeyring.MockInit() +} + // Get retrieves a credential from the system keychain. // Returns ("", nil) if the key is not found. func Get(key string) (string, error) { From a47faa2083ea23cef17bbb7e1df47808fb3d3c86 Mon Sep 17 00:00:00 2001 From: webster Date: Tue, 19 May 2026 16:13:02 -0400 Subject: [PATCH 6/7] Warn when credentials are stored on disk instead of keychain Adds a warning in printSaved when --insecure-storage is used, and in auth status for each credential that comes from the config file, with a suggestion to re-run 'chunk auth set' to migrate to the keychain. Co-Authored-By: Claude Sonnet 4.6 --- internal/cmd/auth.go | 15 ++++++++++++--- internal/cmd/authhelper.go | 7 ++++--- internal/cmd/errmsg.go | 1 + 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index f2dbffe4..7fe328c3 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -195,7 +195,7 @@ func authSetAnthropic(ctx context.Context, io iostream.Streams, rc config.Resolv savedToKeychain, err := authprompt.SaveAnthropicKey(key, rc.AnthropicBaseURL, insecureStorage) if err != nil { - return &userError{msg: "Could not save credentials.", suggestion: configFilePermHint, err: err} + return &userError{msg: "Could not save credentials.", suggestion: keychainHint, err: err} } io.Println("") @@ -218,7 +218,7 @@ func saveCircleCIToken(ctx context.Context, token string, streams iostream.Strea if err != nil { return &userError{ msg: "Failed to save CircleCI token.", - suggestion: "Check that your config file is writable.", + suggestion: keychainHint, err: fmt.Errorf("save token: %w", err), } } @@ -253,6 +253,9 @@ func newAuthStatusCmd() *cobra.Command { } else { io.Printf(" Source: %s\n", rc.CircleCITokenSource) io.Printf(" Token: %s\n", config.MaskKey(rc.CircleCIToken)) + if rc.CircleCITokenSource == config.SourceConfigFile { + io.Println(ui.Warning(" Credential is stored on disk. Run 'chunk auth set circleci' to move it to the system keychain.")) + } io.ErrPrintln(ui.Dim("Validating CircleCI token...")) if err := authprompt.ValidateCircleCIToken(cmd.Context(), rc.CircleCIToken, rc.CircleCIBaseURL); err != nil { io.ErrPrintln(ui.FormatError( @@ -274,6 +277,9 @@ func newAuthStatusCmd() *cobra.Command { } else { io.Printf(" Source: %s\n", rc.AnthropicAPIKeySource) io.Printf(" Key: %s\n", config.MaskKey(rc.AnthropicAPIKey)) + if rc.AnthropicAPIKeySource == config.SourceConfigFile { + io.Println(ui.Warning(" Credential is stored on disk. Run 'chunk auth set anthropic' to move it to the system keychain.")) + } io.ErrPrintln(ui.Dim("Validating API key...")) if err := authprompt.ValidateAPIKey(cmd.Context(), rc.AnthropicAPIKey, rc.AnthropicBaseURL); err != nil { io.ErrPrintln(ui.FormatError( @@ -296,6 +302,9 @@ func newAuthStatusCmd() *cobra.Command { } else { io.Printf(" Source: %s\n", rc.GitHubTokenSource) io.Printf(" Token: %s\n", config.MaskKey(rc.GitHubToken)) + if rc.GitHubTokenSource == config.SourceConfigFile { + io.Println(ui.Warning(" Credential is stored on disk. Run 'chunk auth set github' to move it to the system keychain.")) + } io.ErrPrintln(ui.Dim("Validating GitHub token...")) if err := authprompt.ValidateGitHubToken(cmd.Context(), rc.GitHubToken, rc.GitHubAPIURL); err != nil { io.ErrPrintln(ui.FormatError( @@ -530,7 +539,7 @@ func authSetGitHub(ctx context.Context, io iostream.Streams, rc config.ResolvedC savedToKeychain, err := authprompt.SaveGitHubToken(token, rc.GitHubAPIURL, insecureStorage) if err != nil { - return &userError{msg: "Could not save credentials.", suggestion: configFilePermHint, err: err} + return &userError{msg: "Could not save credentials.", suggestion: keychainHint, err: err} } io.Println("") diff --git a/internal/cmd/authhelper.go b/internal/cmd/authhelper.go index c8e25964..79dceee5 100644 --- a/internal/cmd/authhelper.go +++ b/internal/cmd/authhelper.go @@ -44,6 +44,7 @@ func printSaved(streams iostream.Streams, label string, savedToKeychain bool) { msg = fmt.Sprintf("%s saved to user config (%s)", label, cfgPath) } streams.ErrPrintln(ui.Success(msg)) + streams.ErrPrintln(ui.Warning("Credential is stored on disk. Run 'chunk auth set' to move it to the system keychain.")) } func ensureCircleCIClient(ctx context.Context, streams iostream.Streams, prompter func(string) (string, error)) (*circleci.Client, error) { @@ -92,7 +93,7 @@ func ensureCircleCIClient(ctx context.Context, streams iostream.Streams, prompte savedToKeychain, err := authprompt.SaveCircleCIToken(token, rc.CircleCIBaseURL, false) if err != nil { - return nil, err + return nil, newUserError("Could not save CircleCI token.").withSuggestion(keychainHint).wrap(err) } printSaved(streams, "CircleCI token", savedToKeychain) return circleci.NewClient(circleci.Config{ @@ -155,7 +156,7 @@ func ensureAnthropicClient(ctx context.Context, streams iostream.Streams, prompt savedToKeychain, err := authprompt.SaveAnthropicKey(key, rc.AnthropicBaseURL, false) if err != nil { - return nil, err + return nil, newUserError("Could not save Anthropic API key.").withSuggestion(keychainHint).wrap(err) } printSaved(streams, "Anthropic API key", savedToKeychain) return anthropic.New(anthropic.Config{ @@ -211,7 +212,7 @@ func ensureGitHubClient(ctx context.Context, streams iostream.Streams, prompter savedToKeychain, err := authprompt.SaveGitHubToken(token, rc.GitHubAPIURL, false) if err != nil { - return nil, err + return nil, newUserError("Could not save GitHub token.").withSuggestion(keychainHint).wrap(err) } printSaved(streams, "GitHub token", savedToKeychain) return github.New(github.Config{ diff --git a/internal/cmd/errmsg.go b/internal/cmd/errmsg.go index 52331db2..2a5b1756 100644 --- a/internal/cmd/errmsg.go +++ b/internal/cmd/errmsg.go @@ -2,6 +2,7 @@ package cmd const ( configFilePermHint = "Check file permissions on the chunk config file." + keychainHint = "Keychain unavailable. Re-run with --insecure-storage to save credentials to the config file instead." msgCouldNotLoadConfig = "Could not load configuration." msgCouldNotAccessConfig = "Could not access configuration." msgCouldNotDetermineWorkDir = "Could not determine working directory." From 234c0a384d71ebb8d2b4f8ef48be2df8c1966cfd Mon Sep 17 00:00:00 2001 From: webster Date: Tue, 19 May 2026 18:16:34 -0400 Subject: [PATCH 7/7] Address review findings: fix remove logic, resolve early-exit bug, hint wording - auth remove now checks both keychain and config file for stored credentials instead of only the config file, so keychain-stored tokens are actually cleared - Resolve() returns early if config.Load() fails rather than proceeding with a zero-value UserConfig and making unnecessary keychain calls - printSaved warning now says 'chunk auth set ' (runnable command) - TestAuthRemoveWithStoredKey verifies config file via os.ReadFile instead of config.Load() to avoid coupling acceptance test to internal resolution logic Co-Authored-By: Claude Sonnet 4.6 --- acceptance/auth_test.go | 13 +++-- internal/cmd/auth.go | 97 +++++++++++++++++++++++++------------- internal/cmd/authhelper.go | 2 +- internal/config/config.go | 5 +- 4 files changed, 77 insertions(+), 40 deletions(-) diff --git a/acceptance/auth_test.go b/acceptance/auth_test.go index b60ffbe7..dc8ac9f7 100644 --- a/acceptance/auth_test.go +++ b/acceptance/auth_test.go @@ -3,6 +3,7 @@ package acceptance import ( "net/http" "net/http/httptest" + "os" "path/filepath" "strings" "testing" @@ -264,11 +265,13 @@ func TestAuthRemoveWithStoredKey(t *testing.T) { "expected removal prompt or --force suggestion, got: %s", combined) assert.Assert(t, strings.Contains(combined, env.HomeDir), "expected config path in output, got: %s", combined) - // Key should not have been removed — load config directly to avoid env-var override in Resolve. - cfg, err := config.Load() - assert.NilError(t, err, "config.Load failed after cancelled remove") - assert.Assert(t, strings.Contains(cfg.AnthropicAPIKey, "stored-key-1234"), - "expected stored key to be intact after cancelled remove, got: %q", cfg.AnthropicAPIKey) + // Key should not have been removed — read config file directly to verify. + cfgPath, err := config.Path() + assert.NilError(t, err, "config.Path failed") + data, err := os.ReadFile(cfgPath) + assert.NilError(t, err, "config file read failed after cancelled remove") + assert.Assert(t, strings.Contains(string(data), "stored-key-1234"), + "expected stored key to be intact after cancelled remove, got: %s", string(data)) } // auth remove with both env var and config key. diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index 7fe328c3..6261c244 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -11,6 +11,7 @@ import ( "github.com/CircleCI-Public/chunk-cli/internal/authprompt" "github.com/CircleCI-Public/chunk-cli/internal/config" "github.com/CircleCI-Public/chunk-cli/internal/iostream" + "github.com/CircleCI-Public/chunk-cli/internal/keyring" "github.com/CircleCI-Public/chunk-cli/internal/tui" "github.com/CircleCI-Public/chunk-cli/internal/ui" ) @@ -340,14 +341,11 @@ func newAuthRemoveCmd() *cobra.Command { io := iostream.FromCmd(cmd) switch provider { case providerCircleCI: - envSet := strings.HasPrefix(rc.CircleCITokenSource, "Environment") - return authRemoveCircleCI(io, envSet, force) + return authRemoveCircleCI(io, rc, force) case providerAnthropic: - envSet := strings.HasPrefix(rc.AnthropicAPIKeySource, "Environment") - return authRemoveAnthropic(io, envSet, force) + return authRemoveAnthropic(io, rc, force) case providerGitHub: - envSet := strings.HasPrefix(rc.GitHubTokenSource, "Environment") - return authRemoveGitHub(io, envSet, force) + return authRemoveGitHub(io, rc, force) default: return &userError{ msg: fmt.Sprintf("Unknown provider %q.", provider), @@ -361,13 +359,17 @@ func newAuthRemoveCmd() *cobra.Command { return cmd } -func authRemoveCircleCI(io iostream.Streams, envSet, force bool) error { +func authRemoveCircleCI(io iostream.Streams, rc config.ResolvedConfig, force bool) error { + envSet := strings.HasPrefix(rc.CircleCITokenSource, "Environment") + cfg, err := config.Load() if err != nil { return &userError{msg: msgCouldNotLoadConfig, suggestion: configFilePermHint, err: err} } - if cfg.CircleCIToken == "" { - io.Println(ui.Warning("No CircleCI token stored in config file.")) + keychainToken, _ := keyring.Get(keyring.CircleCITokenKey(rc.CircleCIBaseURL)) + + if cfg.CircleCIToken == "" && keychainToken == "" { + io.Println(ui.Warning("No CircleCI token stored in keychain or config.")) if envSet { io.Println("Note: A CircleCI token is set in environment variables.") io.Println("To remove it, unset the environment variable.") @@ -376,12 +378,19 @@ func authRemoveCircleCI(io iostream.Streams, envSet, force bool) error { return nil } - io.Println("") - cfgPath, err := config.Path() - if err != nil { - return &userError{msg: msgCouldNotAccessConfig, err: err} + var location string + if keychainToken != "" { + location = "system keychain" + } else { + cfgPath, err := config.Path() + if err != nil { + return &userError{msg: msgCouldNotAccessConfig, err: err} + } + location = cfgPath } - io.Printf("This will remove your stored CircleCI token from %s\n", cfgPath) + + io.Println("") + io.Printf("This will remove your stored CircleCI token from %s\n", location) if !force { if nonInteractive() { return errNoForce("remove CircleCI token") @@ -405,7 +414,7 @@ func authRemoveCircleCI(io iostream.Streams, envSet, force bool) error { } return &userError{ msg: "Failed to remove CircleCI token.", - detail: "An error occurred while trying to remove the token from the config file.", + detail: "An error occurred while trying to remove the token.", suggestion: hint, err: err, } @@ -418,13 +427,17 @@ func authRemoveCircleCI(io iostream.Streams, envSet, force bool) error { return nil } -func authRemoveAnthropic(io iostream.Streams, envSet, force bool) error { +func authRemoveAnthropic(io iostream.Streams, rc config.ResolvedConfig, force bool) error { + envSet := strings.HasPrefix(rc.AnthropicAPIKeySource, "Environment") + cfg, err := config.Load() if err != nil { return &userError{msg: msgCouldNotLoadConfig, suggestion: configFilePermHint, err: err} } - if cfg.AnthropicAPIKey == "" { - io.Println(ui.Warning("No API key stored in config file.")) + keychainKey, _ := keyring.Get(keyring.AnthropicKeyKey(rc.AnthropicBaseURL)) + + if cfg.AnthropicAPIKey == "" && keychainKey == "" { + io.Println(ui.Warning("No API key stored in keychain or config.")) if envSet { io.Println("Note: " + config.EnvAnthropicAPIKey + " is set in your environment variables.") io.Println("To remove it, unset the environment variable.") @@ -433,12 +446,19 @@ func authRemoveAnthropic(io iostream.Streams, envSet, force bool) error { return nil } - io.Println("") - cfgPath, err := config.Path() - if err != nil { - return &userError{msg: msgCouldNotAccessConfig, err: err} + var location string + if keychainKey != "" { + location = "system keychain" + } else { + cfgPath, err := config.Path() + if err != nil { + return &userError{msg: msgCouldNotAccessConfig, err: err} + } + location = cfgPath } - io.Printf("This will remove your stored API key from %s\n", cfgPath) + + io.Println("") + io.Printf("This will remove your stored API key from %s\n", location) if !force { if nonInteractive() { return errNoForce("remove Anthropic API key") @@ -462,7 +482,7 @@ func authRemoveAnthropic(io iostream.Streams, envSet, force bool) error { } return &userError{ msg: "Failed to remove API key.", - detail: "An error occurred while trying to remove the API key from the config file.", + detail: "An error occurred while trying to remove the API key.", suggestion: hint, err: err, } @@ -547,13 +567,17 @@ func authSetGitHub(ctx context.Context, io iostream.Streams, rc config.ResolvedC return nil } -func authRemoveGitHub(io iostream.Streams, envSet, force bool) error { +func authRemoveGitHub(io iostream.Streams, rc config.ResolvedConfig, force bool) error { + envSet := strings.HasPrefix(rc.GitHubTokenSource, "Environment") + cfg, err := config.Load() if err != nil { return &userError{msg: msgCouldNotLoadConfig, suggestion: configFilePermHint, err: err} } - if cfg.GitHubToken == "" { - io.Println(ui.Warning("No GitHub token stored in config file.")) + keychainToken, _ := keyring.Get(keyring.GitHubTokenKey(rc.GitHubAPIURL)) + + if cfg.GitHubToken == "" && keychainToken == "" { + io.Println(ui.Warning("No GitHub token stored in keychain or config.")) if envSet { io.Println("Note: A GitHub token is set in environment variables.") io.Println("To remove it, unset the environment variable.") @@ -562,12 +586,19 @@ func authRemoveGitHub(io iostream.Streams, envSet, force bool) error { return nil } - io.Println("") - cfgPath, err := config.Path() - if err != nil { - return &userError{msg: msgCouldNotAccessConfig, err: err} + var location string + if keychainToken != "" { + location = "system keychain" + } else { + cfgPath, err := config.Path() + if err != nil { + return &userError{msg: msgCouldNotAccessConfig, err: err} + } + location = cfgPath } - io.Printf("This will remove your stored GitHub token from %s\n", cfgPath) + + io.Println("") + io.Printf("This will remove your stored GitHub token from %s\n", location) if !force { if nonInteractive() { return errNoForce("remove GitHub token") @@ -591,7 +622,7 @@ func authRemoveGitHub(io iostream.Streams, envSet, force bool) error { } return &userError{ msg: "Failed to remove GitHub token.", - detail: "An error occurred while trying to remove the token from the config file.", + detail: "An error occurred while trying to remove the token.", suggestion: hint, err: err, } diff --git a/internal/cmd/authhelper.go b/internal/cmd/authhelper.go index 79dceee5..6898787b 100644 --- a/internal/cmd/authhelper.go +++ b/internal/cmd/authhelper.go @@ -44,7 +44,7 @@ func printSaved(streams iostream.Streams, label string, savedToKeychain bool) { msg = fmt.Sprintf("%s saved to user config (%s)", label, cfgPath) } streams.ErrPrintln(ui.Success(msg)) - streams.ErrPrintln(ui.Warning("Credential is stored on disk. Run 'chunk auth set' to move it to the system keychain.")) + streams.ErrPrintln(ui.Warning("Credential is stored on disk. Run 'chunk auth set ' to move it to the system keychain.")) } func ensureCircleCIClient(ctx context.Context, streams iostream.Streams, prompter func(string) (string, error)) (*circleci.Client, error) { diff --git a/internal/config/config.go b/internal/config/config.go index dd8dc765..fa595389 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -267,6 +267,9 @@ func resolveGitHubToken(rc *ResolvedConfig, cfg UserConfig, env EnvVars) { // Priority for model: flag > env > config file > default. func Resolve(flagAPIKey, flagModel string) (ResolvedConfig, error) { cfg, err := Load() + if err != nil { + return ResolvedConfig{}, fmt.Errorf("load config: %w", err) + } env, envErr := LoadEnv(context.Background()) if envErr != nil { @@ -305,7 +308,7 @@ func Resolve(flagAPIKey, flagModel string) (ResolvedConfig, error) { rc.AnthropicBaseURL = env.AnthropicBaseURL rc.GitHubAPIURL = env.GitHubAPIURL - return rc, err + return rc, nil } // MaskKey masks all but the last 4 characters with *.