Compare commits

...
Author SHA1 Message Date
Kevin Codex e992526198 Fix provider manager test formatting 2026-07-07 10:26:34 +08:00
Kevin Codex 620f1af884 fix(providers): resolve self-review findings — atomic edits, key normalization, threading
Findings from a multi-agent code review of this branch (8 finder angles,
independently verified); all confirmed against the code, several with
empirical repros.

Correctness:
- config.EditProvider: one atomic read-modify-write for the manager's Save
  (rename + fields + verbatim description + stored-key marker + key
  migration with write-failure rollback). Replaces the rename→upsert→
  describe sequence that (a) turned a case-only rename (groq → Groq) into a
  DUPLICATE profile via UpsertProvider's case-sensitive merge, and (b) could
  fail halfway, leaving config.json renamed while savedProviders and
  ZERO_PROVIDER still held the old name (retry then failed 'not found').
  A rename now also migrates a freshly captured replacement key (captured
  under the current name; the migration moves it).
- savedProviders is mutated surgically on manager delete/edit instead of
  being replaced with the raw user-config list — the session list is seeded
  from the RESOLVED (project-config layered) and usability-FILTERED set, so
  wholesale replacement dropped project-contributed providers and
  resurrected filtered stubs for the rest of the session.
- oauth.ProviderKey normalizes provider names to lower case at the single
  write/lookup choke point: `zero auth login xAI` stored provider:xAI while
  every candidate lookup was case-sensitive — a successful login invisible
  to the runtime. Map-based presence checks lowercase to match.
- OAuth login persistence moved onto the Update goroutine (message handlers)
  — a background tea.Cmd (device-code polls land minutes later) doing an
  unlocked config read-modify-write raced UI-thread writes (lost updates).
  The write error is now surfaced and the in-memory mirror is skipped on
  failure, so a profile that never reached disk can't show as a working
  provider that vanishes on restart.

UX / consistency:
- Esc inside a manager-launched add flow walks back to the provider list
  from EVERY step (was: first step only; deeper steps destroyed the overlay
  and the user's place). In-flight OAuth logins are invalidated on the way
  out; footers say 'Esc back'.
- switchProviderModel returns a structured outcome; the manager branches on
  it instead of strings.Contains(text, "Switched to") — UI copy is no longer
  a control-flow contract (a provider named 'Switched to prod' could close
  the manager on a refusal).
- Manager delete's keychain subprocess + OAuth lookup run in a follow-up
  tea.Cmd (the confirm keypress no longer blocks the render loop); the
  credential probe takes ONE token-store snapshot instead of 2 reads per
  row, and resolved states carry over across reloads (no 'checking…' flash).
- Credential-state reporting converges: /provider status recognizes OAuth
  logins (was 'api key: not set' + warning for a WORKING provider), doctor
  uses the shared HasConfiguredCredential definition plus OAuth awareness
  (its 'matching ProviderSnapshot.APIKeySet' claim is true again), and the
  manager's classifier falls through a stale stored-key marker to env/OAuth
  the way the runtime does.
2026-07-07 09:46:14 +08:00
Kevin Codex 3a02281fd4 fix(tui): read stored keys from the active config's credential store
The TUI's write paths (SecureProviderProfile capture, rename migration,
manager delete) all target the store beside m.userConfigPath, but the read
paths still opened the DEFAULT-path store: profileWithCredential (feeding
the /model and manager switch gate), the model picker's discovery
credential lookup, and the manager's async credential-status probe. With a
non-default config path, a provider saved with only apiKeyStored had its
key in the config-adjacent store, so the gate rejected it as having no
usable credential and the manager showed 'stored key missing' for a
healthy profile.

All TUI stored-key reads now resolve through providerKeyStoreForPath
(config-adjacent, default-path fallback for ephemeral sessions), so the UI
reads from the same store it writes to. Regression test covers both the
credential probe and the switch path with a key seeded only beside the
config.

Also consolidates the OAuth test-store seeding on withAuthStore (now pins
ZERO_OAUTH_STORAGE=file so an inherited keyring backend can't bypass the
temp path) per review nitpick.
2026-07-07 08:31:42 +08:00
Kevin Codex 8705e789b3 fix(providers): second review round — case-only rename, logout hint, test hermeticity
- Skip the credential-store migration on case-only renames (groq -> Groq):
  the store normalizes names case-insensitively, so Set(new)+Delete(old)
  target ONE entry and the delete removed the key that was just "moved".
  The existing entry already serves the new name. [CodeRabbit]
- Point the delete flow's cleanup hint at the stored login that actually
  exists: after a rename ({name:"codex", catalogID:"chatgpt"}) the token
  lives under the catalog id, and `zero auth logout codex` would delete
  nothing. oauthLoginName resolves the same FirstStored candidate the
  runtime uses. [jatmn P3]
- Lock in APIKeyEnv semantics after a key edit: the env reference survives
  as an explicit override (a SET env var wins at resolve time; the stored
  key applies when it is absent). [CodeRabbit nitpick]
- Make main's new firstUsableProvider tests hermetic: they read the REAL
  OAuth token store, so any developer with an actual xai/chatgpt login gets
  spurious failures (found because this machine has one).

Also rebased onto main; the only conflict was comment wording in
firstUsableProvider, where the audit commit (#481) independently landed the
same OAuth-login fallback check this branch added.
2026-07-07 07:59:03 +08:00
Kevin Codex a1d4e57a94 fix(providers): address PR #560 review findings
All six review findings validated as real; fixes with regression tests:

- Persist the apiKeyStored marker when a key is captured onto an existing
  profile: mergeProfile deliberately ignores the marker (a project config
  must not claim the user's stored keys at resolve time), so the user-config
  WRITE path (UpsertProvider) now re-applies it — previously editing a key
  onto an env/no-key profile stranded the secret in the credential store
  while every ApplyStoredAPIKey gate skipped it. [jatmn P2]
- Let active-pointer recovery select OAuth-login providers:
  firstUsableProvider now applies the same providerHasOAuthLogin check as
  setupRequired/usableSavedProviders, so a stale activeProvider plus a saved
  ChatGPT/xAI login recovers instead of forcing onboarding. [jatmn P2]
- Delete stored keys from the store BESIDE the config being edited:
  providers remove and the TUI manager delete now use ProviderKeyStoreAt
  (matching setup/rename) instead of the default-path store. [jatmn P2]
- Roll back the key migration when RenameProvider's config write fails, so
  the store and config.json can't desync (key under the new name, config
  still naming the old one). [CodeRabbit]
- Clear provider descriptions for real: new SetProviderDescription writes
  the field verbatim (the upsert merge treats empty as unchanged); the
  manager's editor routes description changes through it. [jatmn P3]
- Surface stored-key deletion failures in providers remove (stderr warning
  + keyError in the JSON payload) and the TUI delete status, instead of
  letting a lingering secret read as a clean removal. [CodeRabbit]
2026-07-07 07:50:01 +08:00
Kevin Codex c12fb463eb feat(providers): OAuth login profiles and list-first /provider manager
Make multiple providers a first-class workflow: every OAuth login is now a
visible, switchable provider, and /provider manages the whole lifecycle.

OAuth logins become provider profiles:
- zero auth chatgpt / zero auth login <preset> and all four TUI login paths
  (wizard + first-run, browser + device-code) now upsert a catalog-backed
  profile via config.EnsureCatalogProvider after storing the token — without
  stealing the active provider. Previously the token was stored invisibly:
  the provider showed up nowhere and 'providers use' failed not-found.
- Switching to an OAuth provider no longer inlines the bearer as an API key,
  which suppressed the runtime resolver (no token refresh, no Codex
  chatgpt-account-id header → guaranteed 401s). The credential gate now
  recognizes stored OAuth logins on keyless profiles.
- providers list / current report truthful credential state: stored keys
  count as configured (APIKeySet honors apiKeyStored) and token logins show
  'oauth login'; providers check accepts an OAuth login as a credential.

List-first /provider manager (TUI):
- /provider opens the saved-provider list: Enter activates via the shared
  switch path, 'a' adds (existing wizard), 'e' opens a field-level editor
  (endpoint, model, key, description, and safe rename — the stored key,
  activeProvider, and live ZERO_PROVIDER follow the new name), 'd' deletes
  with an inline confirm and stored-key cleanup. Esc walks back one level.
- Credential states resolve asynchronously so keychain reads never block
  the render loop; 'stored key missing' surfaces broken keychain entries.
- Implemented as manager steps on providerWizardState so every existing
  overlay gate, key route, and mouse hook covers it unchanged.

CLI + config parity:
- zero providers remove <name> (deletes the stored key, hands the active
  pointer off) and zero providers rename <old> <new>.
- config.RemoveProvider / RenameProvider: read-modify-atomic-write; rename
  migrates the credential-store entry write-new-then-delete-old so an
  interruption can never lose the key.
2026-07-07 07:48:31 +08:00
28 changed files with 3317 additions and 63 deletions

No files matched your search

+45 -2
View File
@@ -11,10 +11,46 @@ import (
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/provideroauth"
"github.com/Gitlawb/zero/internal/redaction"
)
// ensureLoginProviderProfile makes a freshly stored OAuth login visible as a
// provider: without a profile in config.json the login shows up nowhere — not in
// `zero providers list`, not in the TUI picker — and `zero providers use <name>`
// fails "not found", so the user's working active provider looks broken while
// the new login looks lost. Returns user-facing guidance lines ("" when the
// login has no catalog entry to scaffold from). Best-effort by design: the
// token is already stored, so a profile-write failure degrades to a warning
// line instead of failing a login that succeeded.
func ensureLoginProviderProfile(deps appDeps, provider string) string {
if _, ok := providercatalog.Get(provider); !ok {
// Custom OAuth server without a catalog entry — no endpoint/model defaults
// to scaffold a profile from; the user wires their own profile.
return ""
}
configPath, err := deps.userConfigPath()
if err != nil {
return "warning: login saved, but no provider profile was written: " + err.Error()
}
ensured, err := config.EnsureCatalogProvider(configPath, provider)
if err != nil {
return "warning: login saved, but no provider profile was written: " + err.Error()
}
active := strings.EqualFold(strings.TrimSpace(ensured.Active), strings.TrimSpace(ensured.Name))
switch {
case ensured.Created && active:
return fmt.Sprintf("Added provider %q to your config and set it active.", ensured.Name)
case ensured.Created:
return fmt.Sprintf("Added provider %q to your config; the active provider is still %q.\nSwitch with: zero providers use %s", ensured.Name, ensured.Active, ensured.Name)
case active:
return fmt.Sprintf("Provider %q is configured and active.", ensured.Name)
default:
return fmt.Sprintf("Provider %q is already configured.\nSwitch with: zero providers use %s", ensured.Name, ensured.Name)
}
}
// runAuth dispatches `zero auth <command>` for provider OAuth login. It is
// additive and independent of `zero mcp oauth` (MCP server auth), which is
// unchanged.
@@ -138,8 +174,10 @@ func runAuthChatGPT(args []string, stdout io.Writer, stderr io.Writer, deps appD
if _, err := fmt.Fprint(stdout, statuses); err != nil {
return exitCrash
}
if _, err := fmt.Fprint(stdout, "\nUse it with zero, e.g.:\n zero --provider chatgpt --model gpt-5.5\n"); err != nil {
return exitCrash
if line := ensureLoginProviderProfile(deps, "chatgpt"); line != "" {
if _, err := fmt.Fprintf(stdout, "\n%s\n", line); err != nil {
return exitCrash
}
}
return exitSuccess
}
@@ -331,6 +369,11 @@ func runAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps appDep
if _, err := fmt.Fprintf(stdout, "Logged in to %s.\n%s\n", parsed.positional[0], oauth.FormatStatuses([]oauth.Status{status})); err != nil {
return exitCrash
}
if line := ensureLoginProviderProfile(deps, provider); line != "" {
if _, err := fmt.Fprintln(stdout, line); err != nil {
return exitCrash
}
}
return exitSuccess
}
+85 -1
View File
@@ -2,18 +2,24 @@ package cli
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
)
// withAuthStore points the provider OAuth store at a temp file for the test.
// withAuthStore points the provider OAuth store at a temp file for the test,
// pinning the file backend so an inherited ZERO_OAUTH_STORAGE=keyring can't
// ignore the temp path and hit the OS keychain.
func withAuthStore(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "oauth-tokens.json")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
t.Setenv("ZERO_OAUTH_STORAGE", "file")
return path
}
@@ -197,3 +203,81 @@ func TestRunAuthLoginChatGPTRejectsScope(t *testing.T) {
t.Fatalf("stderr = %q, want the ChatGPT-specific --scope rejection", stderr.String())
}
}
func TestEnsureLoginProviderProfileAddsProviderWithoutStealingActive(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
seed := `{"activeProvider":"opengateway","providers":[{"name":"opengateway","provider_kind":"openai-compatible","baseURL":"https://gateway.example.com/v1","apiKeyStored":true,"model":"some-model"}]}`
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
t.Fatalf("seed config: %v", err)
}
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
line := ensureLoginProviderProfile(deps, "chatgpt")
if !strings.Contains(line, `Added provider "chatgpt"`) {
t.Fatalf("expected added-provider guidance, got %q", line)
}
if !strings.Contains(line, "zero providers use chatgpt") {
t.Fatalf("expected switch hint, got %q", line)
}
cfg := readCLIConfigFixture(t, configPath)
if cfg.ActiveProvider != "opengateway" {
t.Fatalf("active provider changed to %q", cfg.ActiveProvider)
}
names := make([]string, 0, len(cfg.Providers))
for _, provider := range cfg.Providers {
names = append(names, provider.Name)
}
if len(cfg.Providers) != 2 || cfg.Providers[1].CatalogID != "chatgpt" {
t.Fatalf("expected chatgpt profile appended, got %v", names)
}
// A second login must be a no-op with switch guidance, not a duplicate.
line = ensureLoginProviderProfile(deps, "chatgpt")
if !strings.Contains(line, "already configured") {
t.Fatalf("expected already-configured guidance, got %q", line)
}
cfg = readCLIConfigFixture(t, configPath)
if len(cfg.Providers) != 2 {
t.Fatalf("repeat login duplicated the profile: %d providers", len(cfg.Providers))
}
}
func TestEnsureLoginProviderProfileActivatesOnFreshConfig(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
line := ensureLoginProviderProfile(deps, "chatgpt")
if !strings.Contains(line, "set it active") {
t.Fatalf("fresh config should adopt the login as active, got %q", line)
}
cfg := readCLIConfigFixture(t, configPath)
if cfg.ActiveProvider != "chatgpt" {
t.Fatalf("active provider = %q, want chatgpt", cfg.ActiveProvider)
}
}
func TestEnsureLoginProviderProfileSkipsNonCatalogProviders(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
if line := ensureLoginProviderProfile(deps, "my-custom-oauth-server"); line != "" {
t.Fatalf("custom OAuth server must not scaffold a profile, got %q", line)
}
if _, err := os.Stat(configPath); err == nil {
t.Fatalf("config must not be created for a non-catalog login")
}
}
func readCLIConfigFixture(t *testing.T, path string) config.FileConfig {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config: %v", err)
}
var cfg config.FileConfig
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("decode config: %v", err)
}
return cfg
}
+33 -2
View File
@@ -75,6 +75,12 @@ func runProviders(args []string, stdout io.Writer, stderr io.Writer, deps appDep
if command == "use" {
return runProvidersUse(args, stdout, stderr, deps)
}
if command == "remove" || command == "rm" {
return runProvidersRemove(args, stdout, stderr, deps)
}
if command == "rename" {
return runProvidersRename(args, stdout, stderr, deps)
}
if command == "setup" {
return runProvidersSetup(args, stdout, stderr, deps)
}
@@ -246,6 +252,20 @@ func parseCommandCenterArgs(args []string, allowModelFilters bool, allowProvider
func summarizeConfig(resolved config.ResolvedConfig) configSummary {
summary := zerocommands.ConfigSnapshotFromResolved(resolved)
// Mark keyless profiles that authenticate via a stored OAuth login (e.g.
// ChatGPT) so the list shows "oauth login" instead of "api key: not set" —
// the same candidate matching the runtime resolver uses.
logins := oauthLoggedInProviders()
if len(logins) > 0 {
for index := range summary.Providers {
for _, profile := range resolved.Providers {
if profile.Name == summary.Providers[index].Name {
summary.Providers[index].OAuthLogin = providerHasOAuthLogin(profile, logins)
break
}
}
}
}
sort.SliceStable(summary.Providers, func(i int, j int) bool {
if summary.Providers[i].Active != summary.Providers[j].Active {
return summary.Providers[i].Active
@@ -323,7 +343,7 @@ func formatProviderSummaries(command string, providers []providerSummary) string
"model: "+displayCLIValue(provider.Model, "none"),
"api model: "+displayCLIValue(provider.APIModel, "unknown"),
"base url: "+displayCLIValue(provider.BaseURL, "default"),
"api key: "+apiKeyState(provider.APIKeySet),
"api key: "+providerCredentialState(provider),
)
if provider.Message != "" {
lines = append(lines, "status: "+provider.Status+" - "+provider.Message)
@@ -340,7 +360,7 @@ func formatProviderLine(provider providerSummary) string {
if provider.Active {
marker = "*"
}
line := fmt.Sprintf("%s %s [%s] model=%s apiModel=%s api key: %s", marker, displayCLIValue(provider.Name, "none"), displayCLIValue(provider.ProviderKind, "unknown"), displayCLIValue(provider.Model, "none"), displayCLIValue(provider.APIModel, "unknown"), apiKeyState(provider.APIKeySet))
line := fmt.Sprintf("%s %s [%s] model=%s apiModel=%s api key: %s", marker, displayCLIValue(provider.Name, "none"), displayCLIValue(provider.ProviderKind, "unknown"), displayCLIValue(provider.Model, "none"), displayCLIValue(provider.APIModel, "unknown"), providerCredentialState(provider))
if provider.Message != "" {
line += " (" + provider.Status + ": " + provider.Message + ")"
}
@@ -413,6 +433,15 @@ func apiKeyState(set bool) string {
return "not set"
}
// providerCredentialState renders the credential column: a configured key wins,
// then a stored OAuth login, then "not set".
func providerCredentialState(provider providerSummary) string {
if !provider.APIKeySet && provider.OAuthLogin {
return "oauth login"
}
return apiKeyState(provider.APIKeySet)
}
func displayCLIValue(value string, fallback string) string {
if strings.TrimSpace(value) == "" {
return fallback
@@ -466,6 +495,8 @@ func writeProvidersHelp(w io.Writer) error {
zero providers add <catalog-id> [flags]
zero providers check [name] [flags]
zero providers use <name> [flags]
zero providers remove <name> [flags]
zero providers rename <old> <new> [flags]
zero providers setup <catalog-id> [flags]
zero providers detect [flags]
zero providers models [name] [flags]
+55
View File
@@ -11,6 +11,7 @@ import (
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/zerocommands"
"github.com/Gitlawb/zero/internal/zeroruntime"
@@ -835,3 +836,57 @@ func (commandCenterProvider) StreamCompletion(context.Context, zeroruntime.Compl
close(ch)
return ch, nil
}
func TestProviderCredentialStateShowsOAuthLogin(t *testing.T) {
if got := providerCredentialState(providerSummary{APIKeySet: true}); got != "set" {
t.Fatalf("keyed provider = %q, want set", got)
}
if got := providerCredentialState(providerSummary{OAuthLogin: true}); got != "oauth login" {
t.Fatalf("token-login provider = %q, want oauth login", got)
}
if got := providerCredentialState(providerSummary{}); got != "not set" {
t.Fatalf("credential-less provider = %q, want not set", got)
}
}
// TestProvidersListMarksOAuthLoginProviders: a keyless profile whose credential
// is a stored OAuth login (the shape `zero auth chatgpt` now writes) must render
// as "oauth login", not as a broken "api key: not set" entry.
func TestProvidersListMarksOAuthLoginProviders(t *testing.T) {
tokensPath := filepath.Join(t.TempDir(), "oauth-tokens.json")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokensPath)
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
t.Fatalf("oauth store: %v", err)
}
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "bearer-123"}); err != nil {
t.Fatalf("save token: %v", err)
}
resolved := config.ResolvedConfig{
ActiveProvider: "opengateway",
Providers: []config.ProviderProfile{
{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", APIKeyStored: true, Model: "some-model"},
{Name: "chatgpt", CatalogID: "chatgpt", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://chatgpt.com/backend-api/codex", Model: "gpt-5.5"},
},
}
summary := summarizeConfig(resolved)
byName := map[string]providerSummary{}
for _, provider := range summary.Providers {
byName[provider.Name] = provider
}
if !byName["opengateway"].APIKeySet {
t.Fatalf("stored-key provider must report a credential: %+v", byName["opengateway"])
}
if byName["opengateway"].OAuthLogin {
t.Fatalf("key-authed provider must not claim the OAuth login: %+v", byName["opengateway"])
}
if !byName["chatgpt"].OAuthLogin || byName["chatgpt"].APIKeySet {
t.Fatalf("chatgpt must be marked oauth-login and keyless: %+v", byName["chatgpt"])
}
rendered := formatProviderSummaries("list", summary.Providers)
if !strings.Contains(rendered, "oauth login") {
t.Fatalf("list should render the oauth login state, got:\n%s", rendered)
}
}
+141
View File
@@ -3,6 +3,7 @@ package cli
import (
"fmt"
"io"
"path/filepath"
"strconv"
"strings"
"unicode"
@@ -306,3 +307,143 @@ func providerCommandArg(value string) string {
}
return value
}
type providerNamesOptions struct {
names []string
json bool
}
func parseProviderNamesArgs(args []string, want int, usage string) (providerNamesOptions, bool, error) {
options := providerNamesOptions{}
for _, arg := range args {
switch {
case arg == "-h" || arg == "--help" || arg == "help":
return options, true, nil
case arg == "--json":
options.json = true
case strings.HasPrefix(arg, "-"):
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
default:
options.names = append(options.names, arg)
}
}
if len(options.names) != want {
return options, false, execUsageError{usage}
}
return options, false, nil
}
// runProvidersRemove deletes a saved provider profile and its stored API key.
// The OAuth token (if any) is kept — logins outlive profiles so re-adding the
// provider needs no new browser round-trip; `zero auth logout <name>` removes it.
func runProvidersRemove(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseProviderNamesArgs(args, 1, "usage: zero providers remove <name>")
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeProvidersHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
configPath, err := deps.userConfigPath()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
name := options.names[0]
cfg, err := config.RemoveProvider(configPath, name)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
// Delete the key from the store BESIDE the config being edited — the same
// store setup/rename write to — not the default-path store, so a
// non-default config path cannot leave the encrypted key behind.
keyRemoved, keyErr := removeStoredProviderKeyAt(configPath, name)
if options.json {
payload := map[string]any{
"removed": name,
"keyRemoved": keyRemoved,
"activeProvider": cfg.ActiveProvider,
"configPath": configPath,
}
if keyErr != nil {
// A lingering secret must not read as a clean removal.
payload["keyError"] = keyErr.Error()
}
if err := writePrettyJSON(stdout, payload); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintf(stdout, "Removed provider %s\n", name); err != nil {
return exitCrash
}
if keyErr != nil {
if _, err := fmt.Fprintf(stderr, "warning: its stored API key could not be deleted and remains in the credential store: %v\n", keyErr); err != nil {
return exitCrash
}
} else if keyRemoved {
if _, err := fmt.Fprintln(stdout, "Deleted its stored API key."); err != nil {
return exitCrash
}
}
if active := strings.TrimSpace(cfg.ActiveProvider); active != "" {
if _, err := fmt.Fprintf(stdout, "Active provider: %s\n", active); err != nil {
return exitCrash
}
} else {
if _, err := fmt.Fprintln(stdout, "No providers remain — run zero setup to add one."); err != nil {
return exitCrash
}
}
return exitSuccess
}
// removeStoredProviderKeyAt deletes a provider's API key from the credential
// store co-located with configPath (the store SecureProviderProfile captured
// it into and RenameProvider migrates within).
func removeStoredProviderKeyAt(configPath string, provider string) (bool, error) {
store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath))
if err != nil {
return false, err
}
return store.Delete(provider)
}
// runProvidersRename renames a saved provider profile, migrating its stored
// API key and the activeProvider pointer along with it (config.RenameProvider).
func runProvidersRename(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseProviderNamesArgs(args, 2, "usage: zero providers rename <old> <new>")
if err != nil {
return writeExecUsageError(stderr, err.Error())
}
if help {
if err := writeProvidersHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
configPath, err := deps.userConfigPath()
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
cfg, err := config.RenameProvider(configPath, options.names[0], options.names[1])
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
if options.json {
if err := writePrettyJSON(stdout, map[string]any{
"renamed": map[string]string{"from": options.names[0], "to": options.names[1]},
"activeProvider": cfg.ActiveProvider,
"configPath": configPath,
}); err != nil {
return exitCrash
}
return exitSuccess
}
if _, err := fmt.Fprintf(stdout, "Renamed provider %s to %s\n", options.names[0], options.names[1]); err != nil {
return exitCrash
}
return exitSuccess
}
+45
View File
@@ -234,3 +234,48 @@ func writeProviderOnboardingConfig(t *testing.T, path string, cfg config.FileCon
t.Fatalf("write config: %v", err)
}
}
// TestRunProvidersRemoveDeletesKeyBesideConfig: the stored key must be deleted
// from the credential store CO-LOCATED with the config being edited (where
// SecureProviderProfile captured it), not the default-path store.
func TestRunProvidersRemoveDeletesKeyBesideConfig(t *testing.T) {
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
seed := `{"activeProvider":"gw","providers":[{"name":"gw","provider_kind":"openai-compatible","baseURL":"https://gw.example.com/v1","apiKeyStored":true,"model":"m1"},{"name":"other","provider_kind":"openai-compatible","baseURL":"https://o.example.com/v1","model":"m2"}]}`
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
t.Fatalf("seed config: %v", err)
}
store, err := config.ProviderKeyStoreAt(dir)
if err != nil {
t.Fatalf("open store: %v", err)
}
if err := store.Set("gw", "sk-secret"); err != nil {
t.Fatalf("seed key: %v", err)
}
var stdout, stderr bytes.Buffer
deps := appDeps{userConfigPath: func() (string, error) { return configPath, nil }}
if code := runWithDeps([]string{"providers", "remove", "gw", "--json"}, &stdout, &stderr, deps); code != exitSuccess {
t.Fatalf("remove failed: code=%d stderr=%s", code, stderr.String())
}
var payload struct {
Removed string `json:"removed"`
KeyRemoved bool `json:"keyRemoved"`
KeyError string `json:"keyError"`
ActiveProvider string `json:"activeProvider"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode JSON: %v\n%s", err, stdout.String())
}
if payload.Removed != "gw" || !payload.KeyRemoved || payload.KeyError != "" {
t.Fatalf("unexpected payload: %+v", payload)
}
if payload.ActiveProvider != "other" {
t.Fatalf("active must hand off, got %q", payload.ActiveProvider)
}
if _, ok, _ := store.Get("gw"); ok {
t.Fatalf("stored key must be deleted from the store beside the config")
}
}
+5 -1
View File
@@ -457,7 +457,11 @@ func selectProviderForCheck(resolved config.ResolvedConfig, name string) (config
}
func validateProviderRuntimeReady(profile config.ProviderProfile) error {
hasCredential := providerProfileHasCredential(profile)
// A stored OAuth login (e.g. `zero auth chatgpt`) is a credential too: a
// keyless token-login profile must pass the readiness check instead of being
// told to set an API key it will never have.
hasCredential := providerProfileHasCredential(profile) ||
providerHasOAuthLogin(profile, oauthLoggedInProviders())
if profile.CatalogID != "" {
descriptor, err := providercatalog.Require(profile.CatalogID)
if err != nil {
+4 -2
View File
@@ -322,7 +322,9 @@ func setupRequired(resolved config.ResolvedConfig) bool {
// the candidate set is permissive (profile name + catalog ID).
func providerHasOAuthLogin(profile config.ProviderProfile, oauthLogins map[string]bool) bool {
for _, name := range profile.OAuthLoginCandidates() {
if oauthLogins[name] {
// Store keys are normalized to lower case (oauth.ProviderKey), so the
// presence check must compare the same way.
if oauthLogins[strings.ToLower(strings.TrimSpace(name))] {
return true
}
}
@@ -344,7 +346,7 @@ func oauthLoggedInProviders() map[string]bool {
}
for _, status := range statuses {
if status.HasToken {
out[strings.TrimPrefix(status.Key, oauth.KeyPrefixProvider)] = true
out[strings.ToLower(strings.TrimPrefix(status.Key, oauth.KeyPrefixProvider))] = true
}
}
return out
+39 -5
View File
@@ -1,7 +1,6 @@
package cli
import (
"path/filepath"
"testing"
"time"
@@ -10,6 +9,7 @@ import (
)
func TestFirstUsableProviderPrefersRemoteKeyed(t *testing.T) {
withAuthStore(t)
providers := []config.ProviderProfile{
{Name: "ollama", CatalogID: "ollama", BaseURL: "http://localhost:11434/v1", APIKey: "k"}, // usable but local
{Name: "moonshot", CatalogID: "moonshot", BaseURL: "https://api.moonshot.ai/v1", APIKey: "k"}, // usable, remote
@@ -22,6 +22,7 @@ func TestFirstUsableProviderPrefersRemoteKeyed(t *testing.T) {
}
func TestFirstUsableProviderFallsBackToLocal(t *testing.T) {
withAuthStore(t)
providers := []config.ProviderProfile{
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"}, // not usable
{Name: "ollama", CatalogID: "ollama", BaseURL: "http://localhost:11434/v1", APIKey: "k"}, // local, usable
@@ -33,6 +34,7 @@ func TestFirstUsableProviderFallsBackToLocal(t *testing.T) {
}
func TestFirstUsableProviderNoneUsable(t *testing.T) {
withAuthStore(t)
providers := []config.ProviderProfile{
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"},
{Name: "openai", CatalogID: "openai", APIKeyEnv: "OPENAI_API_KEY"},
@@ -45,6 +47,10 @@ func TestFirstUsableProviderNoneUsable(t *testing.T) {
// A keyless local proxy (chatgpt-proxy, RequiresAuth=false) is usable without a
// credential, so it can serve as a fallback rather than forcing onboarding.
func TestFirstUsableProviderAcceptsKeylessLocalProxy(t *testing.T) {
// Isolate the OAuth token store: on a developer machine with a real xai
// login, the env-only xai profile would count as usable and win over the
// local proxy this test is about.
withAuthStore(t)
providers := []config.ProviderProfile{
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"},
{Name: "chatgpt", CatalogID: "chatgpt-proxy", BaseURL: "http://localhost:10531/v1"},
@@ -59,6 +65,7 @@ func TestFirstUsableProviderAcceptsKeylessLocalProxy(t *testing.T) {
// BaseURL has no endpoint, so it must be skipped rather than selected as a
// fallback that fails at first use. A stale CatalogID with a BaseURL still works.
func TestFirstUsableProviderSkipsUnresolvableCatalogWithoutBaseURL(t *testing.T) {
withAuthStore(t)
providers := []config.ProviderProfile{
{Name: "ghost", CatalogID: "no-such-catalog-entry", APIKey: "k"}, // unusable: no endpoint
{Name: "custom", CatalogID: "no-such-catalog-entry", BaseURL: "https://api.custom.test/v1", APIKey: "k"},
@@ -74,10 +81,8 @@ func TestFirstUsableProviderSkipsUnresolvableCatalogWithoutBaseURL(t *testing.T)
// authenticated user gets forced back into onboarding when activeProvider
// goes stale.
func TestFirstUsableProviderRecognizesOAuthLogin(t *testing.T) {
path := filepath.Join(t.TempDir(), "tok.json")
t.Setenv("ZERO_OAUTH_STORAGE", "file") // an inherited "keyring" would ignore the temp path and hit the OS keychain
t.Setenv("ZERO_OAUTH_TOKENS_PATH", path)
store, err := oauth.NewStore(oauth.StoreOptions{FilePath: path})
withAuthStore(t)
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
t.Fatalf("NewStore: %v", err)
}
@@ -117,3 +122,32 @@ func TestProviderProfileIsLocal(t *testing.T) {
})
}
}
// TestFirstUsableProviderAcceptsOAuthLoginProfile: a keyless catalog profile
// whose credential is a stored OAuth login (the shape the login flows persist)
// must be usable during active-pointer recovery — the same rule setupRequired
// and usableSavedProviders apply — instead of falling through to onboarding.
func TestFirstUsableProviderAcceptsOAuthLoginProfile(t *testing.T) {
withAuthStore(t)
providers := []config.ProviderProfile{
{Name: "chatgpt", CatalogID: "chatgpt", BaseURL: "https://chatgpt.com/backend-api/codex", Model: "gpt-5.5"},
}
// No login stored: still not usable.
if got, ok := firstUsableProvider(providers); ok {
t.Fatalf("keyless profile without a login must not be usable, got %q", got.Name)
}
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
t.Fatalf("oauth store: %v", err)
}
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "bearer-123"}); err != nil {
t.Fatalf("save token: %v", err)
}
got, ok := firstUsableProvider(providers)
if !ok || got.Name != "chatgpt" {
t.Fatalf("want OAuth-login profile to be usable, got %q ok=%v", got.Name, ok)
}
}
+362
View File
@@ -7,6 +7,8 @@ import (
"path/filepath"
"sort"
"strings"
"github.com/Gitlawb/zero/internal/providercatalog"
)
func UpsertProvider(path string, profile ProviderProfile, setActive bool) (FileConfig, error) {
@@ -29,6 +31,20 @@ func UpsertProvider(path string, profile ProviderProfile, setActive bool) (FileC
}
mergeProvider(&cfg, profile)
// mergeProfile deliberately ignores APIKeyStored — during resolve-time
// layering a project config must not be able to claim the user's stored
// keys. This user-config WRITE path re-applies the marker: capturing a key
// via SecureProviderProfile onto a previously env/no-key profile must
// persist apiKeyStored, or the secret sits in the credential store while
// every ApplyStoredAPIKey gate skips it (PR #560 review).
if profile.APIKeyStored {
for index := range cfg.Providers {
if cfg.Providers[index].Name == profile.Name {
cfg.Providers[index].APIKeyStored = true
break
}
}
}
if setActive || strings.TrimSpace(cfg.ActiveProvider) == "" {
cfg.ActiveProvider = profile.Name
}
@@ -39,6 +55,62 @@ func UpsertProvider(path string, profile ProviderProfile, setActive bool) (FileC
return cfg, nil
}
// EnsuredProvider reports the outcome of EnsureCatalogProvider: the profile name
// that serves the catalog entry, whether it was newly created, and which provider
// is active after the call (unchanged unless it was blank).
type EnsuredProvider struct {
Name string
Created bool
Active string
}
// EnsureCatalogProvider guarantees a provider profile exists in the config at
// path for the given catalog entry. OAuth login flows call this right after
// storing a token: a login is only reachable from the provider list and
// `zero providers use` when a profile exists, but a login must never replace or
// deactivate the user's current active provider — so an existing profile whose
// Name or CatalogID already matches is left completely untouched (its name,
// credentials, and model are the user's), and a created profile is NOT marked
// active unless no provider was active at all.
func EnsureCatalogProvider(path string, catalogID string) (EnsuredProvider, error) {
path = strings.TrimSpace(path)
if path == "" {
return EnsuredProvider{}, fmt.Errorf("config path is required")
}
descriptor, err := providercatalog.Require(catalogID)
if err != nil {
return EnsuredProvider{}, err
}
cfg := FileConfig{}
if data, err := os.ReadFile(path); err == nil {
if err := json.Unmarshal(data, &cfg); err != nil {
return EnsuredProvider{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
} else if !os.IsNotExist(err) {
return EnsuredProvider{}, fmt.Errorf("read config %s: %w", path, err)
}
for _, provider := range cfg.Providers {
if strings.EqualFold(strings.TrimSpace(provider.CatalogID), descriptor.ID) ||
strings.EqualFold(strings.TrimSpace(provider.Name), descriptor.ID) {
return EnsuredProvider{Name: provider.Name, Active: cfg.ActiveProvider}, nil
}
}
profile := ProviderProfile{
Name: descriptor.ID,
ProviderKind: providerKindForCatalogTransport(descriptor.Transport),
CatalogID: descriptor.ID,
BaseURL: descriptor.DefaultBaseURL,
Model: descriptor.DefaultModel,
}
written, err := UpsertProvider(path, profile, false)
if err != nil {
return EnsuredProvider{}, err
}
return EnsuredProvider{Name: profile.Name, Created: true, Active: written.ActiveProvider}, nil
}
func SetActiveProvider(path string, name string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
@@ -72,6 +144,296 @@ func SetActiveProvider(path string, name string) (FileConfig, error) {
return FileConfig{}, fmt.Errorf("provider %q not found", name)
}
// RemoveProvider deletes the named provider profile from the config at path.
// When the removed profile was active, activeProvider hands off to the first
// remaining provider (or clears when none remain) so the config never points at
// a profile that no longer exists. The caller owns cleaning up the credential
// store entry — config stays pure of secret I/O on the read path, and remove
// keeps that symmetry by only touching config.json.
func RemoveProvider(path string, name string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
name = strings.TrimSpace(name)
if name == "" {
return FileConfig{}, fmt.Errorf("provider name is required")
}
data, err := os.ReadFile(path)
if err != nil {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
cfg := FileConfig{}
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
index := -1
for i, provider := range cfg.Providers {
if strings.EqualFold(strings.TrimSpace(provider.Name), name) {
index = i
break
}
}
if index < 0 {
return FileConfig{}, fmt.Errorf("provider %q not found", name)
}
removed := cfg.Providers[index]
cfg.Providers = append(cfg.Providers[:index], cfg.Providers[index+1:]...)
if strings.EqualFold(strings.TrimSpace(cfg.ActiveProvider), strings.TrimSpace(removed.Name)) {
cfg.ActiveProvider = ""
if len(cfg.Providers) > 0 {
cfg.ActiveProvider = cfg.Providers[0].Name
}
}
if err := writeConfigFile(path, cfg); err != nil {
return FileConfig{}, err
}
return cfg, nil
}
// RenameProvider renames a provider profile, keeping everything keyed by the
// profile name consistent: the activeProvider pointer follows the rename, and a
// key in the encrypted credential store (APIKeyStored) is migrated to the new
// name BEFORE the config is rewritten — the store write must succeed first so a
// failed migration never strands the config pointing at a key that no longer
// resolves. OAuth tokens are deliberately not migrated: the runtime's login
// candidates fall back to the profile's CatalogID, which every OAuth-capable
// catalog profile carries, so a rename keeps the login reachable.
func RenameProvider(path string, oldName string, newName string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
oldName = strings.TrimSpace(oldName)
newName = strings.TrimSpace(newName)
if oldName == "" || newName == "" {
return FileConfig{}, fmt.Errorf("provider names are required")
}
data, err := os.ReadFile(path)
if err != nil {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
cfg := FileConfig{}
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
index := -1
for i, provider := range cfg.Providers {
providerName := strings.TrimSpace(provider.Name)
if strings.EqualFold(providerName, oldName) {
index = i
continue
}
if strings.EqualFold(providerName, newName) {
return FileConfig{}, fmt.Errorf("provider %q already exists", newName)
}
}
if index < 0 {
return FileConfig{}, fmt.Errorf("provider %q not found", oldName)
}
if strings.EqualFold(oldName, newName) && cfg.Providers[index].Name == newName {
return cfg, nil
}
previousName := cfg.Providers[index].Name
keyMigrated := false
if cfg.Providers[index].APIKeyStored {
if err := migrateStoredProviderKey(path, previousName, newName); err != nil {
return FileConfig{}, fmt.Errorf("migrate stored key for %q: %w", oldName, err)
}
keyMigrated = true
}
if strings.EqualFold(strings.TrimSpace(cfg.ActiveProvider), strings.TrimSpace(previousName)) {
cfg.ActiveProvider = newName
}
cfg.Providers[index].Name = newName
if err := writeConfigFile(path, cfg); err != nil {
if keyMigrated {
// Compensate best-effort: config.json still names the OLD profile, so
// move the key back where that config can find it — otherwise a failed
// rewrite strands the key under a name no profile carries.
_ = migrateStoredProviderKey(path, newName, previousName)
}
return FileConfig{}, err
}
return cfg, nil
}
// ProviderEdit is a field-level edit of one saved provider, applied by
// EditProvider in a single atomic write. Name is the CURRENT profile name
// (matched case-insensitively); NewName renames (case-only renames included).
// Empty BaseURL/Model/APIKey mean "leave unchanged"; Description is applied
// VERBATIM (the editor always knows the full desired text, so clearing works).
type ProviderEdit struct {
Name string
NewName string
BaseURL string
Model string
APIKey string
APIKeyStored bool
Description string
}
// EditProvider applies a provider edit in ONE read-modify-write: rename
// (activeProvider follows; a stored key migrates, with a best-effort rollback
// if the config write fails), field updates, the stored-key marker, and the
// verbatim description. A single write keeps the operation atomic — the
// previous rename+upsert+describe sequence could fail halfway and leave
// config.json renamed while every in-memory consumer still held the old name —
// and, unlike UpsertProvider's exact-name merge, the case-insensitive match
// here makes a case-only rename (groq -> Groq) an in-place update instead of
// an appended duplicate profile.
func EditProvider(path string, edit ProviderEdit) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
oldName := strings.TrimSpace(edit.Name)
if oldName == "" {
return FileConfig{}, fmt.Errorf("provider name is required")
}
newName := strings.TrimSpace(edit.NewName)
if newName == "" {
newName = oldName
}
data, err := os.ReadFile(path)
if err != nil {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
cfg := FileConfig{}
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
index := -1
for i, provider := range cfg.Providers {
providerName := strings.TrimSpace(provider.Name)
if strings.EqualFold(providerName, oldName) {
index = i
continue
}
if strings.EqualFold(providerName, newName) {
return FileConfig{}, fmt.Errorf("provider %q already exists", newName)
}
}
if index < 0 {
return FileConfig{}, fmt.Errorf("provider %q not found", oldName)
}
previousName := cfg.Providers[index].Name
renamed := previousName != newName
keyMigrated := false
// A rename moves the stored key along: either the profile's existing entry,
// or a replacement key the caller just captured — the contract is that a
// captured key is stored under the CURRENT name before EditProvider runs, so
// one migration covers both. migrateStoredProviderKey no-ops on case-only
// renames (the store normalizes names), so it cannot delete the key it just
// moved.
if renamed && (cfg.Providers[index].APIKeyStored || edit.APIKeyStored) {
if err := migrateStoredProviderKey(path, previousName, newName); err != nil {
return FileConfig{}, fmt.Errorf("migrate stored key for %q: %w", oldName, err)
}
keyMigrated = true
}
if renamed && strings.EqualFold(strings.TrimSpace(cfg.ActiveProvider), strings.TrimSpace(previousName)) {
cfg.ActiveProvider = newName
}
profile := &cfg.Providers[index]
profile.Name = newName
if baseURL := strings.TrimSpace(edit.BaseURL); baseURL != "" {
profile.BaseURL = baseURL
}
if model := strings.TrimSpace(edit.Model); model != "" {
profile.Model = model
}
if apiKey := strings.TrimSpace(edit.APIKey); apiKey != "" {
profile.APIKey = apiKey
}
if edit.APIKeyStored {
profile.APIKeyStored = true
}
profile.Description = strings.TrimSpace(edit.Description)
if err := writeConfigFile(path, cfg); err != nil {
if keyMigrated {
// Compensate best-effort: config.json still names the OLD profile, so
// move the key back where that config can find it.
_ = migrateStoredProviderKey(path, newName, previousName)
}
return FileConfig{}, err
}
return cfg, nil
}
// SetProviderDescription sets a provider's description VERBATIM — including to
// empty. The generic UpsertProvider merge treats empty fields as "leave
// unchanged", so clearing a description needs this dedicated setter.
func SetProviderDescription(path string, name string, description string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
return FileConfig{}, fmt.Errorf("config path is required")
}
name = strings.TrimSpace(name)
if name == "" {
return FileConfig{}, fmt.Errorf("provider name is required")
}
data, err := os.ReadFile(path)
if err != nil {
return FileConfig{}, fmt.Errorf("read config %s: %w", path, err)
}
cfg := FileConfig{}
if err := json.Unmarshal(data, &cfg); err != nil {
return FileConfig{}, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
for index := range cfg.Providers {
if strings.EqualFold(strings.TrimSpace(cfg.Providers[index].Name), name) {
cfg.Providers[index].Description = strings.TrimSpace(description)
if err := writeConfigFile(path, cfg); err != nil {
return FileConfig{}, err
}
return cfg, nil
}
}
return FileConfig{}, fmt.Errorf("provider %q not found", name)
}
// migrateStoredProviderKey moves a credential-store entry to a new provider
// name: write-new-then-delete-old, so an interruption can leave a duplicate but
// never a missing key. A missing source entry is a no-op (the marker may be
// stale); only a failed WRITE aborts the rename.
func migrateStoredProviderKey(configPath string, oldName string, newName string) error {
// The store normalizes names case-insensitively, so a case-only rename
// (groq -> Groq) targets ONE entry: Set(new) rewrites it in place and
// Delete(old) would then remove the key that was just "moved". Nothing to
// migrate — the existing entry already serves the new name.
if strings.EqualFold(strings.TrimSpace(oldName), strings.TrimSpace(newName)) {
return nil
}
store, err := ProviderKeyStoreAt(filepath.Dir(configPath))
if err != nil {
return err
}
key, ok, err := store.Get(oldName)
if err != nil {
return err
}
if !ok || strings.TrimSpace(key) == "" {
return nil
}
if err := store.Set(newName, key); err != nil {
return err
}
_, _ = store.Delete(oldName)
return nil
}
func SetProviderModel(path string, name string, model string) (FileConfig, error) {
path = strings.TrimSpace(path)
if path == "" {
+543
View File
@@ -5,6 +5,7 @@ import (
"errors"
"io/fs"
"os"
"os/exec"
"path/filepath"
"reflect"
"runtime"
@@ -385,3 +386,545 @@ func readConfigFixture(t *testing.T, path string) FileConfig {
}
return cfg
}
func TestEnsureCatalogProviderCreatesProfileWithoutStealingActive(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "opengateway",
Providers: []ProviderProfile{
{
Name: "opengateway",
ProviderKind: ProviderKindOpenAICompatible,
BaseURL: "https://gateway.example.com/v1",
APIKeyStored: true,
Model: "some-model",
},
},
}, 0o600)
ensured, err := EnsureCatalogProvider(path, "chatgpt")
if err != nil {
t.Fatalf("EnsureCatalogProvider: %v", err)
}
if !ensured.Created {
t.Fatalf("expected profile to be created")
}
if ensured.Name != "chatgpt" {
t.Fatalf("expected profile name chatgpt, got %q", ensured.Name)
}
if ensured.Active != "opengateway" {
t.Fatalf("active provider must stay opengateway, got %q", ensured.Active)
}
cfg := readConfigFixture(t, path)
if cfg.ActiveProvider != "opengateway" {
t.Fatalf("persisted active provider changed to %q", cfg.ActiveProvider)
}
if len(cfg.Providers) != 2 {
t.Fatalf("expected 2 providers, got %d", len(cfg.Providers))
}
chatgpt := cfg.Providers[1]
if chatgpt.Name != "chatgpt" || chatgpt.CatalogID != "chatgpt" {
t.Fatalf("unexpected created profile: %+v", chatgpt)
}
if chatgpt.Model == "" || chatgpt.BaseURL == "" {
t.Fatalf("created profile must carry catalog defaults, got %+v", chatgpt)
}
if chatgpt.APIKey != "" || chatgpt.APIKeyStored {
t.Fatalf("OAuth-created profile must stay keyless, got %+v", chatgpt)
}
}
func TestEnsureCatalogProviderLeavesExistingProfileUntouched(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
original := FileConfig{
ActiveProvider: "opengateway",
Providers: []ProviderProfile{
{Name: "opengateway", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", Model: "some-model"},
// Renamed profile that already serves the chatgpt catalog entry.
{Name: "codex", CatalogID: "chatgpt", Model: "gpt-5.5"},
},
}
data := writeConfigFixture(t, path, original, 0o600)
ensured, err := EnsureCatalogProvider(path, "chatgpt")
if err != nil {
t.Fatalf("EnsureCatalogProvider: %v", err)
}
if ensured.Created {
t.Fatalf("existing profile must not be recreated")
}
if ensured.Name != "codex" {
t.Fatalf("expected existing profile name codex, got %q", ensured.Name)
}
after, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config: %v", err)
}
if string(after) != string(data) {
t.Fatalf("config rewritten for a no-op ensure:\nbefore: %s\nafter: %s", data, after)
}
}
func TestEnsureCatalogProviderActivatesOnEmptyConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
ensured, err := EnsureCatalogProvider(path, "chatgpt")
if err != nil {
t.Fatalf("EnsureCatalogProvider: %v", err)
}
if !ensured.Created {
t.Fatalf("expected profile to be created")
}
if ensured.Active != "chatgpt" {
t.Fatalf("blank active must adopt the new provider, got %q", ensured.Active)
}
}
func TestEnsureCatalogProviderRejectsUnknownCatalogID(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
if _, err := EnsureCatalogProvider(path, "no-such-provider"); err == nil {
t.Fatalf("expected unknown catalog id to error")
}
if _, err := os.Stat(path); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("config must not be written for an unknown catalog id")
}
}
func TestRemoveProviderDeletesAndHandsOffActive(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "beta",
Providers: []ProviderProfile{
{Name: "alpha", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1"},
{Name: "beta", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://b.example.com/v1", Model: "m2"},
},
}, 0o600)
cfg, err := RemoveProvider(path, " BETA ")
if err != nil {
t.Fatalf("RemoveProvider() error = %v", err)
}
if len(cfg.Providers) != 1 || cfg.Providers[0].Name != "alpha" {
t.Fatalf("expected only alpha to remain, got %+v", cfg.Providers)
}
if cfg.ActiveProvider != "alpha" {
t.Fatalf("active must hand off to the first remaining provider, got %q", cfg.ActiveProvider)
}
persisted := readConfigFixture(t, path)
if len(persisted.Providers) != 1 || persisted.ActiveProvider != "alpha" {
t.Fatalf("persisted config wrong: %+v", persisted)
}
// Removing the last provider clears the active pointer entirely.
cfg, err = RemoveProvider(path, "alpha")
if err != nil {
t.Fatalf("RemoveProvider(last) error = %v", err)
}
if len(cfg.Providers) != 0 || cfg.ActiveProvider != "" {
t.Fatalf("expected empty providers and no active, got %+v", cfg)
}
}
func TestRemoveProviderKeepsActiveWhenOtherRemoved(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "alpha",
Providers: []ProviderProfile{
{Name: "alpha", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1"},
{Name: "beta", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://b.example.com/v1", Model: "m2"},
},
}, 0o600)
cfg, err := RemoveProvider(path, "beta")
if err != nil {
t.Fatalf("RemoveProvider() error = %v", err)
}
if cfg.ActiveProvider != "alpha" {
t.Fatalf("active provider must be untouched, got %q", cfg.ActiveProvider)
}
}
func TestRemoveProviderRejectsUnknownWithoutRewriting(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
before := writeConfigFixture(t, path, FileConfig{
ActiveProvider: "alpha",
Providers: []ProviderProfile{{Name: "alpha", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1"}},
}, 0o600)
if _, err := RemoveProvider(path, "ghost"); err == nil {
t.Fatal("RemoveProvider() error = nil, want not-found error")
}
after, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config: %v", err)
}
if string(after) != string(before) {
t.Fatalf("config was rewritten for unknown provider")
}
}
func TestRenameProviderFollowsActiveAndMigratesStoredKey(t *testing.T) {
dir := t.TempDir()
// Force the file credential backend so the test never touches the real OS
// keyring regardless of platform.
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
path := filepath.Join(dir, "config.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "oldname",
Providers: []ProviderProfile{
{Name: "oldname", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", APIKeyStored: true, Model: "m1"},
{Name: "other", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://b.example.com/v1", Model: "m2"},
},
}, 0o600)
store, err := ProviderKeyStoreAt(dir)
if err != nil {
t.Fatalf("open store: %v", err)
}
if err := store.Set("oldname", "sk-secret"); err != nil {
t.Fatalf("seed store: %v", err)
}
cfg, err := RenameProvider(path, "oldname", "newname")
if err != nil {
t.Fatalf("RenameProvider() error = %v", err)
}
if cfg.ActiveProvider != "newname" {
t.Fatalf("active must follow the rename, got %q", cfg.ActiveProvider)
}
if cfg.Providers[0].Name != "newname" || !cfg.Providers[0].APIKeyStored {
t.Fatalf("renamed profile wrong: %+v", cfg.Providers[0])
}
if cfg.Providers[1].Name != "other" {
t.Fatalf("unrelated profile changed: %+v", cfg.Providers[1])
}
key, ok, err := store.Get("newname")
if err != nil || !ok || key != "sk-secret" {
t.Fatalf("stored key must migrate to the new name, got key=%q ok=%v err=%v", key, ok, err)
}
if _, ok, _ := store.Get("oldname"); ok {
t.Fatalf("old credential entry must be deleted after migration")
}
}
func TestRenameProviderRejectsCollisionAndUnknown(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
before := writeConfigFixture(t, path, FileConfig{
ActiveProvider: "alpha",
Providers: []ProviderProfile{
{Name: "alpha", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1"},
{Name: "beta", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://b.example.com/v1", Model: "m2"},
},
}, 0o600)
if _, err := RenameProvider(path, "alpha", "BETA"); err == nil {
t.Fatal("rename onto an existing name must fail")
}
if _, err := RenameProvider(path, "ghost", "gamma"); err == nil {
t.Fatal("renaming an unknown provider must fail")
}
after, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config: %v", err)
}
if string(after) != string(before) {
t.Fatalf("config was rewritten by a rejected rename")
}
}
func TestUpsertProviderPreservesStoredKeyMarkerOnExistingProfile(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
// An env-keyed profile with NO stored-key marker — the shape a provider has
// before its key is captured into the credential store.
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "groq",
Providers: []ProviderProfile{
{Name: "groq", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://api.groq.com/openai/v1", APIKeyEnv: "GROQ_API_KEY", Model: "m1"},
},
}, 0o600)
// The manager/setup edit paths persist a SecureProviderProfile-shaped
// profile: key already in the store, marker set, inline key cleared.
cfg, err := UpsertProvider(path, ProviderProfile{Name: "groq", APIKeyStored: true}, false)
if err != nil {
t.Fatalf("UpsertProvider() error = %v", err)
}
if !cfg.Providers[0].APIKeyStored {
t.Fatalf("APIKeyStored marker must survive the merge, got %+v", cfg.Providers[0])
}
if cfg.Providers[0].APIKeyEnv != "GROQ_API_KEY" || cfg.Providers[0].BaseURL == "" {
t.Fatalf("other fields must be preserved: %+v", cfg.Providers[0])
}
persisted := readConfigFixture(t, path)
if !persisted.Providers[0].APIKeyStored {
t.Fatalf("marker not persisted to disk: %+v", persisted.Providers[0])
}
}
func TestSetProviderDescriptionSetsAndClears(t *testing.T) {
path := filepath.Join(t.TempDir(), "zero.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "alpha",
Providers: []ProviderProfile{
{Name: "alpha", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1", Description: "old text"},
},
}, 0o600)
cfg, err := SetProviderDescription(path, " ALPHA ", "new text")
if err != nil {
t.Fatalf("SetProviderDescription() error = %v", err)
}
if cfg.Providers[0].Description != "new text" {
t.Fatalf("description not set: %+v", cfg.Providers[0])
}
// Clearing must persist too — the reason this setter exists (UpsertProvider's
// merge treats an empty description as "leave unchanged").
cfg, err = SetProviderDescription(path, "alpha", " ")
if err != nil {
t.Fatalf("SetProviderDescription(clear) error = %v", err)
}
if cfg.Providers[0].Description != "" {
t.Fatalf("description not cleared: %+v", cfg.Providers[0])
}
persisted := readConfigFixture(t, path)
if persisted.Providers[0].Description != "" {
t.Fatalf("cleared description not persisted: %+v", persisted.Providers[0])
}
if _, err := SetProviderDescription(path, "ghost", "x"); err == nil {
t.Fatal("unknown provider must error")
}
}
func TestRenameProviderRollsBackKeyMigrationWhenConfigWriteFails(t *testing.T) {
if runtime.GOOS != "darwin" {
t.Skip("uses chflags uchg to force the config write to fail; macOS only")
}
dir := t.TempDir()
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
path := filepath.Join(dir, "config.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "oldname",
Providers: []ProviderProfile{
{Name: "oldname", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", APIKeyStored: true, Model: "m1"},
},
}, 0o600)
store, err := ProviderKeyStoreAt(dir)
if err != nil {
t.Fatalf("open store: %v", err)
}
if err := store.Set("oldname", "sk-secret"); err != nil {
t.Fatalf("seed store: %v", err)
}
// Immutable flag: temp-file creation and store writes in the directory keep
// working, but the final rename over config.json fails — the exact window
// where a migrated key would otherwise strand under the new name.
if out, err := exec.Command("chflags", "uchg", path).CombinedOutput(); err != nil {
t.Skipf("chflags uchg unavailable: %v (%s)", err, out)
}
t.Cleanup(func() { _ = exec.Command("chflags", "nouchg", path).Run() })
if _, err := RenameProvider(path, "oldname", "newname"); err == nil {
t.Fatal("expected the config write to fail under the immutable flag")
}
key, ok, err := store.Get("oldname")
if err != nil || !ok || key != "sk-secret" {
t.Fatalf("key must be rolled back to the old name, got key=%q ok=%v err=%v", key, ok, err)
}
if _, ok, _ := store.Get("newname"); ok {
t.Fatalf("rolled-back migration must not leave a key under the new name")
}
}
// TestRenameProviderCaseOnlyKeepsStoredKey: the credential store normalizes
// names case-insensitively, so a case-only rename targets ONE store entry —
// migrating would Set and then Delete the same key, losing it (PR #560 review).
func TestRenameProviderCaseOnlyKeepsStoredKey(t *testing.T) {
dir := t.TempDir()
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
path := filepath.Join(dir, "config.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "groq",
Providers: []ProviderProfile{
{Name: "groq", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://api.groq.com/openai/v1", APIKeyStored: true, Model: "m1"},
},
}, 0o600)
store, err := ProviderKeyStoreAt(dir)
if err != nil {
t.Fatalf("open store: %v", err)
}
if err := store.Set("groq", "sk-secret"); err != nil {
t.Fatalf("seed store: %v", err)
}
cfg, err := RenameProvider(path, "groq", "Groq")
if err != nil {
t.Fatalf("RenameProvider() error = %v", err)
}
if cfg.Providers[0].Name != "Groq" || cfg.ActiveProvider != "Groq" {
t.Fatalf("case-only rename must still apply to config: %+v", cfg)
}
// The store is case-insensitive: the key must remain retrievable under the
// new casing (same entry), not deleted by a same-entry "migration".
if key, ok, err := store.Get("Groq"); err != nil || !ok || key != "sk-secret" {
t.Fatalf("stored key lost on case-only rename: key=%q ok=%v err=%v", key, ok, err)
}
}
func TestEditProviderAppliesRenameFieldsAndDescriptionAtomically(t *testing.T) {
dir := t.TempDir()
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
path := filepath.Join(dir, "config.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "groq",
Providers: []ProviderProfile{
{Name: "groq", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://api.groq.com/openai/v1", APIKeyStored: true, Model: "m1", Description: "old text"},
{Name: "other", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://o.example.com/v1", Model: "m2"},
},
}, 0o600)
store, err := ProviderKeyStoreAt(dir)
if err != nil {
t.Fatalf("open store: %v", err)
}
if err := store.Set("groq", "sk-old"); err != nil {
t.Fatalf("seed store: %v", err)
}
cfg, err := EditProvider(path, ProviderEdit{
Name: "groq",
NewName: "grok-main",
Model: "m1-pro",
Description: "", // cleared — must persist verbatim
})
if err != nil {
t.Fatalf("EditProvider() error = %v", err)
}
edited := cfg.Providers[0]
if edited.Name != "grok-main" || edited.Model != "m1-pro" || edited.Description != "" {
t.Fatalf("edit not applied: %+v", edited)
}
if edited.BaseURL != "https://api.groq.com/openai/v1" || !edited.APIKeyStored {
t.Fatalf("untouched fields must survive: %+v", edited)
}
if cfg.ActiveProvider != "grok-main" {
t.Fatalf("active must follow the rename, got %q", cfg.ActiveProvider)
}
if key, ok, _ := store.Get("grok-main"); !ok || key != "sk-old" {
t.Fatalf("stored key must migrate with the rename, got %q ok=%v", key, ok)
}
if len(cfg.Providers) != 2 || cfg.Providers[1].Name != "other" {
t.Fatalf("unrelated profile changed: %+v", cfg.Providers)
}
}
// TestEditProviderCaseOnlyRenameUpdatesInPlace: the manager previously skipped
// RenameProvider on case-insensitively-equal names and fell into UpsertProvider,
// whose case-SENSITIVE merge appended a duplicate profile. EditProvider matches
// case-insensitively, so a case-only rename is an in-place update and the store
// entry (case-normalized) survives.
func TestEditProviderCaseOnlyRenameUpdatesInPlace(t *testing.T) {
dir := t.TempDir()
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
path := filepath.Join(dir, "config.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "groq",
Providers: []ProviderProfile{
{Name: "groq", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://api.groq.com/openai/v1", APIKeyStored: true, Model: "m1"},
},
}, 0o600)
store, err := ProviderKeyStoreAt(dir)
if err != nil {
t.Fatalf("open store: %v", err)
}
if err := store.Set("groq", "sk-secret"); err != nil {
t.Fatalf("seed store: %v", err)
}
cfg, err := EditProvider(path, ProviderEdit{Name: "groq", NewName: "Groq"})
if err != nil {
t.Fatalf("EditProvider() error = %v", err)
}
if len(cfg.Providers) != 1 {
t.Fatalf("case-only rename must not duplicate the profile: %+v", cfg.Providers)
}
if cfg.Providers[0].Name != "Groq" || !cfg.Providers[0].APIKeyStored {
t.Fatalf("in-place update wrong: %+v", cfg.Providers[0])
}
if cfg.Providers[0].BaseURL != "https://api.groq.com/openai/v1" {
t.Fatalf("fields must survive a case-only rename: %+v", cfg.Providers[0])
}
if cfg.ActiveProvider != "Groq" {
t.Fatalf("active must follow, got %q", cfg.ActiveProvider)
}
if key, ok, _ := store.Get("Groq"); !ok || key != "sk-secret" {
t.Fatalf("stored key lost on case-only rename: %q ok=%v", key, ok)
}
}
// TestEditProviderRenameMigratesFreshlyCapturedKey: replacing the key AND
// renaming in one edit — the caller captures under the CURRENT name and the
// rename migration moves it, so the new key lands under the new name.
func TestEditProviderRenameMigratesFreshlyCapturedKey(t *testing.T) {
dir := t.TempDir()
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
path := filepath.Join(dir, "config.json")
writeConfigFixture(t, path, FileConfig{
ActiveProvider: "gw",
Providers: []ProviderProfile{
{Name: "gw", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://gw.example.com/v1", APIKeyEnv: "GW_KEY", Model: "m1"},
},
}, 0o600)
store, err := ProviderKeyStoreAt(dir)
if err != nil {
t.Fatalf("open store: %v", err)
}
// The caller's contract: a replacement key is stored under the CURRENT name
// before EditProvider runs (what SecureProviderProfile does).
if err := store.Set("gw", "sk-new"); err != nil {
t.Fatalf("seed store: %v", err)
}
cfg, err := EditProvider(path, ProviderEdit{Name: "gw", NewName: "gateway", APIKeyStored: true})
if err != nil {
t.Fatalf("EditProvider() error = %v", err)
}
if !cfg.Providers[0].APIKeyStored || cfg.Providers[0].Name != "gateway" {
t.Fatalf("marker/rename wrong: %+v", cfg.Providers[0])
}
if key, ok, _ := store.Get("gateway"); !ok || key != "sk-new" {
t.Fatalf("captured key must migrate to the new name, got %q ok=%v", key, ok)
}
if _, ok, _ := store.Get("gw"); ok {
t.Fatalf("old entry must be cleaned up after migration")
}
}
func TestEditProviderRejectsCollisionAndUnknown(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
before := writeConfigFixture(t, path, FileConfig{
ActiveProvider: "alpha",
Providers: []ProviderProfile{
{Name: "alpha", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://a.example.com/v1", Model: "m1"},
{Name: "beta", ProviderKind: ProviderKindOpenAICompatible, BaseURL: "https://b.example.com/v1", Model: "m2"},
},
}, 0o600)
if _, err := EditProvider(path, ProviderEdit{Name: "alpha", NewName: "BETA"}); err == nil {
t.Fatal("rename onto an existing name must fail")
}
if _, err := EditProvider(path, ProviderEdit{Name: "ghost", Model: "x"}); err == nil {
t.Fatal("editing an unknown provider must fail")
}
after, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config: %v", err)
}
if string(after) != string(before) {
t.Fatalf("config was rewritten by a rejected edit")
}
}
+28 -4
View File
@@ -14,6 +14,7 @@ import (
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providerhealth"
"github.com/Gitlawb/zero/internal/redaction"
)
@@ -143,12 +144,19 @@ func providerConfigCheck(profile config.ProviderProfile) Check {
// Report credential PRESENCE, never the value. Reported under a non-sensitive
// key ("credentialConfigured"): the prior "apiKey" key was itself sensitive, so
// check()'s redaction scrubbed the indicator to [REDACTED] — making "set"/"not
// set" invisible. A profile can authenticate via a raw auth-header value instead
// of APIKey, and both are trimmed so a whitespace-only value reads "not set"
// (matching ProviderSnapshot.APIKeySet).
// set" invisible. HasConfiguredCredential is the shared definition of
// "key-authed" (inline key, raw auth header, or a key in the encrypted
// credential store), matching ProviderSnapshot.APIKeySet — checking only the
// inline fields made doctor and `zero providers list` disagree about the
// same stored-key profile. A stored OAuth login counts too: a keyless
// token-login provider (e.g. ChatGPT) is fully able to make requests and
// must not fail the one tool meant to verify setup.
credential := "not set"
if strings.TrimSpace(profile.APIKey) != "" || strings.TrimSpace(profile.AuthHeaderValue) != "" {
switch {
case profile.HasConfiguredCredential():
credential = "set"
case providerHasStoredOAuthLogin(profile):
credential = "oauth login"
}
details := map[string]any{
"name": profile.Name,
@@ -169,6 +177,22 @@ func providerConfigCheck(profile config.ProviderProfile) Check {
return check("provider.config", "Provider config", StatusPass, fmt.Sprintf("Provider config loaded for %s.", providerName(profile)), details)
}
// providerHasStoredOAuthLogin reports whether the token store holds a login for
// any of the profile's candidates — the same candidate set and normalized-key
// matching the runtime resolver uses. Errors degrade to false.
func providerHasStoredOAuthLogin(profile config.ProviderProfile) bool {
candidates := profile.OAuthLoginCandidates()
if len(candidates) == 0 {
return false
}
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
return false
}
_, _, ok := oauth.FirstStored(store, candidates)
return ok
}
// localProviderBaseURL reports whether the configured base_url is a loopback host
// (a keyless local provider like Ollama/LM Studio), which needs no API key.
func localProviderBaseURL(baseURL string) bool {
+10 -2
View File
@@ -38,8 +38,16 @@ func ValidateKey(key string) error {
return nil
}
// ProviderKey builds the store key for a provider login.
func ProviderKey(name string) string { return KeyPrefixProvider + name }
// ProviderKey builds the store key for a provider login, normalizing the name
// to lower case. Every write (Manager.Login, the ChatGPT flow) and every
// lookup (FirstStored, GetFresh, logout, status filters) funnels through here,
// so normalizing at this one choke point keeps them symmetric: without it,
// `zero auth login xAI` stored "provider:xAI" while the profile scaffolded for
// it looked up "provider:xai" case-sensitively — a fresh, successful login
// that was invisible to the runtime.
func ProviderKey(name string) string {
return KeyPrefixProvider + strings.ToLower(strings.TrimSpace(name))
}
// FirstStored returns the token and its ProviderKey for the FIRST candidate name
// that has a token in the store, with ok=false when none do. Callers pass
+17
View File
@@ -126,3 +126,20 @@ func TestResolveStorePathHonorsOverride(t *testing.T) {
t.Fatalf("path = %q, want %q", path, override)
}
}
// TestProviderKeyNormalizesCase: every provider-login write and lookup funnels
// through ProviderKey, so `zero auth login xAI` and the scaffolded profile's
// candidate "xai" must resolve to the SAME store entry — the raw-typed casing
// previously produced an invisible login.
func TestProviderKeyNormalizesCase(t *testing.T) {
if got := ProviderKey(" xAI "); got != "provider:xai" {
t.Fatalf("ProviderKey must trim and lower-case, got %q", got)
}
store, _ := newTestStore(t)
if err := store.Save(ProviderKey("xAI"), Token{AccessToken: "tok"}); err != nil {
t.Fatalf("save: %v", err)
}
if _, key, ok := FirstStored(store, []string{"xai"}); !ok || key != "provider:xai" {
t.Fatalf("candidate \"xai\" must find the mixed-case login, ok=%v key=%q", ok, key)
}
}
+79 -20
View File
@@ -3,6 +3,7 @@ package tui
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -10,8 +11,10 @@ import (
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/credstore"
"github.com/Gitlawb/zero/internal/doctor"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providermodelcatalog"
"github.com/Gitlawb/zero/internal/providers"
"github.com/Gitlawb/zero/internal/redaction"
@@ -493,29 +496,37 @@ func (m model) handleModelCommand(args string) (model, string) {
// picker calls this when a model from a non-active provider is chosen, so the
// picker can list every saved provider and switch across them (like a unified
// provider+model selector). The key is loaded from the encrypted store / env.
func (m model) switchProviderModel(providerName, modelID string) (model, string, tea.Cmd) {
// The returned bool reports whether the switch actually committed — callers
// that branch on the outcome (the provider manager) must use it, never the
// display text: UI copy is not a control-flow contract (a refusal quoting a
// provider name could contain any substring, and rewording the success line
// must not change behavior).
func (m model) switchProviderModel(providerName, modelID string) (model, string, bool, tea.Cmd) {
if m.pending {
return m, "Model\nCannot switch providers while a run is active.", nil
return m, "Model\nCannot switch providers while a run is active.", false, nil
}
if m.newProvider == nil {
return m, "Model\nProvider rebuild is not available for this TUI session.", nil
return m, "Model\nProvider rebuild is not available for this TUI session.", false, nil
}
target, ok := m.savedProviderByName(providerName)
if !ok {
return m, "Model\nunknown provider " + strconv.Quote(providerName), nil
return m, "Model\nunknown provider " + strconv.Quote(providerName), false, nil
}
target = m.profileWithCredential(target)
target.Model = strings.TrimSpace(modelID)
descriptor, hasDescriptor := m.descriptorForProfile(target)
// Gate on the resolved credential, not the APIKeyStored marker: if the stored key
// was deleted/unreadable the marker can still be set, and building a keyless
// provider would only fail later with a 401. Local/no-auth providers need no key.
if strings.TrimSpace(target.APIKey) == "" && strings.TrimSpace(target.AuthHeaderValue) == "" && !(hasDescriptor && descriptor.Local) {
return m, "Model\nprovider " + strconv.Quote(providerName) + " has no usable credential — run setup or `zero auth login " + providerName + "`.", nil
// provider would only fail later with a 401. Local/no-auth providers need no key,
// and a stored OAuth login (e.g. ChatGPT) is a credential too — the profile stays
// keyless on purpose so newProvider attaches the bearer resolver + login key.
if strings.TrimSpace(target.APIKey) == "" && strings.TrimSpace(target.AuthHeaderValue) == "" &&
!(hasDescriptor && descriptor.Local) && !oauthLoginAvailable(target) {
return m, "Model\nprovider " + strconv.Quote(providerName) + " has no usable credential — run setup or `zero auth login " + providerName + "`.", false, nil
}
next, err := m.newProvider(target)
if err != nil {
return m, "Model\n" + redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{target.APIKey}}), nil
return m, "Model\n" + redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{target.APIKey}}), false, nil
}
m.provider = next
m.providerProfile = target
@@ -543,32 +554,80 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string,
if warn := m.visionDropWarning(); warn != "" {
status += "\n" + warn
}
return m, status, tea.Batch(cmds...)
return m, status, true, tea.Batch(cmds...)
}
// profileWithCredential fills a profile's APIKey for provider construction the same
// way the runtime resolves it: a stored key (encrypted credstore), then an env var,
// then a stored OAuth bearer for token-login providers. The config resolver is pure
// (no secret I/O), so a stored-key profile carries an empty APIKey until this runs —
// every place that rebuilds the provider (model switch, provider switch) must call
// this or the request goes out with no key.
// way the runtime resolves it: a stored key (encrypted credstore), then an env var.
// The config resolver is pure (no secret I/O), so a stored-key profile carries an
// empty APIKey until this runs — every place that rebuilds the provider (model
// switch, provider switch) must call this or the request goes out with no key.
//
// A stored OAuth bearer is deliberately NOT copied into APIKey: an inline key makes
// HasConfiguredCredential true, which strips the OAuth login candidates at build
// time, so newProvider would construct the client without the bearer resolver and
// login key — for ChatGPT (Codex) that drops the `chatgpt-account-id` header and
// token refresh, 401-ing every call. Keyless is the correct shape for a token-login
// profile; newProvider resolves the login itself.
func (m model) profileWithCredential(profile config.ProviderProfile) config.ProviderProfile {
if strings.TrimSpace(profile.APIKey) == "" {
if store, err := config.ProviderKeyStore(); err == nil {
if store, err := m.providerKeyStore(); err == nil {
profile = config.ApplyStoredAPIKey(profile, store)
}
}
if strings.TrimSpace(profile.APIKey) == "" && strings.TrimSpace(profile.APIKeyEnv) != "" {
profile.APIKey = strings.TrimSpace(os.Getenv(profile.APIKeyEnv))
}
if descriptor, ok := m.descriptorForProfile(profile); ok && strings.TrimSpace(profile.APIKey) == "" && descriptor.OAuth && !descriptor.OAuthMintsKey {
if token := oauthStoredToken(m.ctx, descriptor.ID); token != "" {
profile.APIKey = token
}
}
return profile
}
// providerKeyStore opens the credential store co-located with THIS session's
// config path — the store every TUI write path (SecureProviderProfile capture,
// rename migration, delete) uses. Reading from the default-path store instead
// makes a stored key invisible whenever userConfigPath is non-default, so the
// switch gate rejects a provider whose key is right there beside its config.
// Ephemeral sessions without a config path fall back to the default store.
func (m model) providerKeyStore() (*credstore.Store, error) {
return providerKeyStoreForPath(m.userConfigPath)
}
func providerKeyStoreForPath(configPath string) (*credstore.Store, error) {
if strings.TrimSpace(configPath) != "" {
return config.ProviderKeyStoreAt(filepath.Dir(configPath))
}
return config.ProviderKeyStore()
}
// oauthLoginAvailable reports whether a stored OAuth login exists for any of the
// profile's login candidates — the SAME candidate set the runtime bearer resolver
// uses (profile name, then catalog id, only for keyless profiles), so the switch
// gate and the runtime can never disagree about whether a login counts.
func oauthLoginAvailable(profile config.ProviderProfile) bool {
_, ok := oauthLoginName(profile)
return ok
}
// oauthLoginName resolves WHICH stored login serves this profile — the same
// FirstStored selection the runtime makes. User-facing hints must name this
// entry, not the profile: after a rename ({name:"codex", catalogID:"chatgpt"})
// the token lives under the catalog id, and `zero auth logout codex` would
// delete nothing while the real login stays behind.
func oauthLoginName(profile config.ProviderProfile) (string, bool) {
candidates := profile.OAuthLoginCandidates()
if len(candidates) == 0 {
return "", false
}
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
return "", false
}
_, key, ok := oauth.FirstStored(store, candidates)
if !ok {
return "", false
}
return strings.TrimPrefix(key, oauth.KeyPrefixProvider), true
}
func (m model) savedProviderByName(name string) (config.ProviderProfile, bool) {
name = strings.TrimSpace(name)
for _, profile := range m.savedProviders {
+13 -2
View File
@@ -419,12 +419,23 @@ func (m model) providerText() string {
}
snapshot := zerocommands.ProviderSnapshotFromProfile(m.providerProfile, true)
// A keyless profile backed by a stored OAuth login (e.g. ChatGPT) is fully
// authenticated — without this, /provider status showed a WORKING provider
// as "api key: not set" with a warning, unlike `zero providers list` which
// already fills OAuthLogin.
if !snapshot.APIKeySet {
snapshot.OAuthLogin = oauthLoginAvailable(m.providerProfile)
}
credentialState := apiKeyState(snapshot.APIKeySet)
if !snapshot.APIKeySet && snapshot.OAuthLogin {
credentialState = "oauth login"
}
profileLines = append(profileLines,
"active: "+boolText(snapshot.Active),
"kind: "+displayValue(snapshot.ProviderKind, "unknown"),
"api model: "+displayValue(snapshot.APIModel, "unknown"),
"base url: "+displayValue(snapshot.BaseURL, "default"),
"api key: "+apiKeyState(snapshot.APIKeySet),
"api key: "+credentialState,
)
if snapshot.Message != "" {
profileLines = append(profileLines, "provider status: "+snapshot.Status+" - "+snapshot.Message)
@@ -432,7 +443,7 @@ func (m model) providerText() string {
status := commandStatusOK
actionLines := providerNextActionLines(m.providerProfile, snapshot, m.providerName)
if providerCredentialRequired(m.providerProfile, snapshot.ProviderKind) && !providerProfileHasCredential(m.providerProfile) {
if providerCredentialRequired(m.providerProfile, snapshot.ProviderKind) && !providerProfileHasCredential(m.providerProfile) && !snapshot.OAuthLogin {
status = commandStatusWarning
}
return renderCommandOutput(commandOutput{
+2 -2
View File
@@ -79,9 +79,9 @@ type parsedCommand struct {
var commandDefinitions = []commandDefinition{
{
name: "/provider",
usage: "/provider [status]",
usage: "/provider [add|status]",
group: commandGroupModel,
description: "Open provider setup.",
description: "Manage providers: activate, add, edit, delete.",
kind: commandProvider,
},
{
+1 -1
View File
@@ -45,7 +45,7 @@ func TestFormatCommandHelpLinesGroupsCommandsByStableOrder(t *testing.T) {
for _, want := range []string{
"model:",
" /provider [status] - Open provider setup.",
" /provider [add|status] - Manage providers: activate, add, edit, delete.",
" /model [list|id] - Show or switch the active model.",
" /effort [list|low|medium|high|auto] - Show or set reasoning effort for supported models.",
"session:",
+19 -7
View File
@@ -1104,6 +1104,10 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.applyProviderWizardOAuth(msg)
case providerWizardDeviceCodeMsg:
return m.applyProviderWizardDeviceCode(msg)
case providerManagerCredsMsg:
return m.applyProviderManagerCreds(msg)
case providerManagerCleanupMsg:
return m.applyProviderManagerCleanup(msg)
case clipboardReadMsg:
// Result of a right-click paste. Insert on success; surface a brief
// status if the clipboard couldn't be read (e.g. no clipboard utility on
@@ -1230,8 +1234,10 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.resolvePermission(permissionDecisionDeny)
}
if m.providerWizard != nil {
m.providerWizard = nil
return m, nil
// Delegate so multi-level surfaces (provider manager list → edit →
// field, manage-key step) can walk BACK one level; the wizard's own
// handler closes the overlay for the single-level steps.
return m.handleProviderWizardKey(msg)
}
if m.mcpAddWizard != nil {
m.mcpAddWizard = nil
@@ -3762,7 +3768,7 @@ func (m model) choosePicker() (tea.Model, tea.Cmd) {
text := ""
if owner := strings.TrimSpace(item.OwnerProvider); owner != "" && !strings.EqualFold(owner, strings.TrimSpace(m.providerName)) {
// A model from another saved provider: switch provider + model together.
m, text, cmd = m.switchProviderModel(owner, item.Value)
m, text, _, cmd = m.switchProviderModel(owner, item.Value)
} else {
m, text = m.handleModelCommand(item.Value)
}
@@ -3955,14 +3961,20 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
case commandSandboxSetup:
return m.startSandboxSetupCommand(command.text)
case commandProvider:
if strings.TrimSpace(command.text) == "" {
arg := strings.ToLower(strings.TrimSpace(command.text))
if arg == "" || arg == "add" {
if m.pending {
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: pickerBusyText(command.name)})
return m, nil
}
m.providerWizard = m.newProviderWizard()
m.clearSuggestions()
return m, nil
// Bare /provider opens the list-first manager over the saved
// providers; /provider add jumps straight into the add wizard.
if arg == "add" {
m.providerWizard = m.newProviderWizard()
m.clearSuggestions()
return m, nil
}
return m.openProviderManager()
}
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.providerText()})
return m, nil
+7
View File
@@ -586,6 +586,13 @@ func (m model) applySetupOAuth(msg setupOAuthMsg) (tea.Model, tea.Cmd) {
if msg.apiKey != "" {
m.setup.apiKey.SetValue(msg.apiKey)
}
if msg.tokenLogin {
// Early persist on the Update goroutine (see persistOAuthLoginProvider's
// threading contract) so quitting setup after the login doesn't lose it;
// completeSetup persists the full profile with the chosen model anyway,
// so a failure here is recoverable and not fatal to setup.
_ = persistOAuthLoginProvider(m.setup.configPath, msg.providerID)
}
m.setup.oauthErr = ""
m.setup.err = ""
m.setup.oauthDevice = false
+1 -1
View File
@@ -388,7 +388,7 @@ func (m model) modelPickerDiscoveryCmds() tea.Cmd {
// OAuth bearer for token-login providers (e.g. xAI).
func (m model) modelPickerProviderDiscoveryCmd(descriptor providercatalog.Descriptor, profile config.ProviderProfile) tea.Cmd {
authed := profile
if store, err := config.ProviderKeyStore(); err == nil {
if store, err := m.providerKeyStore(); err == nil {
authed = config.ApplyStoredAPIKey(authed, store)
}
key := strings.TrimSpace(authed.APIKey)
+77 -3
View File
@@ -14,6 +14,7 @@ import (
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/modelregistry"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/providermodeldiscovery"
"github.com/Gitlawb/zero/internal/zeroruntime"
@@ -965,9 +966,9 @@ func TestSwitchProviderModelWarmsDiscoveryForTheNewProvider(t *testing.T) {
},
})
next, text, cmd := m.switchProviderModel("ollama", "kimi-k2.7-code:cloud")
if !strings.Contains(text, "Switched to ollama") {
t.Fatalf("switch notice = %q, want it to confirm the switch", text)
next, text, ok, cmd := m.switchProviderModel("ollama", "kimi-k2.7-code:cloud")
if !ok || !strings.Contains(text, "Switched to ollama") {
t.Fatalf("switch notice = %q (ok=%v), want a committed switch", text, ok)
}
if next.modelName != "kimi-k2.7-code:cloud" || next.providerName != "ollama" {
t.Fatalf("model/provider not switched: modelName=%q providerName=%q", next.modelName, next.providerName)
@@ -1022,3 +1023,76 @@ func TestNormalizeProfileForProviderCanonicalizesPlaceholderName(t *testing.T) {
}
}
}
// TestSwitchProviderModelUsesOAuthLoginWithoutInliningBearer: switching to a
// token-login provider (ChatGPT) must pass the credential gate on the stored
// OAuth login and hand newProvider a KEYLESS profile. Inlining the bearer as
// APIKey makes HasConfiguredCredential true, which strips the OAuth login
// candidates at build time — the client is then constructed without the bearer
// resolver and login key, dropping token refresh and the Codex
// `chatgpt-account-id` header, so every call 401s.
func TestSwitchProviderModelUsesOAuthLoginWithoutInliningBearer(t *testing.T) {
tokensPath := filepath.Join(t.TempDir(), "oauth-tokens.json")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokensPath)
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
t.Fatalf("oauth store: %v", err)
}
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "bearer-123", TokenType: "Bearer"}); err != nil {
t.Fatalf("save token: %v", err)
}
var built config.ProviderProfile
m := newModel(context.Background(), Options{
ProviderName: "opengateway",
ModelName: "some-model",
Provider: &fakeProvider{},
ProviderProfile: config.ProviderProfile{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", Model: "some-model"},
SavedProviders: []config.ProviderProfile{
{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", Model: "some-model"},
{Name: "chatgpt", CatalogID: "chatgpt", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://chatgpt.com/backend-api/codex", Model: "gpt-5.5"},
},
NewProvider: func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
built = profile
return &fakeProvider{}, nil
},
})
next, text, ok, _ := m.switchProviderModel("chatgpt", "gpt-5.5")
if !ok || !strings.Contains(text, "Switched to chatgpt") {
t.Fatalf("switch should succeed on the stored OAuth login, got %q (ok=%v)", text, ok)
}
if next.providerName != "chatgpt" || next.modelName != "gpt-5.5" {
t.Fatalf("switch did not commit: provider=%q model=%q", next.providerName, next.modelName)
}
if built.APIKey != "" {
t.Fatalf("OAuth bearer must not be inlined as APIKey (suppresses the runtime resolver), got %q", built.APIKey)
}
}
// TestSwitchProviderModelStillRejectsProviderWithNoCredential: with no key, no
// header, no local runtime, and no OAuth login, the gate must keep refusing.
func TestSwitchProviderModelStillRejectsProviderWithNoCredential(t *testing.T) {
tokensPath := filepath.Join(t.TempDir(), "oauth-tokens.json")
t.Setenv("ZERO_OAUTH_TOKENS_PATH", tokensPath)
m := newModel(context.Background(), Options{
ProviderName: "opengateway",
ModelName: "some-model",
Provider: &fakeProvider{},
ProviderProfile: config.ProviderProfile{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", Model: "some-model"},
SavedProviders: []config.ProviderProfile{
{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", Model: "some-model"},
{Name: "chatgpt", CatalogID: "chatgpt", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://chatgpt.com/backend-api/codex", Model: "gpt-5.5"},
},
NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
t.Fatal("newProvider must not run for a credential-less provider")
return nil, nil
},
})
_, text, ok, _ := m.switchProviderModel("chatgpt", "gpt-5.5")
if ok || !strings.Contains(text, "no usable credential") {
t.Fatalf("expected the credential gate to refuse, got %q (ok=%v)", text, ok)
}
}
+828
View File
@@ -0,0 +1,828 @@
// Provider manager: the list-first /provider surface. It shares
// providerWizardState (steps providerWizardStepManage / EditMenu / EditValue)
// so the existing overlay gating, key routing, and mouse plumbing that check
// m.providerWizard cover it without touching those call sites.
//
// UX contract: the list IS the home screen — Enter activates the selected
// provider, `a` opens the add wizard, `e` opens a field-level editor, `d`
// asks inline and deletes. Esc walks back one level and closes from the list.
package tui
import (
"os"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
)
const providerManagerMaxVisible = 10
// providerManagerRow is one saved provider in the list. cred is resolved
// asynchronously (keychain reads shell out to `security` on macOS and must
// never block the render loop); empty means "still checking".
type providerManagerRow struct {
profile config.ProviderProfile
local bool
cred string
}
type providerEditField int
const (
providerEditFieldName providerEditField = iota
providerEditFieldEndpoint
providerEditFieldModel
providerEditFieldAPIKey
providerEditFieldDescription
providerEditFieldSave
)
var providerEditFields = []struct {
field providerEditField
label string
hint string
}{
{providerEditFieldName, "Name", "Renames the profile; a stored key and the active pointer follow automatically."},
{providerEditFieldEndpoint, "Endpoint", "Base URL for the provider's API."},
{providerEditFieldModel, "Model", "Default model for this provider."},
{providerEditFieldAPIKey, "API key", "Enter a new key to replace the stored one; leave empty to keep it."},
{providerEditFieldDescription, "Description", "Optional label shown in the provider list."},
{providerEditFieldSave, "Save", "Persist the changes."},
}
// providerManagerCredsMsg delivers the asynchronously resolved credential
// states for the manager rows. gen guards against a stale probe finishing
// after the manager was reopened with fresh rows.
type providerManagerCredsMsg struct {
gen int
creds map[string]string
}
// openProviderManager opens /provider on the list of saved providers. With
// nothing saved yet there is nothing to manage — fall through to the add
// wizard, which is also the first-run behavior users already know.
func (m model) openProviderManager() (model, tea.Cmd) {
if len(m.savedProviders) == 0 {
m.providerWizard = m.newProviderWizard()
m.clearSuggestions()
return m, nil
}
wizard := &providerWizardState{
step: providerWizardStepManage,
manage: true,
providers: providerWizardProviders(),
}
wizard.refreshModels()
m.providerWizard = wizard
m.clearSuggestions()
return m.reloadProviderManagerRows()
}
// reloadProviderManagerRows rebuilds the list from savedProviders and kicks off
// the async credential probe for the new generation.
func (m model) reloadProviderManagerRows() (model, tea.Cmd) {
if m.providerWizard == nil {
return m, nil
}
// Carry resolved credential states forward for names still present, so a
// single-row mutation doesn't flash every other row back to "checking…"
// while the async probe re-runs.
previous := make(map[string]string, len(m.providerWizard.manageRows))
for _, row := range m.providerWizard.manageRows {
previous[row.profile.Name] = row.cred
}
rows := make([]providerManagerRow, 0, len(m.savedProviders))
for _, profile := range m.savedProviders {
row := providerManagerRow{profile: profile, cred: previous[profile.Name]}
if descriptor, ok := m.descriptorForProfile(profile); ok {
row.local = descriptor.Local
}
rows = append(rows, row)
}
m.providerWizard.manageRows = rows
m.providerWizard.manageCursor = clampInt(m.providerWizard.manageCursor, 0, maxInt(0, len(rows)-1))
// The session's live provider is the truth the user cares about (config's
// activeProvider follows it on every switch).
m.providerWizard.manageActiveName = m.providerName
m.providerWizard.manageCredGen++
return m, providerManagerCredsCmd(m.providerWizard.manageCredGen, rows, m.userConfigPath)
}
// providerManagerCredsCmd resolves each row's credential state off the UI
// goroutine: encrypted-store reads (keychain subprocess on macOS), env lookups,
// and the OAuth token store. Keys are read from the store BESIDE configPath —
// the one the manager's write paths use.
func providerManagerCredsCmd(gen int, rows []providerManagerRow, configPath string) tea.Cmd {
profiles := make([]config.ProviderProfile, len(rows))
locals := make([]bool, len(rows))
for i, row := range rows {
profiles[i] = row.profile
locals[i] = row.local
}
return func() tea.Msg {
store, storeErr := providerKeyStoreForPath(configPath)
var getter config.APIKeyGetter
if storeErr == nil {
getter = store
}
// One token-store snapshot answers every row's OAuth question — a fresh
// store open + full file read per row (×2 candidates) answered the same
// question N times over.
logins := storedOAuthLogins()
creds := make(map[string]string, len(profiles))
for i, profile := range profiles {
creds[profile.Name] = providerManagerCredState(profile, locals[i], getter, logins)
}
return providerManagerCredsMsg{gen: gen, creds: creds}
}
}
// providerManagerCredState classifies how a profile authenticates, surfacing
// broken states ("stored key missing") instead of hiding them — that exact
// state is how a lost keychain entry shows up. A stale APIKeyStored marker
// falls THROUGH to the env/OAuth checks first, matching the runtime's own
// fallback order (profileWithCredential): a provider that will switch fine via
// its env var must not render as broken.
func providerManagerCredState(profile config.ProviderProfile, local bool, store config.APIKeyGetter, logins map[string]bool) string {
if strings.TrimSpace(profile.APIKey) != "" || strings.TrimSpace(profile.AuthHeaderValue) != "" {
return "key set"
}
if profile.APIKeyStored && store != nil {
if key, ok, err := store.Get(profile.Name); err == nil && ok && strings.TrimSpace(key) != "" {
return "key stored"
}
}
if env := strings.TrimSpace(profile.APIKeyEnv); env != "" && strings.TrimSpace(os.Getenv(env)) != "" {
return "env " + env
}
// OAuthLoginCandidates is nil for key-authed profiles, so probe a marker-free
// copy: a stale marker must not hide a usable login.
keyless := profile
keyless.APIKeyStored = false
for _, candidate := range keyless.OAuthLoginCandidates() {
// Store keys are normalized to lower case (oauth.ProviderKey).
if logins[strings.ToLower(strings.TrimSpace(candidate))] {
return "oauth login"
}
}
if local {
return "local"
}
if profile.APIKeyStored {
return "stored key missing"
}
return "no credential"
}
// storedOAuthLogins snapshots which provider logins exist in the token store —
// the same one-read pattern as cli's oauthLoggedInProviders. Errors degrade to
// an empty set.
func storedOAuthLogins() map[string]bool {
logins := map[string]bool{}
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
return logins
}
statuses, err := store.Status(oauth.KeyPrefixProvider)
if err != nil {
return logins
}
for _, status := range statuses {
if status.HasToken {
logins[strings.ToLower(strings.TrimPrefix(status.Key, oauth.KeyPrefixProvider))] = true
}
}
return logins
}
func (m model) applyProviderManagerCreds(msg providerManagerCredsMsg) (model, tea.Cmd) {
if m.providerWizard == nil || !m.providerWizard.manage || msg.gen != m.providerWizard.manageCredGen {
return m, nil
}
for i := range m.providerWizard.manageRows {
if cred, ok := msg.creds[m.providerWizard.manageRows[i].profile.Name]; ok {
m.providerWizard.manageRows[i].cred = cred
}
}
return m, nil
}
// providerWizardManagerStep reports whether the wizard is on one of the
// manager-owned steps (list / edit menu / edit value).
func (wizard *providerWizardState) managerStep() bool {
if wizard == nil {
return false
}
switch wizard.step {
case providerWizardStepManage, providerWizardStepEditMenu, providerWizardStepEditValue:
return true
}
return false
}
func (wizard *providerWizardState) currentManagerRow() (providerManagerRow, bool) {
if wizard == nil || len(wizard.manageRows) == 0 {
return providerManagerRow{}, false
}
wizard.manageCursor = clampInt(wizard.manageCursor, 0, len(wizard.manageRows)-1)
return wizard.manageRows[wizard.manageCursor], true
}
func (m model) handleProviderManagerKey(msg tea.KeyMsg) (model, tea.Cmd) {
wizard := m.providerWizard
switch wizard.step {
case providerWizardStepManage:
return m.handleProviderManageListKey(msg)
case providerWizardStepEditMenu:
return m.handleProviderEditMenuKey(msg)
case providerWizardStepEditValue:
return m.handleProviderEditValueKey(msg)
}
return m, nil
}
func (m model) handleProviderManageListKey(msg tea.KeyMsg) (model, tea.Cmd) {
wizard := m.providerWizard
if wizard.manageDeleting {
switch {
case keyIs(msg, tea.KeyEnter) || strings.EqualFold(keyText(msg), "y"):
return m.deleteManagerSelection()
case keyIs(msg, tea.KeyEsc) || strings.EqualFold(keyText(msg), "n"):
wizard.manageDeleting = false
}
return m, nil
}
switch {
case keyIs(msg, tea.KeyEsc):
m.providerWizard = nil
return m, nil
case keyIs(msg, tea.KeyUp):
m.moveProviderManager(-1)
return m, nil
case keyIs(msg, tea.KeyDown) || keyIs(msg, tea.KeyTab):
m.moveProviderManager(1)
return m, nil
case keyIs(msg, tea.KeyEnter):
return m.activateManagerSelection()
case strings.EqualFold(keyText(msg), "a"):
// Add: hand over to the normal wizard flow; Esc from its first step
// returns here (fromManage-style handling in handleProviderWizardKey).
wizard.step = providerWizardStepMethod
wizard.err = ""
wizard.manageStatus = ""
return m, nil
case strings.EqualFold(keyText(msg), "e"):
if row, ok := wizard.currentManagerRow(); ok {
wizard.beginProviderEdit(row.profile)
}
return m, nil
case strings.EqualFold(keyText(msg), "d"):
if _, ok := wizard.currentManagerRow(); ok {
wizard.manageDeleting = true
wizard.manageStatus = ""
}
return m, nil
}
return m, nil
}
func (m *model) moveProviderManager(delta int) {
wizard := m.providerWizard
count := len(wizard.manageRows)
if count == 0 {
wizard.manageCursor = 0
return
}
wizard.manageCursor = ((wizard.manageCursor+delta)%count + count) % count
wizard.manageStatus = ""
}
// activateManagerSelection makes the selected provider active via the shared
// switch path (persists activeProvider+model, rebuilds the client OAuth-aware,
// exports ZERO_PROVIDER, warms discovery). On success the manager closes and
// the switch notice lands in the transcript; a refusal (busy run, missing
// credential) stays inline so the user keeps their place in the list.
func (m model) activateManagerSelection() (model, tea.Cmd) {
wizard := m.providerWizard
row, ok := wizard.currentManagerRow()
if !ok {
return m, nil
}
if m.pending {
wizard.manageStatus = "Cannot switch providers while a run is active."
return m, nil
}
next, text, switched, cmd := m.switchProviderModel(row.profile.Name, row.profile.Model)
if switched {
next.providerWizard = nil
next.transcript = reduceTranscript(next.transcript, transcriptAction{kind: actionAppendSystem, text: text})
return next, cmd
}
if next.providerWizard != nil {
next.providerWizard.manageStatus = strings.TrimPrefix(text, "Model\n")
}
return next, cmd
}
// deleteManagerSelection removes the confirmed provider: the config write runs
// synchronously (the list must reflect the config the instant the confirm
// resolves), while the stored-key delete and the OAuth-login lookup — a
// keychain subprocess and a token-store read — run in a follow-up tea.Cmd so
// the confirm keypress never stalls the render loop. The OAuth token is
// deliberately kept — logins outlive profiles so re-adding the provider
// doesn't force a browser round-trip; zero auth logout removes it.
func (m model) deleteManagerSelection() (model, tea.Cmd) {
wizard := m.providerWizard
wizard.manageDeleting = false
row, ok := wizard.currentManagerRow()
if !ok {
return m, nil
}
name := row.profile.Name
if strings.TrimSpace(m.userConfigPath) == "" {
wizard.manageStatus = "No user config path — cannot delete."
return m, nil
}
cfg, err := config.RemoveProvider(m.userConfigPath, name)
if err != nil {
wizard.manageStatus = "Delete failed: " + err.Error()
return m, nil
}
// Surgical removal — see saveManagerEdit for why the raw cfg.Providers list
// must not replace the resolved/filtered savedProviders wholesale.
m.savedProviders = removeSavedProvider(m.savedProviders, name)
notes := []string{"Deleted " + name + "."}
if strings.EqualFold(strings.TrimSpace(m.providerName), strings.TrimSpace(name)) {
notes = append(notes, "This session keeps running on it until you switch.")
} else if active := strings.TrimSpace(cfg.ActiveProvider); active != "" && !strings.EqualFold(active, name) {
notes = append(notes, "Active provider: "+active+".")
}
cleanup := providerManagerCleanupCmd(m.userConfigPath, row.profile)
if len(m.savedProviders) == 0 {
m.providerWizard = nil
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Provider\n" + strings.Join(notes, " ")})
return m, cleanup
}
next, cmd := m.reloadProviderManagerRows()
next.providerWizard.manageStatus = strings.Join(notes, " ")
return next, tea.Batch(cmd, cleanup)
}
// removeSavedProvider drops one profile from the in-memory saved list.
func removeSavedProvider(saved []config.ProviderProfile, name string) []config.ProviderProfile {
kept := saved[:0]
for _, profile := range saved {
if strings.EqualFold(strings.TrimSpace(profile.Name), strings.TrimSpace(name)) {
continue
}
kept = append(kept, profile)
}
return kept
}
// providerManagerCleanupMsg reports the off-thread half of a delete: the
// stored-key removal outcome and the OAuth-login hint.
type providerManagerCleanupMsg struct {
notes []string
}
// providerManagerCleanupCmd finishes a delete off the UI goroutine: the
// keychain delete shells out to `security` on macOS and the OAuth-login lookup
// reads the token store — blocking work the confirm keypress must not wait on.
// A failed key delete is surfaced rather than letting a lingering secret read
// as a clean removal.
func providerManagerCleanupCmd(configPath string, profile config.ProviderProfile) tea.Cmd {
name := profile.Name
catalogID := profile.CatalogID
return func() tea.Msg {
notes := []string{}
keyStore, storeErr := providerKeyStoreForPath(configPath)
if storeErr == nil {
_, storeErr = keyStore.Delete(name)
}
if storeErr != nil {
notes = append(notes, "Warning: its stored API key could not be deleted ("+storeErr.Error()+").")
}
if login, ok := oauthLoginName(config.ProviderProfile{Name: name, CatalogID: catalogID}); ok {
notes = append(notes, "OAuth login kept — remove with `zero auth logout "+login+"`.")
}
return providerManagerCleanupMsg{notes: notes}
}
}
func (m model) applyProviderManagerCleanup(msg providerManagerCleanupMsg) (model, tea.Cmd) {
if len(msg.notes) == 0 {
return m, nil
}
extra := strings.Join(msg.notes, " ")
if m.providerWizard != nil && m.providerWizard.manage {
if m.providerWizard.manageStatus != "" {
m.providerWizard.manageStatus += " " + extra
} else {
m.providerWizard.manageStatus = extra
}
return m, nil
}
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: "Provider\n" + extra})
return m, nil
}
// --- edit -------------------------------------------------------------------
func (wizard *providerWizardState) beginProviderEdit(profile config.ProviderProfile) {
wizard.editOriginal = profile
wizard.editDraft = profile
wizard.editDraft.APIKey = "" // key field is enter-to-replace, never prefilled
wizard.editCursor = 0
wizard.err = ""
wizard.manageStatus = ""
wizard.step = providerWizardStepEditMenu
}
func (wizard *providerWizardState) editFieldValue(field providerEditField, draft config.ProviderProfile) string {
switch field {
case providerEditFieldName:
return draft.Name
case providerEditFieldEndpoint:
return draft.BaseURL
case providerEditFieldModel:
return draft.Model
case providerEditFieldAPIKey:
return draft.APIKey
case providerEditFieldDescription:
return draft.Description
}
return ""
}
func (m model) handleProviderEditMenuKey(msg tea.KeyMsg) (model, tea.Cmd) {
wizard := m.providerWizard
switch {
case keyIs(msg, tea.KeyEsc) || keyIs(msg, tea.KeyLeft):
wizard.step = providerWizardStepManage
wizard.err = ""
return m, nil
case keyIs(msg, tea.KeyUp):
wizard.editCursor = ((wizard.editCursor-1)%len(providerEditFields) + len(providerEditFields)) % len(providerEditFields)
return m, nil
case keyIs(msg, tea.KeyDown) || keyIs(msg, tea.KeyTab):
wizard.editCursor = (wizard.editCursor + 1) % len(providerEditFields)
return m, nil
case keyIs(msg, tea.KeyEnter):
entry := providerEditFields[clampInt(wizard.editCursor, 0, len(providerEditFields)-1)]
if entry.field == providerEditFieldSave {
return m.saveManagerEdit()
}
wizard.editField = entry.field
wizard.editBuffer = wizard.editFieldValue(entry.field, wizard.editDraft)
wizard.err = ""
wizard.step = providerWizardStepEditValue
return m, nil
}
return m, nil
}
func (m model) handleProviderEditValueKey(msg tea.KeyMsg) (model, tea.Cmd) {
wizard := m.providerWizard
switch {
case keyIs(msg, tea.KeyEsc):
wizard.step = providerWizardStepEditMenu
wizard.err = ""
return m, nil
case keyIs(msg, tea.KeyEnter):
if err := wizard.commitEditBuffer(); err != "" {
wizard.err = err
return m, nil
}
wizard.err = ""
wizard.step = providerWizardStepEditMenu
return m, nil
case keyCtrl(msg, 'u'):
wizard.editBuffer = ""
wizard.err = ""
return m, nil
case keyBackspace(msg):
if wizard.editBuffer != "" {
runes := []rune(wizard.editBuffer)
wizard.editBuffer = string(runes[:len(runes)-1])
}
wizard.err = ""
return m, nil
case keyText(msg) != "":
for _, r := range keyRunes(msg) {
if r == '\n' || r == '\r' || r == '\t' {
continue
}
wizard.editBuffer += string(r)
}
wizard.err = ""
return m, nil
}
return m, nil
}
// commitEditBuffer validates and folds the edited value into the draft.
// Returns a non-empty error string to keep the user on the field.
func (wizard *providerWizardState) commitEditBuffer() string {
value := strings.TrimSpace(wizard.editBuffer)
switch wizard.editField {
case providerEditFieldName:
if value == "" {
return "name cannot be empty"
}
wizard.editDraft.Name = value
case providerEditFieldEndpoint:
if err := providerWizardEndpointError(value); err != "" {
return err
}
wizard.editDraft.BaseURL = value
case providerEditFieldModel:
if value == "" {
return "model cannot be empty"
}
wizard.editDraft.Model = value
case providerEditFieldAPIKey:
wizard.editDraft.APIKey = strings.TrimSpace(wizard.editBuffer)
case providerEditFieldDescription:
wizard.editDraft.Description = value
}
return ""
}
// saveManagerEdit persists the draft in ONE atomic config write
// (config.EditProvider handles rename — case-only included — field merge,
// verbatim description, the stored-key marker, and key migration together, so
// a failure leaves nothing half-applied). A freshly entered key is captured
// into the encrypted store under the CURRENT name first (EditProvider's rename
// migration then moves it), so config.json never holds it in cleartext. The
// live session follows a rename so ZERO_PROVIDER and the status line never
// point at a name that no longer exists.
func (m model) saveManagerEdit() (model, tea.Cmd) {
wizard := m.providerWizard
if strings.TrimSpace(m.userConfigPath) == "" {
wizard.err = "no user config path — cannot save"
return m, nil
}
oldName := strings.TrimSpace(wizard.editOriginal.Name)
newName := strings.TrimSpace(wizard.editDraft.Name)
if newName == "" {
wizard.err = "name cannot be empty"
return m, nil
}
edit := config.ProviderEdit{
Name: oldName,
NewName: newName,
BaseURL: strings.TrimSpace(wizard.editDraft.BaseURL),
Model: strings.TrimSpace(wizard.editDraft.Model),
Description: wizard.editDraft.Description,
}
if key := strings.TrimSpace(wizard.editDraft.APIKey); key != "" {
captured := config.SecureProviderProfile(config.ProviderProfile{Name: oldName, APIKey: key}, m.userConfigPath)
// On a store failure SecureProviderProfile keeps the inline key, which
// EditProvider then persists (the startup migration re-captures later) —
// the same fail-soft posture as every other capture path.
edit.APIKey = captured.APIKey
edit.APIKeyStored = captured.APIKeyStored
}
if _, err := config.EditProvider(m.userConfigPath, edit); err != nil {
wizard.err = err.Error()
return m, nil
}
// Mirror the edit into the in-memory list surgically. savedProviders was
// seeded from the RESOLVED (project-config layered) and usability-FILTERED
// provider set — substituting the raw user-file list here would drop
// project-contributed providers and resurrect filtered stubs for the session.
m.savedProviders = applySavedProviderEdit(m.savedProviders, oldName, edit)
// Keep the live session's identity in sync with a rename of the provider it
// is running on: the exported ZERO_PROVIDER must resolve for spawned children.
if strings.EqualFold(strings.TrimSpace(m.providerName), oldName) {
m.providerName = newName
m.providerProfile.Name = newName
config.SetActiveProviderEnv(newName)
}
wizard.step = providerWizardStepManage
next, cmd := m.reloadProviderManagerRows()
next.providerWizard.manageStatus = "Updated " + newName + "." + providerEditRestartNote(next.providerName, newName)
return next, cmd
}
// providerEditRestartNote reminds the user when the edited provider is the one
// this session is running on — endpoint/model/key changes only apply to the
// built client after a switch (Enter on the row re-activates and rebuilds).
// liveName is the session's provider AFTER any rename sync, so a single
// comparison against the edited profile's final name suffices.
func providerEditRestartNote(liveName string, editedName string) string {
if strings.EqualFold(strings.TrimSpace(liveName), strings.TrimSpace(editedName)) {
return " Press Enter on it to apply the changes to this session."
}
return ""
}
// applySavedProviderEdit mirrors a persisted config.EditProvider into the
// in-memory saved list without wholesale replacement (see saveManagerEdit).
func applySavedProviderEdit(saved []config.ProviderProfile, oldName string, edit config.ProviderEdit) []config.ProviderProfile {
for index := range saved {
if !strings.EqualFold(strings.TrimSpace(saved[index].Name), strings.TrimSpace(oldName)) {
continue
}
profile := &saved[index]
if newName := strings.TrimSpace(edit.NewName); newName != "" {
profile.Name = newName
}
if baseURL := strings.TrimSpace(edit.BaseURL); baseURL != "" {
profile.BaseURL = baseURL
}
if model := strings.TrimSpace(edit.Model); model != "" {
profile.Model = model
}
if apiKey := strings.TrimSpace(edit.APIKey); apiKey != "" {
profile.APIKey = apiKey
}
if edit.APIKeyStored {
profile.APIKeyStored = true
profile.APIKey = ""
}
profile.Description = strings.TrimSpace(edit.Description)
break
}
return saved
}
// --- rendering ---------------------------------------------------------------
// upsertSavedProviderProfile mirrors a freshly persisted profile into the
// in-memory saved list (replace by name, else append).
func upsertSavedProviderProfile(saved []config.ProviderProfile, profile config.ProviderProfile) []config.ProviderProfile {
for index := range saved {
if strings.EqualFold(strings.TrimSpace(saved[index].Name), strings.TrimSpace(profile.Name)) {
saved[index] = profile
return saved
}
}
return append(saved, profile)
}
func (wizard *providerWizardState) renderManageStep(width int) []string {
rows := wizard.manageRows
lines := []string{}
if wizard.manageStatus != "" {
lines = append(lines, fitStyledLine(zeroTheme.accent.Render(wizard.manageStatus), width), "")
}
if len(rows) == 0 {
lines = append(lines, zeroTheme.faint.Render(" No providers saved — press a to add one."))
return lines
}
wizard.manageCursor = clampInt(wizard.manageCursor, 0, len(rows)-1)
nameWidth := 0
for _, row := range rows {
nameWidth = maxInt(nameWidth, len([]rune(row.profile.Name)))
}
nameWidth = minInt(nameWidth, 28)
maxVisible := minInt(providerManagerMaxVisible, len(rows))
start := selectableListStart(len(rows), maxVisible, wizard.manageCursor)
for offset, row := range rows[start : start+maxVisible] {
index := start + offset
surface := transparentSurface
marker := surface(zeroTheme.faintest).Render(" ")
if index == wizard.manageCursor {
surface = zeroTheme.onSel
marker = surface(zeroTheme.accent).Render("❯ ")
}
active := ""
if strings.EqualFold(strings.TrimSpace(row.profile.Name), strings.TrimSpace(wizard.manageActiveName)) {
active = surface(zeroTheme.accent).Render(" ● active")
}
name := padProviderManagerCell(row.profile.Name, nameWidth)
meta := providerManagerRowMeta(row.profile)
left := marker + surface(zeroTheme.ink).Render(name) + " " + surface(zeroTheme.faint).Render(meta)
right := surface(zeroTheme.faint).Render(providerManagerCredDisplay(row)) + active
gap := width - lipgloss.Width(left) - lipgloss.Width(right)
line := left + surface(zeroTheme.ink).Render(strings.Repeat(" ", maxInt(1, gap))) + right
lines = append(lines, fillPaletteLine(line, width, surface))
}
if row, ok := wizard.currentManagerRow(); ok {
lines = append(lines, zeroTheme.line.Render(strings.Repeat("─", width)))
detail := strings.TrimSpace(row.profile.BaseURL)
if description := strings.TrimSpace(row.profile.Description); description != "" {
if detail != "" {
detail += " — "
}
detail += description
}
if detail == "" {
detail = "(no endpoint)"
}
lines = append(lines, fitStyledLine(zeroTheme.faint.Render(detail), width))
if wizard.manageDeleting {
lines = append(lines, fitStyledLine(zeroTheme.red.Render("Delete "+row.profile.Name+"? This also removes its stored API key. Enter/y confirm · Esc/n cancel"), width))
}
}
return lines
}
// manageActiveName is resolved at render time via the field set in
// reloadProviderManagerRows — kept on the struct so the pure render funcs
// don't need the model.
func providerManagerRowMeta(profile config.ProviderProfile) string {
kind := strings.TrimSpace(string(profile.ProviderKind))
if kind == "" {
kind = strings.TrimSpace(profile.Provider)
}
if catalog := strings.TrimSpace(profile.CatalogID); catalog != "" && !strings.EqualFold(catalog, profile.Name) {
kind = catalog
}
model := strings.TrimSpace(profile.Model)
switch {
case kind != "" && model != "":
return kind + " · " + model
case kind != "":
return kind
default:
return model
}
}
func providerManagerCredDisplay(row providerManagerRow) string {
if row.cred == "" {
return "checking…"
}
return row.cred
}
func padProviderManagerCell(value string, width int) string {
runes := []rune(value)
if len(runes) > width {
if width <= 1 {
return string(runes[:width])
}
return string(runes[:width-1]) + "…"
}
return value + strings.Repeat(" ", width-len(runes))
}
func (wizard *providerWizardState) renderEditMenuStep(width int) []string {
lines := []string{zeroTheme.accent.Render("Edit " + wizard.editOriginal.Name)}
wizard.editCursor = clampInt(wizard.editCursor, 0, len(providerEditFields)-1)
for index, entry := range providerEditFields {
surface := transparentSurface
marker := surface(zeroTheme.faintest).Render(" ")
if index == wizard.editCursor {
surface = zeroTheme.onSel
marker = surface(zeroTheme.accent).Render("❯ ")
}
value := wizard.editFieldValue(entry.field, wizard.editDraft)
display := ""
switch entry.field {
case providerEditFieldAPIKey:
if value != "" {
display = maskedProviderWizardKey(value) + " (new)"
} else if wizard.editOriginal.APIKeyStored {
display = "stored — enter to replace"
} else {
display = "none — enter to add"
}
case providerEditFieldSave:
display = ""
default:
display = displayValue(value, "(empty)")
}
left := marker + surface(zeroTheme.ink).Render(padProviderManagerCell(entry.label, 12))
if display != "" {
left += surface(zeroTheme.faint).Render(display)
}
lines = append(lines, fillPaletteLine(fitStyledLine(left, width), width, surface))
}
entry := providerEditFields[wizard.editCursor]
lines = append(lines, "", fitStyledLine(zeroTheme.faint.Render(entry.hint), width))
return lines
}
func (wizard *providerWizardState) renderEditValueStep(width int) []string {
entry := providerEditFields[clampInt(wizard.editCursor, 0, len(providerEditFields)-1)]
value := wizard.editBuffer
if wizard.editField == providerEditFieldAPIKey {
value = maskedProviderWizardKey(value)
}
prompt := zeroTheme.userPrompt.Render(entry.label + " > ")
if value == "" {
value = zeroTheme.faint.Render("(empty)")
} else {
value = zeroTheme.ink.Render(value)
}
return []string{
zeroTheme.accent.Render("Edit " + wizard.editOriginal.Name + " — " + entry.label),
fitStyledLine(prompt+value, width),
"",
fitStyledLine(zeroTheme.faint.Render(entry.hint), width),
}
}
+647
View File
@@ -0,0 +1,647 @@
package tui
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/oauth"
"github.com/Gitlawb/zero/internal/zeroruntime"
)
// managerTestModel builds a model with two saved providers, a seeded config
// file, and a fake provider factory, then opens /provider on the manager list.
func managerTestModel(t *testing.T) model {
t.Helper()
// Isolate every credential surface the manager touches: XDG redirects the
// default config dir (oauth token store, default credential store location)
// and the file backend keeps the OS keychain out of tests entirely.
home := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", home)
t.Setenv("ZERO_OAUTH_TOKENS_PATH", filepath.Join(home, "oauth-tokens.json"))
t.Setenv("ZERO_CRED_STORAGE", "encrypted-file")
configPath := filepath.Join(t.TempDir(), "config.json")
seed := config.FileConfig{
ActiveProvider: "opengateway",
Providers: []config.ProviderProfile{
{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://gateway.example.com/v1", APIKey: "sk-gw", Model: "mimo-v2.5-pro", Description: "Main gateway"},
{Name: "backup", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://backup.example.com/v1", APIKey: "sk-backup", Model: "backup-model"},
},
}
data, err := json.MarshalIndent(seed, "", " ")
if err != nil {
t.Fatalf("encode seed config: %v", err)
}
if err := os.WriteFile(configPath, data, 0o600); err != nil {
t.Fatalf("write seed config: %v", err)
}
m := newModel(context.Background(), Options{
ProviderName: "opengateway",
ModelName: "mimo-v2.5-pro",
Provider: &fakeProvider{},
ProviderProfile: seed.Providers[0],
SavedProviders: seed.Providers,
UserConfigPath: configPath,
NewProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
return &fakeProvider{}, nil
},
})
m.width = 120
m.height = 40
next, _ := m.openProviderManager()
return next
}
func managerKey(t *testing.T, m model, msg tea.KeyMsg) model {
t.Helper()
next, _ := m.handleProviderWizardKey(msg)
return next
}
func TestOpenProviderManagerListsSavedProviders(t *testing.T) {
m := managerTestModel(t)
if m.providerWizard == nil || m.providerWizard.step != providerWizardStepManage {
t.Fatalf("expected the manager list to open, got %+v", m.providerWizard)
}
if len(m.providerWizard.manageRows) != 2 {
t.Fatalf("expected 2 rows, got %d", len(m.providerWizard.manageRows))
}
rendered := m.providerWizard.render(m.width)
for _, want := range []string{"opengateway", "backup", "active", "Enter activate"} {
if !strings.Contains(rendered, want) {
t.Fatalf("manager render missing %q:\n%s", want, rendered)
}
}
}
func TestOpenProviderManagerFallsBackToWizardWhenEmpty(t *testing.T) {
m := newModel(context.Background(), Options{})
next, _ := m.openProviderManager()
if next.providerWizard == nil || next.providerWizard.step != providerWizardStepMethod {
t.Fatalf("no saved providers should open the add wizard, got %+v", next.providerWizard)
}
}
func TestProviderManagerEnterActivatesSelection(t *testing.T) {
m := managerTestModel(t)
m = managerKey(t, m, testKey(tea.KeyDown)) // move to "backup"
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEnter))
if next.providerWizard != nil {
t.Fatalf("manager should close after a successful switch")
}
if next.providerName != "backup" || next.modelName != "backup-model" {
t.Fatalf("switch did not commit: provider=%q model=%q", next.providerName, next.modelName)
}
persisted := readManagerConfig(t, next.userConfigPath)
if persisted.ActiveProvider != "backup" {
t.Fatalf("activeProvider not persisted, got %q", persisted.ActiveProvider)
}
}
func TestProviderManagerActivateBlockedWhilePending(t *testing.T) {
m := managerTestModel(t)
m.pending = true
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEnter))
if next.providerWizard == nil || next.providerWizard.manageStatus == "" {
t.Fatalf("a pending run must keep the manager open with a status, got %+v", next.providerWizard)
}
if next.providerName != "opengateway" {
t.Fatalf("provider must not switch while pending, got %q", next.providerName)
}
}
func TestProviderManagerDeleteConfirmsAndRemoves(t *testing.T) {
m := managerTestModel(t)
m = managerKey(t, m, testKey(tea.KeyDown)) // select "backup"
m = managerKey(t, m, testKeyText("d"))
if !m.providerWizard.manageDeleting {
t.Fatalf("d must arm the inline delete confirm")
}
// Esc cancels the confirm without closing the manager.
m = managerKey(t, m, testKey(tea.KeyEsc))
if m.providerWizard == nil || m.providerWizard.manageDeleting {
t.Fatalf("Esc must cancel the confirm, keeping the manager open")
}
if got := readManagerConfig(t, m.userConfigPath); len(got.Providers) != 2 {
t.Fatalf("cancelled delete must not touch config, got %d providers", len(got.Providers))
}
m = managerKey(t, m, testKeyText("d"))
next, _ := m.handleProviderWizardKey(testKeyText("y"))
if next.providerWizard == nil {
t.Fatalf("manager should stay open while providers remain")
}
if len(next.savedProviders) != 1 || next.savedProviders[0].Name != "opengateway" {
t.Fatalf("savedProviders not updated: %+v", next.savedProviders)
}
persisted := readManagerConfig(t, next.userConfigPath)
if len(persisted.Providers) != 1 || persisted.Providers[0].Name != "opengateway" {
t.Fatalf("config not updated: %+v", persisted.Providers)
}
if persisted.ActiveProvider != "opengateway" {
t.Fatalf("active must be untouched when a non-active profile is deleted, got %q", persisted.ActiveProvider)
}
if !strings.Contains(next.providerWizard.manageStatus, "Deleted backup") {
t.Fatalf("expected delete status, got %q", next.providerWizard.manageStatus)
}
}
func TestProviderManagerEditModelPersists(t *testing.T) {
m := managerTestModel(t)
m = managerKey(t, m, testKeyText("e"))
if m.providerWizard.step != providerWizardStepEditMenu {
t.Fatalf("e must open the field editor, got step %v", m.providerWizard.step)
}
// Field order: Name, Endpoint, Model, API key, Description, Save.
m = managerKey(t, m, testKey(tea.KeyDown))
m = managerKey(t, m, testKey(tea.KeyDown))
m = managerKey(t, m, testKey(tea.KeyEnter)) // edit Model
if m.providerWizard.step != providerWizardStepEditValue || m.providerWizard.editField != providerEditFieldModel {
t.Fatalf("expected model value editor, got step %v field %v", m.providerWizard.step, m.providerWizard.editField)
}
m.providerWizard.editBuffer = "mimo-v3"
m = managerKey(t, m, testKey(tea.KeyEnter)) // commit field
if m.providerWizard.step != providerWizardStepEditMenu {
t.Fatalf("commit should return to the field menu")
}
// Move to Save (cursor still on Model=index 2; Save is index 5).
m = managerKey(t, m, testKey(tea.KeyDown))
m = managerKey(t, m, testKey(tea.KeyDown))
m = managerKey(t, m, testKey(tea.KeyDown))
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEnter))
if next.providerWizard == nil || next.providerWizard.step != providerWizardStepManage {
t.Fatalf("save should land back on the manager list")
}
persisted := readManagerConfig(t, next.userConfigPath)
if persisted.Providers[0].Model != "mimo-v3" {
t.Fatalf("edited model not persisted: %+v", persisted.Providers[0])
}
if persisted.Providers[0].APIKey != "sk-gw" {
t.Fatalf("unrelated credential must survive the edit: %+v", persisted.Providers[0])
}
}
func TestProviderManagerRenameFollowsLiveSession(t *testing.T) {
m := managerTestModel(t)
m = managerKey(t, m, testKeyText("e"))
m = managerKey(t, m, testKey(tea.KeyEnter)) // edit Name (cursor 0)
m.providerWizard.editBuffer = "gateway-main"
m = managerKey(t, m, testKey(tea.KeyEnter))
// Save is 5 rows below Name.
for range 5 {
m = managerKey(t, m, testKey(tea.KeyDown))
}
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEnter))
if next.providerWizard == nil || next.providerWizard.step != providerWizardStepManage {
t.Fatalf("save should return to the list, got %+v", next.providerWizard)
}
if next.providerName != "gateway-main" {
t.Fatalf("live session name must follow the rename, got %q", next.providerName)
}
persisted := readManagerConfig(t, next.userConfigPath)
if persisted.ActiveProvider != "gateway-main" {
t.Fatalf("activeProvider must follow the rename, got %q", persisted.ActiveProvider)
}
names := []string{}
for _, profile := range persisted.Providers {
names = append(names, profile.Name)
}
if len(persisted.Providers) != 2 || persisted.Providers[0].Name != "gateway-main" {
t.Fatalf("rename not persisted, providers: %v", names)
}
}
func TestProviderManagerEscWalksBackThenCloses(t *testing.T) {
m := managerTestModel(t)
m = managerKey(t, m, testKeyText("e"))
m = managerKey(t, m, testKey(tea.KeyEnter)) // into Name editor
m = managerKey(t, m, testKey(tea.KeyEsc))
if m.providerWizard.step != providerWizardStepEditMenu {
t.Fatalf("Esc from a field editor must return to the field menu")
}
m = managerKey(t, m, testKey(tea.KeyEsc))
if m.providerWizard.step != providerWizardStepManage {
t.Fatalf("Esc from the field menu must return to the list")
}
m = managerKey(t, m, testKey(tea.KeyEsc))
if m.providerWizard != nil {
t.Fatalf("Esc from the list must close the manager")
}
}
func TestProviderManagerAddReturnsToListOnEsc(t *testing.T) {
m := managerTestModel(t)
m = managerKey(t, m, testKeyText("a"))
if m.providerWizard.step != providerWizardStepMethod {
t.Fatalf("a must open the add wizard's method step")
}
m = managerKey(t, m, testKey(tea.KeyEsc))
if m.providerWizard == nil || m.providerWizard.step != providerWizardStepManage {
t.Fatalf("Esc from a manager-opened add flow must return to the list")
}
}
func TestProviderManagerCredsMsgFillsRowsAndIgnoresStale(t *testing.T) {
m := managerTestModel(t)
gen := m.providerWizard.manageCredGen
next, _ := m.applyProviderManagerCreds(providerManagerCredsMsg{gen: gen, creds: map[string]string{
"opengateway": "key set",
"backup": "no credential",
}})
if next.providerWizard.manageRows[0].cred != "key set" || next.providerWizard.manageRows[1].cred != "no credential" {
t.Fatalf("creds not applied: %+v", next.providerWizard.manageRows)
}
// A stale generation must not clobber fresh rows.
next, _ = next.applyProviderManagerCreds(providerManagerCredsMsg{gen: gen - 1, creds: map[string]string{
"opengateway": "stale",
}})
if next.providerWizard.manageRows[0].cred != "key set" {
t.Fatalf("stale creds applied: %+v", next.providerWizard.manageRows[0])
}
}
func readManagerConfig(t *testing.T, path string) config.FileConfig {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read config: %v", err)
}
var cfg config.FileConfig
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("decode config: %v", err)
}
return cfg
}
// TestProviderManagerEditKeyPersistsStoredMarker: replacing the key of a
// provider that previously had NO stored-key marker (e.g. env-authed) must
// persist apiKeyStored — otherwise the secret sits in the credential store
// while every ApplyStoredAPIKey gate skips it (PR #560 review, P2).
func TestProviderManagerEditKeyPersistsStoredMarker(t *testing.T) {
m := managerTestModel(t)
// Reshape "backup" into an env-authed profile with no marker and no inline key.
seedCfg := readManagerConfig(t, m.userConfigPath)
seedCfg.Providers[1].APIKey = ""
seedCfg.Providers[1].APIKeyEnv = "BACKUP_API_KEY"
data, err := json.MarshalIndent(seedCfg, "", " ")
if err != nil {
t.Fatalf("encode config: %v", err)
}
if err := os.WriteFile(m.userConfigPath, data, 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
m = managerKey(t, m, testKey(tea.KeyDown)) // select "backup"
m = managerKey(t, m, testKeyText("e"))
for range 3 { // Name → Endpoint → Model → API key
m = managerKey(t, m, testKey(tea.KeyDown))
}
m = managerKey(t, m, testKey(tea.KeyEnter))
if m.providerWizard.editField != providerEditFieldAPIKey {
t.Fatalf("expected API key editor, got field %v", m.providerWizard.editField)
}
m.providerWizard.editBuffer = "sk-new-secret"
m = managerKey(t, m, testKey(tea.KeyEnter))
m = managerKey(t, m, testKey(tea.KeyDown)) // Description
m = managerKey(t, m, testKey(tea.KeyDown)) // Save
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEnter))
if next.providerWizard == nil || next.providerWizard.step != providerWizardStepManage {
t.Fatalf("save should return to the list, err=%q", next.providerWizard.err)
}
persisted := readManagerConfig(t, next.userConfigPath)
backup := persisted.Providers[1]
if !backup.APIKeyStored {
t.Fatalf("apiKeyStored marker must persist after a key edit: %+v", backup)
}
if backup.APIKey != "" {
t.Fatalf("cleartext key must never land in config.json: %+v", backup)
}
// The env reference is deliberately preserved as an explicit override: the
// resolver fills APIKey from a SET env var first, and the stored key applies
// only when the env var is absent — locked in here so a future merge change
// can't silently flip the precedence.
if backup.APIKeyEnv != "BACKUP_API_KEY" {
t.Fatalf("APIKeyEnv must survive a key edit as an explicit override, got %q", backup.APIKeyEnv)
}
store, err := config.ProviderKeyStoreAt(filepath.Dir(next.userConfigPath))
if err != nil {
t.Fatalf("open store: %v", err)
}
if key, ok, err := store.Get("backup"); err != nil || !ok || key != "sk-new-secret" {
t.Fatalf("key must be captured into the store beside the config, got key=%q ok=%v err=%v", key, ok, err)
}
}
// TestProviderManagerDescriptionClearPersists: clearing a description must
// actually persist (the upsert merge treats empty as "unchanged" — PR #560, P3).
func TestProviderManagerDescriptionClearPersists(t *testing.T) {
m := managerTestModel(t) // opengateway has description "Main gateway"
m = managerKey(t, m, testKeyText("e"))
for range 4 { // Name → Endpoint → Model → API key → Description
m = managerKey(t, m, testKey(tea.KeyDown))
}
m = managerKey(t, m, testKey(tea.KeyEnter))
if m.providerWizard.editField != providerEditFieldDescription {
t.Fatalf("expected description editor, got field %v", m.providerWizard.editField)
}
m.providerWizard.editBuffer = ""
m = managerKey(t, m, testKey(tea.KeyEnter))
m = managerKey(t, m, testKey(tea.KeyDown)) // Save
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEnter))
if next.providerWizard == nil || next.providerWizard.step != providerWizardStepManage {
t.Fatalf("save should return to the list, err=%q", next.providerWizard.err)
}
persisted := readManagerConfig(t, next.userConfigPath)
if persisted.Providers[0].Description != "" {
t.Fatalf("cleared description must persist, got %q", persisted.Providers[0].Description)
}
if row, ok := next.providerWizard.currentManagerRow(); !ok || row.profile.Description != "" {
t.Fatalf("manager rows must reflect the cleared description: %+v", row.profile)
}
}
// TestProviderManagerDeleteHintNamesActualOAuthLogin: after a rename the token
// lives under the catalog id, not the profile name — the cleanup hint must name
// the entry `zero auth logout` would actually delete (PR #560 review, P3).
func TestProviderManagerDeleteHintNamesActualOAuthLogin(t *testing.T) {
m := managerTestModel(t)
// Reshape "backup" into a renamed OAuth catalog profile: keyless, named
// "codex", backed by a token stored under the chatgpt catalog id.
seedCfg := readManagerConfig(t, m.userConfigPath)
seedCfg.Providers[1] = config.ProviderProfile{Name: "codex", CatalogID: "chatgpt", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://chatgpt.com/backend-api/codex", Model: "gpt-5.5"}
data, err := json.MarshalIndent(seedCfg, "", " ")
if err != nil {
t.Fatalf("encode config: %v", err)
}
if err := os.WriteFile(m.userConfigPath, data, 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
m.savedProviders = seedCfg.Providers
next, _ := m.reloadProviderManagerRows()
m = next
store, err := oauth.NewStore(oauth.StoreOptions{})
if err != nil {
t.Fatalf("oauth store: %v", err)
}
if err := store.Save(oauth.ProviderKey("chatgpt"), oauth.Token{AccessToken: "bearer-123"}); err != nil {
t.Fatalf("save token: %v", err)
}
m = managerKey(t, m, testKey(tea.KeyDown)) // select "codex"
m = managerKey(t, m, testKeyText("d"))
next, cmd := m.handleProviderWizardKey(testKeyText("y"))
if next.providerWizard == nil {
t.Fatalf("manager should stay open")
}
// The stored-key delete and OAuth hint run in a follow-up cmd off the UI
// goroutine; drain it into the model like the runtime would.
next = drainProviderManagerCmds(t, next, cmd)
status := next.providerWizard.manageStatus
if !strings.Contains(status, "zero auth logout chatgpt") {
t.Fatalf("hint must name the stored login (chatgpt), got %q", status)
}
if strings.Contains(status, "logout codex") {
t.Fatalf("hint must not point at a login key that does not exist, got %q", status)
}
}
// TestProviderManagerReadsStoredKeyBesideConfig: the TUI must READ stored keys
// from the same config-adjacent store its write paths use. managerTestModel
// deliberately puts userConfigPath outside XDG_CONFIG_HOME, so a key seeded
// beside the config is invisible to the default-path store — with the old
// default-store reads, the switch gate rejected the provider and the manager
// showed "stored key missing" for a perfectly healthy profile (PR #560, P2).
func TestProviderManagerReadsStoredKeyBesideConfig(t *testing.T) {
m := managerTestModel(t)
// Reshape "backup" into a stored-key profile whose key lives ONLY in the
// store beside userConfigPath.
seedCfg := readManagerConfig(t, m.userConfigPath)
seedCfg.Providers[1].APIKey = ""
seedCfg.Providers[1].APIKeyStored = true
data, err := json.MarshalIndent(seedCfg, "", " ")
if err != nil {
t.Fatalf("encode config: %v", err)
}
if err := os.WriteFile(m.userConfigPath, data, 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
store, err := config.ProviderKeyStoreAt(filepath.Dir(m.userConfigPath))
if err != nil {
t.Fatalf("open store: %v", err)
}
if err := store.Set("backup", "sk-beside-config"); err != nil {
t.Fatalf("seed key: %v", err)
}
m.savedProviders = seedCfg.Providers
next, _ := m.reloadProviderManagerRows()
m = next
// The async credential probe must find the key in the config-adjacent store.
cmd := providerManagerCredsCmd(m.providerWizard.manageCredGen, m.providerWizard.manageRows, m.userConfigPath)
msg, ok := cmd().(providerManagerCredsMsg)
if !ok {
t.Fatalf("expected creds msg")
}
if msg.creds["backup"] != "key stored" {
t.Fatalf("creds probe must read the store beside the config, got %q", msg.creds["backup"])
}
// And the switch path must load the key instead of rejecting the provider.
var built config.ProviderProfile
m.newProvider = func(profile config.ProviderProfile) (zeroruntime.Provider, error) {
built = profile
return &fakeProvider{}, nil
}
m = managerKey(t, m, testKey(tea.KeyDown)) // select "backup"
next, _ = m.handleProviderWizardKey(testKey(tea.KeyEnter))
if next.providerWizard != nil {
t.Fatalf("switch must succeed on a config-adjacent stored key, status=%q", next.providerWizard.manageStatus)
}
if built.APIKey != "sk-beside-config" {
t.Fatalf("switch must load the key from the store beside the config, got %q", built.APIKey)
}
}
// drainProviderManagerCmds executes a manager action's follow-up cmds (batch
// or single) and applies any providerManagerCleanupMsg, mirroring what the
// bubbletea runtime does with the returned tea.Cmd.
func drainProviderManagerCmds(t *testing.T, m model, cmd tea.Cmd) model {
t.Helper()
var apply func(c tea.Cmd)
apply = func(c tea.Cmd) {
if c == nil {
return
}
switch msg := c().(type) {
case tea.BatchMsg:
for _, sub := range msg {
apply(sub)
}
case providerManagerCleanupMsg:
m, _ = m.applyProviderManagerCleanup(msg)
}
}
apply(cmd)
return m
}
// TestProviderManagerCaseOnlyRenameUpdatesInPlace: groq → Groq through the
// editor must rename in place — the old EqualFold-skip + case-sensitive upsert
// combination appended a duplicate profile (review finding, empirically shown).
func TestProviderManagerCaseOnlyRenameUpdatesInPlace(t *testing.T) {
m := managerTestModel(t)
m = managerKey(t, m, testKeyText("e"))
m = managerKey(t, m, testKey(tea.KeyEnter)) // edit Name (cursor 0)
m.providerWizard.editBuffer = "OpenGateway"
m = managerKey(t, m, testKey(tea.KeyEnter))
for range 5 {
m = managerKey(t, m, testKey(tea.KeyDown))
}
next, _ := m.handleProviderWizardKey(testKey(tea.KeyEnter)) // Save
if next.providerWizard == nil || next.providerWizard.step != providerWizardStepManage {
t.Fatalf("save should return to the list, err=%q", next.providerWizard.err)
}
persisted := readManagerConfig(t, next.userConfigPath)
if len(persisted.Providers) != 2 {
t.Fatalf("case-only rename must not duplicate: %d providers", len(persisted.Providers))
}
if persisted.Providers[0].Name != "OpenGateway" || persisted.Providers[0].APIKey != "sk-gw" {
t.Fatalf("in-place update wrong: %+v", persisted.Providers[0])
}
if persisted.ActiveProvider != "OpenGateway" {
t.Fatalf("active must follow, got %q", persisted.ActiveProvider)
}
if len(next.savedProviders) != 2 || next.savedProviders[0].Name != "OpenGateway" {
t.Fatalf("in-memory list must mirror the rename: %+v", next.savedProviders)
}
}
// TestProviderManagerMutationsKeepResolvedProviders: savedProviders is seeded
// from the RESOLVED+FILTERED provider set — a manager delete/edit must mutate
// it surgically, not replace it with the raw user-config list (which would drop
// project-config-contributed providers for the session).
func TestProviderManagerMutationsKeepResolvedProviders(t *testing.T) {
m := managerTestModel(t)
// Simulate a project-config-contributed provider: present in the session's
// resolved list, absent from the user config.json the writers operate on.
projectProvider := config.ProviderProfile{Name: "team-gateway", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://team.example.com/v1", APIKey: "sk-team", Model: "team-model"}
m.savedProviders = append(m.savedProviders, projectProvider)
next, _ := m.reloadProviderManagerRows()
m = next
// Delete "backup" (a user-config profile): team-gateway must survive.
m = managerKey(t, m, testKey(tea.KeyDown)) // select "backup"
m = managerKey(t, m, testKeyText("d"))
next, _ = m.handleProviderWizardKey(testKeyText("y"))
names := []string{}
for _, profile := range next.savedProviders {
names = append(names, profile.Name)
}
if len(next.savedProviders) != 2 || names[0] != "opengateway" || names[1] != "team-gateway" {
t.Fatalf("project-contributed provider lost by delete: %v", names)
}
// Edit opengateway's model: team-gateway must still survive.
m = next
m.providerWizard.manageCursor = 0
m = managerKey(t, m, testKeyText("e"))
for range 2 {
m = managerKey(t, m, testKey(tea.KeyDown))
}
m = managerKey(t, m, testKey(tea.KeyEnter)) // Model field
m.providerWizard.editBuffer = "mimo-next"
m = managerKey(t, m, testKey(tea.KeyEnter))
for range 3 {
m = managerKey(t, m, testKey(tea.KeyDown))
}
next, _ = m.handleProviderWizardKey(testKey(tea.KeyEnter)) // Save
names = names[:0]
for _, profile := range next.savedProviders {
names = append(names, profile.Name)
}
if len(next.savedProviders) != 2 || names[1] != "team-gateway" {
t.Fatalf("project-contributed provider lost by edit: %v", names)
}
if next.savedProviders[0].Model != "mimo-next" {
t.Fatalf("edit not mirrored in-memory: %+v", next.savedProviders[0])
}
}
// TestProviderManagerEscWalksBackFromDeepAddFlow: Esc anywhere inside a
// manager-launched add flow returns to the provider list ("Esc walks back one
// level"), never destroying the manager context — previously only the first
// step walked back and every deeper step hard-closed the overlay.
func TestProviderManagerEscWalksBackFromDeepAddFlow(t *testing.T) {
m := managerTestModel(t)
m = managerKey(t, m, testKeyText("a")) // add flow, method step
m = managerKey(t, m, testKey(tea.KeyEnter))
if m.providerWizard == nil || m.providerWizard.step == providerWizardStepMethod {
t.Fatalf("expected to advance past the method step, got %+v", m.providerWizard)
}
deepStep := m.providerWizard.step
m = managerKey(t, m, testKey(tea.KeyEsc))
if m.providerWizard == nil {
t.Fatalf("Esc on step %v must not destroy the manager overlay", deepStep)
}
if m.providerWizard.step != providerWizardStepManage {
t.Fatalf("Esc must return to the provider list, got step %v", m.providerWizard.step)
}
}
// TestProviderManagerActivationUsesStructuredResult: a refusal whose display
// text happens to contain "Switched to" (a provider literally named that) must
// NOT close the manager as a success — the outcome bool, not UI copy, decides.
func TestProviderManagerActivationUsesStructuredResult(t *testing.T) {
m := managerTestModel(t)
seedCfg := readManagerConfig(t, m.userConfigPath)
seedCfg.Providers[1] = config.ProviderProfile{Name: "Switched to prod", ProviderKind: config.ProviderKindOpenAICompatible, BaseURL: "https://p.example.com/v1", Model: "m"}
data, err := json.MarshalIndent(seedCfg, "", " ")
if err != nil {
t.Fatalf("encode config: %v", err)
}
if err := os.WriteFile(m.userConfigPath, data, 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
m.savedProviders = seedCfg.Providers
next, _ := m.reloadProviderManagerRows()
m = next
m = managerKey(t, m, testKey(tea.KeyDown)) // select the credential-less provider
next, _ = m.handleProviderWizardKey(testKey(tea.KeyEnter))
if next.providerWizard == nil {
t.Fatalf("a refused switch must keep the manager open even when the refusal text contains \"Switched to\"")
}
if !strings.Contains(next.providerWizard.manageStatus, "no usable credential") {
t.Fatalf("expected the refusal inline, got %q", next.providerWizard.manageStatus)
}
if next.providerName != "opengateway" {
t.Fatalf("provider must not switch, got %q", next.providerName)
}
}
// TestProviderManagerCredStateFallsThroughStaleMarker: a stale APIKeyStored
// marker with a SET env var must render the env credential (the runtime falls
// back the same way and switches fine), not "stored key missing".
func TestProviderManagerCredStateFallsThroughStaleMarker(t *testing.T) {
t.Setenv("STALE_MARKER_KEY", "sk-env")
profile := config.ProviderProfile{Name: "gw", APIKeyStored: true, APIKeyEnv: "STALE_MARKER_KEY"}
state := providerManagerCredState(profile, false, nil, map[string]bool{})
if state != "env STALE_MARKER_KEY" {
t.Fatalf("stale marker must fall through to the env credential, got %q", state)
}
// Marker with neither store entry nor fallback: the broken state still shows.
t.Setenv("STALE_MARKER_KEY", "")
state = providerManagerCredState(profile, false, nil, map[string]bool{})
if state != "stored key missing" {
t.Fatalf("expected stored key missing with no fallback, got %q", state)
}
}
+150 -2
View File
@@ -50,6 +50,18 @@ func (m model) applyProviderWizardOAuth(msg providerWizardOAuthMsg) (model, tea.
if msg.apiKey != "" {
m.providerWizard.apiKey = msg.apiKey
}
if msg.tokenLogin {
// Persist the profile HERE, on the Update goroutine (see
// persistOAuthLoginProvider for the threading contract), and only mirror
// it into the in-memory saved list when the write succeeded — a mirror of
// a profile that never reached disk shows a phantom provider that works
// this session and silently vanishes on restart.
if err := persistOAuthLoginProvider(m.userConfigPath, msg.providerID); err != nil {
m.providerWizard.oauthErr = "Signed in, but the provider profile could not be saved: " + redaction.ErrorMessage(err, redaction.Options{})
return m, nil
}
m.savedProviders = appendOAuthLoginProfile(m.savedProviders, msg.providerID)
}
// OAuth succeeded (key minted, or a refreshable token stored for the runtime
// resolver). Skip the endpoint/credential steps and go straight to model
// selection.
@@ -141,6 +153,48 @@ func runProviderChatGPTLogin() error {
return store.Save(oauth.ProviderKey("chatgpt"), token)
}
// appendOAuthLoginProfile mirrors the profile persistOAuthLoginProvider wrote to
// config into an in-memory saved-provider list, skipping when a profile already
// serves the catalog entry (by name or catalog id).
func appendOAuthLoginProfile(saved []config.ProviderProfile, providerID string) []config.ProviderProfile {
descriptor, ok := providercatalog.Get(providerID)
if !ok {
return saved
}
for _, profile := range saved {
if strings.EqualFold(strings.TrimSpace(profile.CatalogID), descriptor.ID) ||
strings.EqualFold(strings.TrimSpace(profile.Name), descriptor.ID) {
return saved
}
}
return append(saved, config.ProviderProfile{
Name: descriptor.ID,
ProviderKind: providerWizardProviderKind(descriptor),
CatalogID: descriptor.ID,
BaseURL: descriptor.DefaultBaseURL,
Model: descriptor.DefaultModel,
})
}
// persistOAuthLoginProvider guarantees the stored login is reachable from the
// provider list even when the wizard is abandoned before the model step: the
// token alone is invisible — no profile means no entry in /provider, /model, or
// `zero providers use`, which reads as the login having vanished.
//
// MUST run on the Update goroutine (call it from a message handler, never from
// inside a tea.Cmd): config.json writes are unlocked read-modify-writes, and a
// background login — a device-code poll can complete minutes later — racing a
// UI-thread write would silently clobber it (lost update). The returned error
// lets the caller surface a failed write instead of showing a provider that
// never reached disk.
func persistOAuthLoginProvider(configPath string, catalogID string) error {
if strings.TrimSpace(configPath) == "" {
return nil
}
_, err := config.EnsureCatalogProvider(configPath, catalogID)
return err
}
// buildOAuthPresetEnv layers the process env with the preset opt-in so a
// `zero` launch from a TUI session can use the baked-in ChatGPT client_id
// without requiring the user to export ZERO_OAUTH_ALLOW_PRESETS themselves.
@@ -249,6 +303,12 @@ const (
providerWizardStepCredential
providerWizardStepModel
providerWizardStepDone
// Manager steps (provider_manager.go): the list-first /provider surface.
// They live on the same state struct so every overlay gate, key route, and
// mouse hook that already checks m.providerWizard covers them for free.
providerWizardStepManage
providerWizardStepEditMenu
providerWizardStepEditValue
)
// providerWizardMethodOption is a row in the "How do you want to connect?" step.
@@ -319,6 +379,20 @@ type providerWizardState struct {
oauthDevice bool
deviceUserCode string
deviceVerificationURI string
// Manager state (provider_manager.go): the list-first /provider surface.
manage bool
manageRows []providerManagerRow
manageCursor int
manageDeleting bool
manageStatus string
manageCredGen int
manageActiveName string
// Edit state: field-level editor for one saved profile.
editOriginal config.ProviderProfile
editDraft config.ProviderProfile
editCursor int
editField providerEditField
editBuffer string
}
func (m model) newProviderWizard() *providerWizardState {
@@ -624,6 +698,26 @@ func (m model) handleProviderWizardKey(msg tea.KeyMsg) (model, tea.Cmd) {
if m.providerWizard == nil {
return m, nil
}
// A manager-launched add flow honors the manager's "Esc walks back one
// level" contract on EVERY step (not just the first): Esc returns to the
// provider list instead of destroying the overlay — and with it the user's
// cursor and status — from a step deep in the flow. An in-flight OAuth login
// is abandoned the same way; bumping the attempt id makes its late result
// stale so applyProviderWizardOAuth drops it.
if m.providerWizard.manage && !m.providerWizard.managerStep() && keyIs(msg, tea.KeyEsc) {
wizard := m.providerWizard
if wizard.oauthPending {
wizard.oauthPending = false
wizard.oauthAttemptID++
}
wizard.oauthDevice = false
wizard.deviceUserCode = ""
wizard.deviceVerificationURI = ""
wizard.err = ""
wizard.oauthErr = ""
wizard.step = providerWizardStepManage
return m, nil
}
// While a browser/device OAuth login is in flight, ignore input except Esc,
// which abandons the wizard (the background flow times out and is dropped).
if m.providerWizard.oauthPending {
@@ -632,6 +726,10 @@ func (m model) handleProviderWizardKey(msg tea.KeyMsg) (model, tea.Cmd) {
}
return m, nil
}
// Manager steps (list / edit) own their keys entirely.
if m.providerWizard.managerStep() {
return m.handleProviderManagerKey(msg)
}
// Manage-key step: keep/replace/remove a provider's stored key.
if m.providerWizard.step == providerWizardStepManageKey {
switch {
@@ -799,6 +897,13 @@ func (m model) handleProviderWizardPaste(content string) (model, tea.Cmd) {
m.providerWizard.appendAPIKey([]rune(content))
case providerWizardStepModel:
m.providerWizard.appendModelSearch([]rune(content))
case providerWizardStepEditValue:
for _, r := range content {
if r == '\n' || r == '\r' || r == '\t' {
continue
}
m.providerWizard.editBuffer += string(r)
}
}
return m, nil
}
@@ -1012,6 +1117,9 @@ func (m model) applyProviderWizard() (model, tea.Cmd) {
m.providerProfile = profile
m.providerName = profile.Name
m.modelName = profile.Model
// Keep the in-memory saved list in sync so the provider manager and /model
// picker show the new profile without a restart.
m.savedProviders = upsertSavedProviderProfile(m.savedProviders, profile)
// Keep sub-agent child processes on the same provider we just switched to —
// same as the /model and /provider switch paths (command_center.go). Without
// this, a ZERO_PROVIDER exported by an earlier switch stays pointing at the
@@ -1131,13 +1239,23 @@ func (wizard *providerWizardState) render(width int) string {
lines = append(lines, wizard.renderModelStep(innerWidth)...)
case providerWizardStepDone:
lines = append(lines, wizard.renderDoneStep(innerWidth)...)
case providerWizardStepManage:
lines = append(lines, wizard.renderManageStep(innerWidth)...)
case providerWizardStepEditMenu:
lines = append(lines, wizard.renderEditMenuStep(innerWidth)...)
case providerWizardStepEditValue:
lines = append(lines, wizard.renderEditValueStep(innerWidth)...)
}
lines = append(lines,
zeroTheme.line.Render(strings.Repeat("─", innerWidth)),
zeroTheme.faint.Render(wizard.footer()),
)
block := styledBlockFillTitle(overlayWidth, "Provider setup", lines, zeroTheme.lineStrong, lipgloss.NewStyle())
title := "Provider setup"
if wizard.managerStep() {
title = "Providers"
}
block := styledBlockFillTitle(overlayWidth, title, lines, zeroTheme.lineStrong, lipgloss.NewStyle())
if width > overlayWidth {
return indentBlock(block, (width-overlayWidth)/2)
}
@@ -1145,8 +1263,30 @@ func (wizard *providerWizardState) render(width int) string {
}
func (wizard *providerWizardState) footer() string {
text := wizard.footerText()
// Inside a manager-launched add flow, Esc walks back to the provider list
// (see handleProviderWizardKey) — the hint must match.
if wizard.manage && !wizard.managerStep() {
text = strings.ReplaceAll(text, "Esc close", "Esc back")
}
return text
}
func (wizard *providerWizardState) footerText() string {
canRight := wizard.canAdvanceWithRight()
switch wizard.step {
case providerWizardStepManage:
if wizard.manageDeleting {
return "Enter/y delete Esc/n cancel"
}
if len(wizard.manageRows) == 0 {
return "a add Esc close"
}
return "↑/↓ move Enter activate a add e edit d delete Esc close"
case providerWizardStepEditMenu:
return "↑/↓ move Enter edit field / save Esc back"
case providerWizardStepEditValue:
return "type to edit Enter apply Ctrl+U clear Esc back"
case providerWizardStepMethod:
return "↑/↓ move Enter/→ continue Esc close"
case providerWizardStepProvider:
@@ -1189,7 +1329,7 @@ func providerWizardOverlayWidth(width int, step providerWizardStep) int {
switch step {
case providerWizardStepProvider:
target = providerWizardProviderWidth
case providerWizardStepModel:
case providerWizardStepModel, providerWizardStepManage:
target = providerWizardModelWidth
}
target = minInt(target, width)
@@ -1203,6 +1343,14 @@ func providerWizardStepLine(wizard *providerWizardState) string {
if wizard == nil {
return ""
}
if wizard.managerStep() {
switch wizard.step {
case providerWizardStepManage:
return pluralCount(len(wizard.manageRows), "saved provider")
default:
return "edit provider"
}
}
step := wizard.step
type stepLabel struct {
step providerWizardStep
@@ -1,10 +1,14 @@
package tui
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providercatalog"
)
@@ -384,3 +388,64 @@ func TestProviderWizardDeviceCodeIgnoresStaleAttempt(t *testing.T) {
t.Fatalf("stale device-code result applied device details: %+v", out.providerWizard)
}
}
func TestPersistOAuthLoginProviderWritesKeylessProfileWithoutStealingActive(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
seed := `{"activeProvider":"opengateway","providers":[{"name":"opengateway","provider_kind":"openai-compatible","baseURL":"https://gateway.example.com/v1","apiKeyStored":true,"model":"some-model"}]}`
if err := os.WriteFile(configPath, []byte(seed), 0o600); err != nil {
t.Fatalf("seed config: %v", err)
}
persistOAuthLoginProvider(configPath, "chatgpt")
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("read config: %v", err)
}
var cfg config.FileConfig
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("decode config: %v", err)
}
if cfg.ActiveProvider != "opengateway" {
t.Fatalf("active provider changed to %q", cfg.ActiveProvider)
}
if len(cfg.Providers) != 2 || cfg.Providers[1].CatalogID != "chatgpt" {
t.Fatalf("expected chatgpt profile appended, got %+v", cfg.Providers)
}
if cfg.Providers[1].APIKey != "" || cfg.Providers[1].APIKeyStored {
t.Fatalf("OAuth profile must stay keyless so the token resolver attaches, got %+v", cfg.Providers[1])
}
// Blank path (no user config, e.g. tests/ephemeral runs) must be a no-op.
persistOAuthLoginProvider("", "chatgpt")
}
func TestAppendOAuthLoginProfileAddsOnceAndRespectsRenames(t *testing.T) {
saved := []config.ProviderProfile{{Name: "opengateway", ProviderKind: config.ProviderKindOpenAICompatible}}
saved = appendOAuthLoginProfile(saved, "chatgpt")
if len(saved) != 2 || saved[1].Name != "chatgpt" || saved[1].CatalogID != "chatgpt" {
t.Fatalf("expected chatgpt appended, got %+v", saved)
}
if saved[1].Model == "" || saved[1].BaseURL == "" {
t.Fatalf("appended profile must carry catalog defaults, got %+v", saved[1])
}
// Idempotent: a second login must not duplicate.
saved = appendOAuthLoginProfile(saved, "chatgpt")
if len(saved) != 2 {
t.Fatalf("duplicate appended: %+v", saved)
}
// A renamed profile serving the same catalog entry blocks the append too.
renamed := []config.ProviderProfile{{Name: "codex", CatalogID: "chatgpt"}}
renamed = appendOAuthLoginProfile(renamed, "chatgpt")
if len(renamed) != 1 {
t.Fatalf("renamed profile not respected: %+v", renamed)
}
// Unknown catalog id: no-op.
if got := appendOAuthLoginProfile(nil, "no-such-provider"); got != nil {
t.Fatalf("unknown provider must not append, got %+v", got)
}
}
+13 -6
View File
@@ -49,8 +49,12 @@ type ProviderSnapshot struct {
APIModel string `json:"apiModel,omitempty"`
Active bool `json:"active"`
APIKeySet bool `json:"apiKeySet"`
Status string `json:"status,omitempty"`
Message string `json:"message,omitempty"`
// OAuthLogin marks a keyless profile whose credential is a stored OAuth login
// (e.g. ChatGPT). Filled by callers with access to the oauth store — the
// snapshot builder itself does no secret I/O.
OAuthLogin bool `json:"oauthLogin,omitempty"`
Status string `json:"status,omitempty"`
Message string `json:"message,omitempty"`
}
type ModelSnapshotOptions struct {
@@ -164,10 +168,13 @@ func ProviderSnapshotFromProfile(profile config.ProviderProfile, active bool) Pr
BaseURL: redactProviderBaseURL(profile.BaseURL, profile.APIKey, profile.AuthHeaderValue),
Model: profile.Model,
Active: active,
// A profile can authenticate via a raw auth-header value instead of APIKey;
// treat either as a configured credential so auth-header-only profiles don't
// render as "not set".
APIKeySet: strings.TrimSpace(profile.APIKey) != "" || strings.TrimSpace(profile.AuthHeaderValue) != "",
// HasConfiguredCredential is the shared definition of "this profile is
// key-authed": inline key, raw auth-header value, or a key in the encrypted
// credential store (APIKeyStored). The resolver is pure and never loads
// stored keys into APIKey, so checking only the inline key rendered every
// stored-key profile as "api key: not set" — a healthy provider that read
// as broken.
APIKeySet: profile.HasConfiguredCredential(),
Status: "ok",
}
metadata, err := providers.ResolveRuntimeMetadata(profile, providers.Options{})
+3
View File
@@ -340,6 +340,9 @@ func TestProviderSnapshotAPIKeySetCountsAuthHeaderValue(t *testing.T) {
{"auth header only", config.ProviderProfile{Name: "p", AuthHeaderValue: "Bearer t"}, true},
{"both", config.ProviderProfile{Name: "p", APIKey: "sk-x", AuthHeaderValue: "Bearer t"}, true},
{"neither", config.ProviderProfile{Name: "p"}, false},
// A key in the encrypted credential store is a configured credential even
// though the pure resolver leaves APIKey empty.
{"stored key marker", config.ProviderProfile{Name: "p", APIKeyStored: true}, true},
{"whitespace api key only", config.ProviderProfile{Name: "p", APIKey: " "}, false},
{"whitespace auth header only", config.ProviderProfile{Name: "p", AuthHeaderValue: " "}, false},
}