Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) once the first release is
tagged. Until then, source builds report the version `dev`.

## Unreleased

### Features

* **providers:** add `zero providers repair-config [--name <name>]` to recover a single legacy unnamed provider profile while preserving the active-provider name or falling back to `openai`

### Bug Fixes

* **oauth:** validate provider configuration before authorization and immediately before token replacement across CLI, TUI, setup, and device flows, preserving existing credentials when validation fails

## [0.8.0](https://github.com/Gitlawb/zero/compare/v0.7.0...v0.8.0) (2026-08-21)


Expand All @@ -26,7 +36,6 @@ tagged. Until then, source builds report the version `dev`.
* **modelregistry:** expose reasoning effort for DeepSeek V4 models ([#931](https://github.com/Gitlawb/zero/issues/931)) ([90dcfd1](https://github.com/Gitlawb/zero/commit/90dcfd127e8a6d9902ec9f4e72f6d03fef1a0fc6))
* **providers:** discover ChatGPT capabilities ([#890](https://github.com/Gitlawb/zero/issues/890)) ([2d2450e](https://github.com/Gitlawb/zero/commit/2d2450e9a744349f0d01b1d4e9ba29c24ba5650d))
* **sandbox:** normalize launcher names before the command-prefix denylist ([#934](https://github.com/Gitlawb/zero/issues/934)) ([6edf9a8](https://github.com/Gitlawb/zero/commit/6edf9a8b78dc030dc44598919db1c7fa9d4f809a))

## [0.7.0](https://github.com/Gitlawb/zero/compare/v0.6.0...v0.7.0) (2026-08-10)


Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,12 @@ zero models list
zero doctor
```

If an upgraded `config.json` contains one legacy provider profile without a
name, repair it with `zero providers repair-config`. The command preserves the
saved `activeProvider` name (falling back to `openai`), or accepts an explicit
replacement with `--name <name>`. Multiple unnamed rows are not guessed; repair
those directly in `config.json`.

For API providers, set the matching environment variable before setup or enter
the key in the wizard:

Expand Down Expand Up @@ -290,7 +296,7 @@ zero exec one-shot or scripted agent run
zero setup first-run provider setup
zero auth OAuth/login helpers for supported providers
zero models model registry and capabilities
zero providers provider profiles and detection
zero providers provider profiles, recovery, and detection
zero doctor setup, key, and connectivity checks
zero context context-budget report
zero repo-map deterministic repository map
Expand Down
8 changes: 7 additions & 1 deletion README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ zero models list
zero doctor
```

如果升级后的 `config.json` 中有一个旧版未命名的提供商配置,请运行
`zero providers repair-config` 进行修复。该命令会保留已保存的
`activeProvider` 名称(未设置时回退到 `openai`),也可以通过
`--name <名称>` 显式指定新名称。对于多个未命名的配置行,Zero 不会猜测,
请直接在 `config.json` 中修复。

对于 API 提供商,在设置之前设置匹配的环境变量或在向导中输入密钥:

```bash
Expand Down Expand Up @@ -208,7 +214,7 @@ zero exec 一次性或脚本化智能体运行
zero setup 首次运行提供商设置
zero auth 支持提供商的 OAuth/登录辅助
zero models 模型注册表和能力
zero providers 提供商配置和检测
zero providers 提供商配置、修复和检测
zero doctor 设置、密钥和连接检查
zero context 上下文预算报告
zero repo-map 确定性仓库映射
Expand Down
7 changes: 7 additions & 0 deletions docs/oauth-subscriptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ When a login exists for a provider, the **OpenAI and Anthropic** providers send
before. Tokens are stored 0600 (or the OS keyring with
`ZERO_OAUTH_STORAGE=keyring`) and never logged. See `zero auth --help`.

Provider OAuth login validates the persisted user configuration before opening
authorization and revalidates it immediately before replacing a stored token.
This applies to CLI login, the TUI/setup wizard, and device-code completion. If
the configuration is invalid at either check, Zero aborts without overwriting
the previous OAuth credential. If the error identifies one legacy unnamed
provider profile, repair it with `zero providers repair-config`.

### In the setup wizard (`/provider`)

Running `/provider` opens a **"How do you want to connect?"** chooser:
Expand Down
21 changes: 21 additions & 0 deletions internal/cli/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,27 @@ func TestRunNoArgsFailsWhenResolveErrorIsNotProviderRelated(t *testing.T) {
}
}

func TestRunNoArgsOffersRepairCommandForPersistedNameFailure(t *testing.T) {
var stdout, stderr bytes.Buffer
cwd := t.TempDir()
configPath := filepath.Join(t.TempDir(), "zero", "config.json")
writeProviderOnboardingConfig(t, configPath, config.FileConfig{Providers: []config.ProviderProfile{{Name: ""}, {Name: "work"}, {Name: "WORK"}}})
exitCode := runWithDeps(nil, &stdout, &stderr, appDeps{
getwd: func() (string, error) { return cwd, nil },
userConfigPath: func() (string, error) { return configPath, nil },
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
return config.Resolve(config.ResolveOptions{UserConfigPath: configPath, Env: map[string]string{}})
},
runTUI: func(context.Context, tui.Options) int {
t.Fatal("TUI must not launch with ambiguous persisted identities")
return 0
},
})
if exitCode == exitSuccess || !strings.Contains(stderr.String(), "zero providers repair-config") {
t.Fatalf("exit=%d stderr=%q, want actionable repair path", exitCode, stderr.String())
}
}

func TestRunNoArgsLaunchesTUIWithMCPState(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
Expand Down
80 changes: 61 additions & 19 deletions internal/cli/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"

Expand Down Expand Up @@ -39,7 +38,9 @@ func ensureLoginProviderProfile(deps appDeps, provider string) string {
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))
// Both sides are persisted provider names, so this is a provider-identity
// question and uses the credential store's rule rather than EqualFold.
active := config.SameProviderIdentity(ensured.Active, ensured.Name)
switch {
case ensured.Created && active:
return fmt.Sprintf("Added provider %q to your config and set it active.", ensured.Name)
Expand All @@ -52,6 +53,14 @@ func ensureLoginProviderProfile(deps appDeps, provider string) string {
}
}

func preflightAuthLogin(deps appDeps) error {
configPath, err := deps.userConfigPath()
if err != nil {
return err
}
return config.PreflightUserConfig(configPath)
}

// runAuth dispatches `zero auth <command>` for provider OAuth login. It is
// additive and independent of `zero mcp oauth` (MCP server auth), which is
// unchanged.
Expand Down Expand Up @@ -99,6 +108,18 @@ func runAuthOpenRouter(args []string, stdout io.Writer, stderr io.Writer, deps a
if len(args) > 0 {
return writeExecUsageError(stderr, fmt.Sprintf("zero auth openrouter takes no arguments (got %q)", args[0]))
}
// Two checks, one lifecycle. This one refuses config we can already tell is
// unpublishable BEFORE the browser flow mints a live remote credential:
// validation lived only inside saveOpenRouterProviderKey, past the
// irreversible boundary, so a legacy unnamed or duplicate-name config sent
// the user through OpenRouter's authorization and then handed back an
// orphaned key with a nonzero exit. The sibling chatgpt and login flows check
// here too. The second check stays where it is, immediately before
// EnsureCatalogProvider, because the config can change while the browser flow
// is open.
if err := preflightAuthLogin(deps); err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
key, err := deps.openRouterLogin(context.Background(), provideroauth.OpenRouterOptions{
Out: stdout,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
Expand All @@ -111,10 +132,13 @@ func runAuthOpenRouter(args []string, stdout io.Writer, stderr io.Writer, deps a
key = strings.TrimSpace(key)
line, err := saveOpenRouterProviderKey(deps, key)
if err != nil {
// The key was minted, so still hand it over for manual use — but a login
// that could not be persisted is a failure, not a success: exiting 0 here
// told scripts (and users) the provider was configured when it was not.
if _, writeErr := fmt.Fprintf(stdout, "\nOpenRouter login complete — new API key minted, but Zero could not save it: %s\nUse it manually, e.g.:\n export OPENROUTER_API_KEY=%s\n", err, key); writeErr != nil {
return exitCrash
}
return exitSuccess
return exitCrash
}
if _, err := fmt.Fprintf(stdout, "\nOpenRouter login complete — new API key saved.\n%s\n", line); err != nil {
return exitCrash
Expand All @@ -131,24 +155,24 @@ func saveOpenRouterProviderKey(deps appDeps, key string) (string, error) {
if err != nil {
return "", err
}
ensured, err := config.EnsureCatalogProvider(configPath, "openrouter")
if err != nil {
// Validate the persisted config BEFORE EnsureCatalogProvider's lookup, which
// matches case-insensitively and would happily return one of a pair of legacy
// duplicate rows — whose shared credential the capture below then overwrites
// for a config that can never be published.
if err := config.PreflightUserConfig(configPath); err != nil {
return "", err
}
store, err := config.ProviderKeyStoreAt(filepath.Dir(configPath))
ensured, err := config.EnsureCatalogProvider(configPath, "openrouter")
if err != nil {
return "", err
}
if err := store.Set(ensured.Name, key); err != nil {
// One operation owns validate → capture → publish, and restores the previous
// stored key if publication is rejected, so a failed login never costs the
// user the key they were already working with.
if err := config.PublishProviderCredential(configPath, ensured.Name, key); err != nil {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return "", err
}
if err := config.MarkProviderAPIKeyStored(configPath, ensured.Name); err != nil {
// Best-effort rollback: don't leave the key orphaned in the credential
// store while config.json still says it isn't there.
_, _ = store.Delete(ensured.Name)
return "", err
}
active := strings.EqualFold(strings.TrimSpace(ensured.Active), strings.TrimSpace(ensured.Name))
active := config.SameProviderIdentity(ensured.Active, ensured.Name)
switch {
case ensured.Created && active:
return fmt.Sprintf("Added provider %q to your config and set it active.", ensured.Name), nil
Expand Down Expand Up @@ -176,6 +200,9 @@ func runAuthChatGPT(args []string, stdout io.Writer, stderr io.Writer, deps appD
if len(args) > 0 {
return writeExecUsageError(stderr, fmt.Sprintf("zero auth chatgpt takes no arguments (got %q)", args[0]))
}
if err := preflightAuthLogin(deps); err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}

// Build the same env map the oauth engine reads so the chatgpt preset is
// opted into (the preset is off by default to keep third-party OAuth
Expand Down Expand Up @@ -212,6 +239,9 @@ func runAuthChatGPT(args []string, stdout io.Writer, stderr io.Writer, deps appD
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if err := preflightAuthLogin(deps); err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if err := store.Save(oauth.ProviderKey("chatgpt"), token); err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
Expand Down Expand Up @@ -372,6 +402,7 @@ func newAuthManager(deps appDeps, out io.Writer) (*oauth.Manager, error) {
// `zero auth login <preset>` (e.g. xai) should resolve the baked-in preset
// without the operator exporting ZERO_OAUTH_ALLOW_PRESETS first.
AllowPresets: true,
BeforeSave: func() error { return preflightAuthLogin(deps) },
})
}

Expand Down Expand Up @@ -402,6 +433,9 @@ func runAuthLogin(args []string, stdout io.Writer, stderr io.Writer, deps appDep
}
return runAuthChatGPT(nil, stdout, stderr, deps)
}
if err := preflightAuthLogin(deps); err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
manager, err := newAuthManager(deps, stdout)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
Expand Down Expand Up @@ -438,6 +472,13 @@ func runAuthLogout(args []string, stdout io.Writer, stderr io.Writer, deps appDe
return writeExecUsageError(stderr, "usage: zero auth logout <provider>")
}
provider := parsed.positional[0]
configPath, err := deps.userConfigPath()
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
if err := config.PreflightUserConfig(configPath); err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
}
manager, err := newAuthManager(deps, stdout)
if err != nil {
return writeAppError(stderr, redaction.ErrorMessage(err, redaction.Options{}), exitCrash)
Expand All @@ -449,15 +490,16 @@ func runAuthLogout(args []string, stdout io.Writer, stderr io.Writer, deps appDe
// Also drop any stored API key and its marker so `auth logout` clears the whole
// credential (OAuth token AND key), not just the OAuth side. Surface deletion
// failures rather than reporting success while a credential remains.
// Marker first, then the secret: the reverse order leaves apiKeyStored:true
// with nothing behind it if the config write fails. Provider credentials are
// user-scoped, so deletion uses the same default user store as runtime lookup.
if _, clearErr := config.ClearProviderKeyStoredCaseVariants(configPath, provider); clearErr != nil {
return writeAppError(stderr, redaction.ErrorMessage(clearErr, redaction.Options{}), exitCrash)
}
keyRemoved, keyErr := config.ForgetProviderKey(provider)
if keyErr != nil {
return writeAppError(stderr, redaction.ErrorMessage(keyErr, redaction.Options{}), exitCrash)
}
if configPath, perr := deps.userConfigPath(); perr == nil {
if _, clearErr := config.ClearProviderKeyStored(configPath, provider); clearErr != nil {
return writeAppError(stderr, redaction.ErrorMessage(clearErr, redaction.Options{}), exitCrash)
}
}
removed = removed || keyRemoved
if parsed.json {
payload := struct {
Expand Down
Loading
Loading