mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
330b10fe8e | ||
|
|
ac25352e12 | ||
|
|
de7f0b6a5b | ||
|
|
3eafaf584d | ||
|
|
9cfdb2fecf | ||
|
|
4ffbf4e0fe | ||
|
|
440d4df367 | ||
|
|
4bc9a97176 | ||
|
|
6d7d3946c8 |
No files matched your search
@@ -1 +1 @@
|
||||
{".":"0.2.0"}
|
||||
{".":"0.1.0"}
|
||||
@@ -6,71 +6,6 @@ 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`.
|
||||
|
||||
## [0.2.0](https://github.com/Gitlawb/zero/compare/v0.1.0...v0.2.0) (2026-07-06)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add --auto flag for LLM-generated commit messages ([#423](https://github.com/Gitlawb/zero/issues/423)) ([b0abde7](https://github.com/Gitlawb/zero/commit/b0abde7d0697e808480cd59d69a6f4d0c6320475))
|
||||
* add zero changes push and pr subcommands, and extra repo-info metrics ([#391](https://github.com/Gitlawb/zero/issues/391)) ([2312abe](https://github.com/Gitlawb/zero/commit/2312abe5ddd95f4c6ef373cfb61cc03092f48cdd))
|
||||
* agent quality, caching, retry, and tooling upgrades ([#506](https://github.com/Gitlawb/zero/issues/506)) ([3c81fea](https://github.com/Gitlawb/zero/commit/3c81fea22873ee3df7fc97b10cb4f77792706c4b))
|
||||
* **agent:** curb over-engineering the solution in the editing discipline ([#517](https://github.com/Gitlawb/zero/issues/517)) ([f4c998a](https://github.com/Gitlawb/zero/commit/f4c998ac30c4f07ff313a2d706791e857293be49)), closes [#516](https://github.com/Gitlawb/zero/issues/516)
|
||||
* **agent:** inject per-user config.UserConfigDir()/zero/ZERO.md guidelines into system prompt ([#475](https://github.com/Gitlawb/zero/issues/475)) ([7b10aab](https://github.com/Gitlawb/zero/commit/7b10aab74bf14a01166b2cea22deab79bba9850b))
|
||||
* **openai:** forward prompt_cache_key for server-side prefix cache routing ([#515](https://github.com/Gitlawb/zero/issues/515)) ([87e7e69](https://github.com/Gitlawb/zero/commit/87e7e69afd18b5539579856f3a61c6a95bc445ae))
|
||||
* **providers:** add `zero providers models` to discover a provider's models ([#386](https://github.com/Gitlawb/zero/issues/386)) ([0bc8074](https://github.com/Gitlawb/zero/commit/0bc8074c97b0310e4a9d70c3f967003ee5e8a59f))
|
||||
* **providers:** add KiloCode and OpenCode provider support ([#388](https://github.com/Gitlawb/zero/issues/388)) ([b1ccb6d](https://github.com/Gitlawb/zero/commit/b1ccb6d9c1875377f5e5ea81a1304edd1e41ab4f))
|
||||
* **providers:** add Meituan LongCat catalog preset ([#424](https://github.com/Gitlawb/zero/issues/424)) ([b4275e3](https://github.com/Gitlawb/zero/commit/b4275e350472b2490212bf814709819d354c1216))
|
||||
* **providers:** split minimax zai into intl cn ([#398](https://github.com/Gitlawb/zero/issues/398)) ([aaad4d2](https://github.com/Gitlawb/zero/commit/aaad4d271270f41af837b6f3b60ae80beba0c645))
|
||||
* require manual approval before npm publish + drop release-as pin ([#369](https://github.com/Gitlawb/zero/issues/369)) ([bd89a1f](https://github.com/Gitlawb/zero/commit/bd89a1f451643c1b65ec803070abc7b116631ebe))
|
||||
* **sandbox:** unelevated Windows fallback tier instead of prompts-only degrade ([#427](https://github.com/Gitlawb/zero/issues/427)) ([b9ddd6f](https://github.com/Gitlawb/zero/commit/b9ddd6f42138312a1fee8d8bb67c46c8eb1dea2f))
|
||||
* support shift enter for composer newlines ([#462](https://github.com/Gitlawb/zero/issues/462)) ([daf65e0](https://github.com/Gitlawb/zero/commit/daf65e0af9a040314d4ab337b0ad59c55416b7bc))
|
||||
* **tui:** /loop — repeat a prompt or command on an interval or self-paced ([#502](https://github.com/Gitlawb/zero/issues/502)) ([387fe67](https://github.com/Gitlawb/zero/commit/387fe67ee7cd81317c9c969f5906a4437080fea3))
|
||||
* **tui:** add search/filter to provider picker in setup wizard ([#400](https://github.com/Gitlawb/zero/issues/400)) ([2fcea71](https://github.com/Gitlawb/zero/commit/2fcea71778d23e050c93409c471aef45b68c1621))
|
||||
* **update:** add zero upgrade command to apply self-updates ([#461](https://github.com/Gitlawb/zero/issues/461)) ([5f36349](https://github.com/Gitlawb/zero/commit/5f36349c1884e81fa9bc66bb5fe813b627e897b7))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **action:** keep provider key scoped to zero step ([#448](https://github.com/Gitlawb/zero/issues/448)) ([407a927](https://github.com/Gitlawb/zero/commit/407a92739ff508cba32d2c12b3f36f0efcdd54c3))
|
||||
* add android platform support for Termux npm install ([#455](https://github.com/Gitlawb/zero/issues/455)) ([9bd93c6](https://github.com/Gitlawb/zero/commit/9bd93c62f8d57fb74057284aa66a1b6e1429dcdd)), closes [#449](https://github.com/Gitlawb/zero/issues/449)
|
||||
* **agent:** reject a malformed additional_permissions payload before prompting ([#453](https://github.com/Gitlawb/zero/issues/453)) ([e4f760e](https://github.com/Gitlawb/zero/commit/e4f760ee8bd57299cd2fcb37e8e23130037c2607))
|
||||
* allow non-TLS connections to private-network provider endpoints ([#444](https://github.com/Gitlawb/zero/issues/444)) ([1d86384](https://github.com/Gitlawb/zero/commit/1d8638466ca31517eb9db2b9353d3dce1cbeeabc))
|
||||
* **auth:** route zero auth login chatgpt to the dedicated ChatGPT flow ([#443](https://github.com/Gitlawb/zero/issues/443)) ([305a62c](https://github.com/Gitlawb/zero/commit/305a62c954ca6cec00bc58d5398f933415156aff))
|
||||
* **config:** fall back to a usable saved provider instead of forcing full re-onboarding ([#410](https://github.com/Gitlawb/zero/issues/410)) ([c60ad87](https://github.com/Gitlawb/zero/commit/c60ad8729f79bb841114d352ee2d2fe29d5d0e41))
|
||||
* **config:** let a gateway ANTHROPIC_BASE_URL resolve as anthropic-compatible ([#497](https://github.com/Gitlawb/zero/issues/497)) ([30dd7c3](https://github.com/Gitlawb/zero/commit/30dd7c3112ad22d42fa12b5addd4e38f4beda42a)), closes [#479](https://github.com/Gitlawb/zero/issues/479)
|
||||
* **config:** unbrick first-run setup — default google/anthropic models, enter setup on fixable config errors ([#385](https://github.com/Gitlawb/zero/issues/385)) ([72eed06](https://github.com/Gitlawb/zero/commit/72eed06b4f94c43d75d31fe54a58d2f566de059e))
|
||||
* **config:** use ~/.config on macOS and enter setup when no provider ([#371](https://github.com/Gitlawb/zero/issues/371)) ([#372](https://github.com/Gitlawb/zero/issues/372)) ([027a8f2](https://github.com/Gitlawb/zero/commit/027a8f2768b17b89f5c8270887f156e2ccda69ea))
|
||||
* **docs:** rename AGENTS.MD > AGENTS.md ([#438](https://github.com/Gitlawb/zero/issues/438)) ([4266baf](https://github.com/Gitlawb/zero/commit/4266baf222df583ed2078b776687f12d496475b5))
|
||||
* **gemini:** strip unsupported JSON Schema fields from tool declarations ([#374](https://github.com/Gitlawb/zero/issues/374)) ([39e7100](https://github.com/Gitlawb/zero/commit/39e7100674150144a1152e3110c64c7cf0321d64)), closes [#373](https://github.com/Gitlawb/zero/issues/373)
|
||||
* **install:** persist install dir to user PATH on Windows ([#407](https://github.com/Gitlawb/zero/issues/407)) ([bdb1b0e](https://github.com/Gitlawb/zero/commit/bdb1b0ecd15859b1712a6037d296dace7f9c3c3f))
|
||||
* **mcp:** block cross-origin credential redirects ([#396](https://github.com/Gitlawb/zero/issues/396)) ([f915f70](https://github.com/Gitlawb/zero/commit/f915f70e5a3096e2419fa8d961a0f84a626fa4a9))
|
||||
* **oauth:** treat Windows ERROR_ACCESS_DENIED as lock contention in createSecretFile ([#445](https://github.com/Gitlawb/zero/issues/445)) ([d05e914](https://github.com/Gitlawb/zero/commit/d05e9148a7f79f67d1d3c31fca2775f21fbd331e))
|
||||
* **openai:** handle Ollama reasoning stream deltas ([#486](https://github.com/Gitlawb/zero/issues/486)) ([f6c0606](https://github.com/Gitlawb/zero/commit/f6c060631e18e082dda24cc4dc0903c31c2120d6))
|
||||
* preserve conversation context in exec prompts ([#460](https://github.com/Gitlawb/zero/issues/460)) ([949ee43](https://github.com/Gitlawb/zero/commit/949ee43f71e5cb7fab4695c5cb7b442fe4ecfbf7))
|
||||
* **provider-wizard:** allow multiple custom OpenAI-compatible providers ([#403](https://github.com/Gitlawb/zero/issues/403)) ([3fbbd28](https://github.com/Gitlawb/zero/commit/3fbbd28e4c586822cc4312c86232d94befe56e87))
|
||||
* **sandbox:** fix nested pipe creation under the Windows restricted token ([#456](https://github.com/Gitlawb/zero/issues/456)) ([563a6db](https://github.com/Gitlawb/zero/commit/563a6dbe91e65d5daeefd7626e8a77e30a6d8fb2))
|
||||
* **sandbox:** gate /tmp test assertions on GOOS, not path existence ([#426](https://github.com/Gitlawb/zero/issues/426)) ([f653dca](https://github.com/Gitlawb/zero/commit/f653dcac363fb69ad7be5b35e6e0fa6d2bce476d))
|
||||
* **sandbox:** self-heal a corrupt unelevated setup marker ([#437](https://github.com/Gitlawb/zero/issues/437)) ([8d0c5fe](https://github.com/Gitlawb/zero/commit/8d0c5feccb8bdbfb015df0508aa6e3bcbd1fd0e8))
|
||||
* **specialist:** cap max specialist nesting depth ([#491](https://github.com/Gitlawb/zero/issues/491)) ([177442c](https://github.com/Gitlawb/zero/commit/177442cfe4015bd8df04cc9894f98b468ee796d4))
|
||||
* Termux/Android support — PRoot scroll, SIGSYS sandbox, build docs ([#509](https://github.com/Gitlawb/zero/issues/509)) ([0f69d99](https://github.com/Gitlawb/zero/commit/0f69d995e9b586b774f66c066b21abab5e03024a))
|
||||
* **tools:** Block MSYS coreutils under Windows sandbox ([#476](https://github.com/Gitlawb/zero/issues/476)) ([81aad58](https://github.com/Gitlawb/zero/commit/81aad58d97839e51c068e2f08907618991fdc3fb))
|
||||
* **tools:** CRLF line ending mismatch in edit_file tool on Windows ([#378](https://github.com/Gitlawb/zero/issues/378)) ([33dc7ae](https://github.com/Gitlawb/zero/commit/33dc7ae2cc82c5389675531e1416856dae7151ce))
|
||||
* **tools:** fix cmd.exe /S/C corrupting commands with embedded quotes ([#465](https://github.com/Gitlawb/zero/issues/465)) ([190241b](https://github.com/Gitlawb/zero/commit/190241bd593f43211b766e0b13c8e89802d4bb37))
|
||||
* **tools:** flag piped POSIX utilities before running on Windows ([#412](https://github.com/Gitlawb/zero/issues/412)) ([5658a36](https://github.com/Gitlawb/zero/commit/5658a366274fc59a9d5336b06a21019c9c25cbf1))
|
||||
* **tools:** make grep and glob respect run cancellation ([#464](https://github.com/Gitlawb/zero/issues/464)) ([ba6c026](https://github.com/Gitlawb/zero/commit/ba6c0264697b7d7ed479f6e782fba9700a481e3d))
|
||||
* **tools:** require permission before web_search requests ([#382](https://github.com/Gitlawb/zero/issues/382)) ([960db96](https://github.com/Gitlawb/zero/commit/960db9660e4e31dc588fe8f7d6f116ff5e225566))
|
||||
* **tui:** compose help overlay through the viewport overlay pipeline ([#421](https://github.com/Gitlawb/zero/issues/421)) ([5b2b4de](https://github.com/Gitlawb/zero/commit/5b2b4dea1aaf9e0f68baa25e97e83296fb17b1a2))
|
||||
* **tui:** keep the profile name on /model switch so the stored key resolves ([#441](https://github.com/Gitlawb/zero/issues/441)) ([9134148](https://github.com/Gitlawb/zero/commit/9134148f4df3e4e556fba6c2f8babfdf6fcfeee1)), closes [#440](https://github.com/Gitlawb/zero/issues/440)
|
||||
* **tui:** resolve every permission request so the agent can't deadlock ([#397](https://github.com/Gitlawb/zero/issues/397)) ([952788f](https://github.com/Gitlawb/zero/commit/952788f72d32957659fe004521fcc8372b9ba9b4))
|
||||
* **tui:** show an M suffix for million-scale token counts ([#457](https://github.com/Gitlawb/zero/issues/457)) ([0562e3b](https://github.com/Gitlawb/zero/commit/0562e3bef7df2328610a48a1e81632a8da4aec64))
|
||||
* **tui:** title /model rows by model name, not the catalog description ([#395](https://github.com/Gitlawb/zero/issues/395)) ([cdf9d83](https://github.com/Gitlawb/zero/commit/cdf9d839ae57a729f292f36f7c5b0c67b41b288d))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* cache TUI model registry ([#496](https://github.com/Gitlawb/zero/issues/496)) ([e7d88b4](https://github.com/Gitlawb/zero/commit/e7d88b4b518049733da25a8447c00144bd1da518))
|
||||
* universal tool-output ceiling with spill + async post-edit diagnostics ([#518](https://github.com/Gitlawb/zero/issues/518)) ([95ccd5b](https://github.com/Gitlawb/zero/commit/95ccd5bc327f6fb464ff0239f7229de789f578dc))
|
||||
|
||||
## 0.1.0 (2026-07-02)
|
||||
|
||||
|
||||
|
||||
+21
-54
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -160,14 +159,8 @@ func (a *Agent) handleSessionLoad(_ context.Context, params json.RawMessage) (an
|
||||
// Load history BEFORE publishing the session so no concurrent prompt observes
|
||||
// a half-initialized session (registerSession sets history under the lock and
|
||||
// reuses an already-live session rather than orphaning its in-flight turn).
|
||||
history, historyErr := a.loadHistory(meta.SessionID)
|
||||
history := a.loadHistory(meta.SessionID)
|
||||
sess := a.registerSession(meta.SessionID, root, history)
|
||||
a.warnPersistence(
|
||||
¬ifier{conn: a.conn, sessionID: sess.id},
|
||||
"load session history",
|
||||
"Could not load session history. The session is open, but earlier turns may be missing until storage recovers.",
|
||||
historyErr,
|
||||
)
|
||||
return LoadSessionResult{
|
||||
Modes: a.modeState(sess),
|
||||
}, nil
|
||||
@@ -260,14 +253,7 @@ func (a *Agent) runTurn(ctx context.Context, sess *acpSession, userText string,
|
||||
if stopErr != nil {
|
||||
return "", RPCError(codeInternalError, stopErr.Error())
|
||||
}
|
||||
if err := a.persistTurn(sess, userText, result.FinalAnswer); err != nil {
|
||||
a.warnPersistence(
|
||||
note,
|
||||
"save session history",
|
||||
"Could not save session history. This turn is available in memory, but future resume may miss it until storage recovers.",
|
||||
err,
|
||||
)
|
||||
}
|
||||
a.persistTurn(sess, userText, result.FinalAnswer)
|
||||
return reason, nil
|
||||
}
|
||||
|
||||
@@ -400,34 +386,29 @@ func (a *Agent) modeState(s *acpSession) *SessionModeState {
|
||||
|
||||
// ---- persistence + continuity ----
|
||||
|
||||
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) error {
|
||||
defer sess.appendHistory(turnRecord{user: user, assistant: assistant})
|
||||
func (a *Agent) persistTurn(sess *acpSession, user, assistant string) {
|
||||
if a.deps.Store != nil {
|
||||
_, _ = a.deps.Store.AppendEvent(sess.id, sessions.AppendEventInput{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{"role": "user", "content": user},
|
||||
})
|
||||
if assistant != "" {
|
||||
_, _ = a.deps.Store.AppendEvent(sess.id, sessions.AppendEventInput{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{"role": "assistant", "content": assistant},
|
||||
})
|
||||
}
|
||||
}
|
||||
sess.appendHistory(turnRecord{user: user, assistant: assistant})
|
||||
}
|
||||
|
||||
func (a *Agent) loadHistory(sessionID string) []turnRecord {
|
||||
if a.deps.Store == nil {
|
||||
return nil
|
||||
}
|
||||
events := []sessions.AppendEventInput{
|
||||
{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{"role": "user", "content": user},
|
||||
},
|
||||
}
|
||||
if assistant != "" {
|
||||
events = append(events, sessions.AppendEventInput{
|
||||
Type: sessions.EventMessage,
|
||||
Payload: map[string]any{"role": "assistant", "content": assistant},
|
||||
})
|
||||
}
|
||||
_, err := a.deps.Store.AppendEvents(sess.id, events)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Agent) loadHistory(sessionID string) ([]turnRecord, error) {
|
||||
if a.deps.Store == nil {
|
||||
return nil, nil
|
||||
}
|
||||
events, err := a.deps.Store.ReadEvents(sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil
|
||||
}
|
||||
var records []turnRecord
|
||||
var pendingUser string
|
||||
@@ -463,21 +444,7 @@ func (a *Agent) loadHistory(sessionID string) ([]turnRecord, error) {
|
||||
if havePending {
|
||||
records = append(records, turnRecord{user: pendingUser})
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (a *Agent) warnPersistence(note *notifier, action string, message string, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
sessionID := ""
|
||||
if note != nil {
|
||||
sessionID = note.sessionID
|
||||
}
|
||||
log.Printf("zero acp: failed to %s for session %s: %v", action, sessionID, err)
|
||||
if note != nil {
|
||||
note.text("\n\n[zero warning] " + message + "\n")
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
// buildPrompt prepends prior conversation as context, since agent.Run drives a
|
||||
|
||||
@@ -5,8 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -244,77 +242,8 @@ func TestACPRejectsInvalidCwd(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPPromptWarnsWhenTurnPersistenceFails(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var newRes NewSessionResult
|
||||
if err := h.client.Call(ctx, MethodSessionNew, NewSessionParams{Cwd: t.TempDir(), McpServers: []McpServer{}}, &newRes); err != nil {
|
||||
t.Fatalf("session/new: %v", err)
|
||||
}
|
||||
metadataPath := filepath.Join(deps.Store.RootDir, newRes.SessionID, sessions.MetadataFile)
|
||||
if err := os.Remove(metadataPath); err != nil {
|
||||
t.Fatalf("remove metadata: %v", err)
|
||||
}
|
||||
|
||||
var promptRes PromptResult
|
||||
if err := h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: newRes.SessionID,
|
||||
Prompt: []ContentBlock{TextBlock("hi")},
|
||||
}, &promptRes); err != nil {
|
||||
t.Fatalf("session/prompt: %v", err)
|
||||
}
|
||||
if promptRes.StopReason != StopEndTurn {
|
||||
t.Fatalf("stopReason = %q, want %q", promptRes.StopReason, StopEndTurn)
|
||||
}
|
||||
got := drainTextUntil(t, h.updates, func(text string) bool {
|
||||
return strings.Contains(text, "Hello from ZERO") &&
|
||||
strings.Contains(text, "Could not save session history")
|
||||
})
|
||||
if !strings.Contains(got, "Could not save session history") {
|
||||
t.Fatalf("streamed text = %q, want persistence warning", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACPLoadWarnsWhenHistoryReadFails(t *testing.T) {
|
||||
deps := testDeps(t)
|
||||
cwd := t.TempDir()
|
||||
meta, err := deps.Store.Create(sessions.CreateInput{Title: "ACP session", Cwd: cwd})
|
||||
if err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
eventsPath := filepath.Join(deps.Store.RootDir, meta.SessionID, sessions.EventsFile)
|
||||
if err := os.WriteFile(eventsPath, []byte("{bad json}\n"), 0o600); err != nil {
|
||||
t.Fatalf("write corrupt events: %v", err)
|
||||
}
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{SessionID: meta.SessionID, Cwd: cwd, McpServers: []McpServer{}}, &LoadSessionResult{}); err != nil {
|
||||
t.Fatalf("session/load: %v", err)
|
||||
}
|
||||
got := drainTextUntil(t, h.updates, func(text string) bool {
|
||||
return strings.Contains(text, "Could not load session history")
|
||||
})
|
||||
if !strings.Contains(got, "Could not load session history") {
|
||||
t.Fatalf("streamed text = %q, want load warning", got)
|
||||
}
|
||||
}
|
||||
|
||||
// drainText collects streamed chunks for a short window and concatenates them.
|
||||
func drainText(t *testing.T, ch <-chan string) string {
|
||||
t.Helper()
|
||||
return drainTextUntil(t, ch, func(text string) bool {
|
||||
return strings.Contains(text, "Hello from ZERO")
|
||||
})
|
||||
}
|
||||
|
||||
func drainTextUntil(t *testing.T, ch <-chan string, done func(string) bool) string {
|
||||
t.Helper()
|
||||
var b strings.Builder
|
||||
deadline := time.After(2 * time.Second)
|
||||
@@ -322,7 +251,7 @@ func drainTextUntil(t *testing.T, ch <-chan string, done func(string) bool) stri
|
||||
select {
|
||||
case s := <-ch:
|
||||
b.WriteString(s)
|
||||
if done(b.String()) {
|
||||
if strings.Contains(b.String(), "Hello from ZERO") {
|
||||
return b.String()
|
||||
}
|
||||
case <-deadline:
|
||||
|
||||
@@ -721,8 +721,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
Content: nudge,
|
||||
})
|
||||
}
|
||||
finalExposed, _ := partitionToolsCached(registry, permissionMode, options, loaded, toolDefCache)
|
||||
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, finalExposed, options); strings.TrimSpace(answer) != "" {
|
||||
if answer, finalMessages, finishReason := finalAnswerAfterMaxTurns(ctx, provider, messages, options); strings.TrimSpace(answer) != "" {
|
||||
result.FinalAnswer = answer
|
||||
result.FinishReason = finishReason
|
||||
result.Messages = copyMessages(finalMessages)
|
||||
@@ -742,7 +741,7 @@ func Run(ctx context.Context, prompt string, provider Provider, options Options)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages []zeroruntime.Message, toolDefs []zeroruntime.ToolDefinition, options Options) (string, []zeroruntime.Message, string) {
|
||||
func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages []zeroruntime.Message, options Options) (string, []zeroruntime.Message, string) {
|
||||
finalMessages := copyMessages(messages)
|
||||
finalMessages = append(finalMessages, zeroruntime.Message{
|
||||
Role: zeroruntime.MessageRoleUser,
|
||||
@@ -753,7 +752,6 @@ func finalAnswerAfterMaxTurns(ctx context.Context, provider Provider, messages [
|
||||
// transient hiccup doesn't drop the final summary (AUDIT-L1).
|
||||
stream, err := streamWithReconnect(ctx, provider, zeroruntime.CompletionRequest{
|
||||
Messages: copyMessages(finalMessages),
|
||||
Tools: toolDefs,
|
||||
ReasoningEffort: options.ReasoningEffort,
|
||||
PromptCacheKey: options.SessionID,
|
||||
}, reconnectNoticeFor(options))
|
||||
|
||||
@@ -2768,11 +2768,11 @@ func TestRunRequestsFinalAnswerAfterMaxTurns(t *testing.T) {
|
||||
t.Fatalf("expected final answer from finalization turn, got %q", result.FinalAnswer)
|
||||
}
|
||||
if len(provider.requests) != 2 {
|
||||
t.Fatalf("expected finalization request after max turns, got %d requests", len(provider.requests))
|
||||
t.Fatalf("expected final no-tools request after max turns, got %d requests", len(provider.requests))
|
||||
}
|
||||
finalRequest := provider.requests[1]
|
||||
if toolDefinitionByName(finalRequest.Tools, "read_file") == nil {
|
||||
t.Fatalf("finalization request must keep prior tools exposed for strict provider replay, got %#v", finalRequest.Tools)
|
||||
if len(finalRequest.Tools) != 0 {
|
||||
t.Fatalf("finalization request must not advertise tools, got %#v", finalRequest.Tools)
|
||||
}
|
||||
lastMessage := finalRequest.Messages[len(finalRequest.Messages)-1]
|
||||
if lastMessage.Role != zeroruntime.MessageRoleUser || !strings.Contains(lastMessage.Content, "tool-turn limit") {
|
||||
|
||||
@@ -731,7 +731,6 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
|
||||
lastKnownMCPConfig := mcpConfig
|
||||
return deps.runTUI(context.Background(), tui.Options{
|
||||
Cwd: workspaceRoot,
|
||||
Version: version,
|
||||
Theme: theme,
|
||||
SavedTheme: resolved.Preferences.Theme,
|
||||
UserConfigPath: userConfigPath,
|
||||
|
||||
+2
-45
@@ -11,46 +11,10 @@ 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.
|
||||
@@ -174,10 +138,8 @@ func runAuthChatGPT(args []string, stdout io.Writer, stderr io.Writer, deps appD
|
||||
if _, err := fmt.Fprint(stdout, statuses); err != nil {
|
||||
return exitCrash
|
||||
}
|
||||
if line := ensureLoginProviderProfile(deps, "chatgpt"); line != "" {
|
||||
if _, err := fmt.Fprintf(stdout, "\n%s\n", line); 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
|
||||
}
|
||||
return exitSuccess
|
||||
}
|
||||
@@ -369,11 +331,6 @@ 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
|
||||
}
|
||||
|
||||
|
||||
@@ -2,24 +2,18 @@ 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,
|
||||
// pinning the file backend so an inherited ZERO_OAUTH_STORAGE=keyring can't
|
||||
// ignore the temp path and hit the OS keychain.
|
||||
// withAuthStore points the provider OAuth store at a temp file for the test.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -203,81 +197,3 @@ 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
|
||||
}
|
||||
@@ -75,12 +75,6 @@ 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)
|
||||
}
|
||||
@@ -252,20 +246,6 @@ 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
|
||||
@@ -343,7 +323,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: "+providerCredentialState(provider),
|
||||
"api key: "+apiKeyState(provider.APIKeySet),
|
||||
)
|
||||
if provider.Message != "" {
|
||||
lines = append(lines, "status: "+provider.Status+" - "+provider.Message)
|
||||
@@ -360,7 +340,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"), providerCredentialState(provider))
|
||||
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))
|
||||
if provider.Message != "" {
|
||||
line += " (" + provider.Status + ": " + provider.Message + ")"
|
||||
}
|
||||
@@ -433,15 +413,6 @@ 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
|
||||
@@ -495,8 +466,6 @@ 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]
|
||||
|
||||
@@ -11,7 +11,6 @@ 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"
|
||||
@@ -836,57 +835,3 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package cli
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
@@ -307,143 +306,3 @@ 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
|
||||
}
|
||||
@@ -234,48 +234,3 @@ 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")
|
||||
}
|
||||
}
|
||||
@@ -457,11 +457,7 @@ func selectProviderForCheck(resolved config.ResolvedConfig, name string) (config
|
||||
}
|
||||
|
||||
func validateProviderRuntimeReady(profile config.ProviderProfile) error {
|
||||
// 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())
|
||||
hasCredential := providerProfileHasCredential(profile)
|
||||
if profile.CatalogID != "" {
|
||||
descriptor, err := providercatalog.Require(profile.CatalogID)
|
||||
if err != nil {
|
||||
|
||||
+7
-14
@@ -322,9 +322,7 @@ 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() {
|
||||
// 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))] {
|
||||
if oauthLogins[name] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -346,19 +344,18 @@ func oauthLoggedInProviders() map[string]bool {
|
||||
}
|
||||
for _, status := range statuses {
|
||||
if status.HasToken {
|
||||
out[strings.ToLower(strings.TrimPrefix(status.Key, oauth.KeyPrefixProvider))] = true
|
||||
out[strings.TrimPrefix(status.Key, oauth.KeyPrefixProvider)] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// firstUsableProvider returns the saved provider best suited to run without
|
||||
// onboarding: the first usable (inline credential, stored OAuth login, or
|
||||
// no-auth/local) non-local provider, else the first usable local one. It lets
|
||||
// the CLI fall back to an already-configured login when the active provider
|
||||
// happens to lack a credential, instead of re-running onboarding every launch.
|
||||
// onboarding: the first usable (inline credential present, or no-auth/local)
|
||||
// non-local provider, else the first usable local one. It lets the CLI fall back
|
||||
// to an already-configured login when the active provider happens to lack a
|
||||
// credential, instead of re-running onboarding every launch.
|
||||
func firstUsableProvider(providers []config.ProviderProfile) (config.ProviderProfile, bool) {
|
||||
logins := oauthLoggedInProviders()
|
||||
var localFallback config.ProviderProfile
|
||||
haveLocal := false
|
||||
for _, profile := range providers {
|
||||
@@ -374,11 +371,7 @@ func firstUsableProvider(providers []config.ProviderProfile) (config.ProviderPro
|
||||
continue
|
||||
}
|
||||
}
|
||||
// A stored OAuth login (e.g. `zero auth login xai`) is a credential too, even
|
||||
// when the profile has no inline key / env var — mirrors setupRequired and
|
||||
// usableSavedProviders so this fallback doesn't force onboarding for a
|
||||
// provider the user is already authenticated with.
|
||||
if _, missing := setupMissingCredentialEnv(profile); missing && !providerHasOAuthLogin(profile, logins) {
|
||||
if _, missing := setupMissingCredentialEnv(profile); missing {
|
||||
continue
|
||||
}
|
||||
if providerProfileIsLocal(profile) {
|
||||
|
||||
@@ -2,14 +2,11 @@ package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
)
|
||||
|
||||
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,7 +19,6 @@ 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
|
||||
@@ -34,7 +30,6 @@ 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"},
|
||||
@@ -47,10 +42,6 @@ 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"},
|
||||
@@ -65,7 +56,6 @@ 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"},
|
||||
@@ -76,29 +66,6 @@ func TestFirstUsableProviderSkipsUnresolvableCatalogWithoutBaseURL(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// An OAuth-only provider (no inline key, no env var) must be selectable as a
|
||||
// fallback, matching setupRequired/usableSavedProviders — otherwise a fully
|
||||
// authenticated user gets forced back into onboarding when activeProvider
|
||||
// goes stale.
|
||||
func TestFirstUsableProviderRecognizesOAuthLogin(t *testing.T) {
|
||||
withAuthStore(t)
|
||||
store, err := oauth.NewStore(oauth.StoreOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
if err := store.Save(oauth.ProviderKey("xai"), oauth.Token{AccessToken: "tok", ExpiresAt: time.Now().Add(time.Hour)}); err != nil {
|
||||
t.Fatalf("seed token: %v", err)
|
||||
}
|
||||
|
||||
providers := []config.ProviderProfile{
|
||||
{Name: "xai", CatalogID: "xai", APIKeyEnv: "XAI_API_KEY"}, // no inline key/env, but logged in via OAuth
|
||||
}
|
||||
got, ok := firstUsableProvider(providers)
|
||||
if !ok || got.Name != "xai" {
|
||||
t.Fatalf("want OAuth-logged-in provider (xai), got %q ok=%v", got.Name, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderProfileIsLocal(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -122,32 +89,3 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
)
|
||||
|
||||
func UpsertProvider(path string, profile ProviderProfile, setActive bool) (FileConfig, error) {
|
||||
@@ -31,20 +29,6 @@ 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
|
||||
}
|
||||
@@ -55,62 +39,6 @@ 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 == "" {
|
||||
@@ -144,296 +72,6 @@ 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 == "" {
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
@@ -386,545 +385,3 @@ 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")
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ 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"
|
||||
)
|
||||
@@ -144,19 +143,12 @@ 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. 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.
|
||||
// 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).
|
||||
credential := "not set"
|
||||
switch {
|
||||
case profile.HasConfiguredCredential():
|
||||
if strings.TrimSpace(profile.APIKey) != "" || strings.TrimSpace(profile.AuthHeaderValue) != "" {
|
||||
credential = "set"
|
||||
case providerHasStoredOAuthLogin(profile):
|
||||
credential = "oauth login"
|
||||
}
|
||||
details := map[string]any{
|
||||
"name": profile.Name,
|
||||
@@ -177,22 +169,6 @@ 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 {
|
||||
|
||||
@@ -122,17 +122,17 @@ func createSecretFile(path string) ([]byte, error) {
|
||||
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
|
||||
return nil, fmt.Errorf("oauth: create token secret lock: %w", err)
|
||||
}
|
||||
// Remember the lock-creation error itself rather than a subsequent
|
||||
// "secret file doesn't exist yet" read error -- otherwise a real
|
||||
// contention/ACL problem is masked by the expected-while-waiting
|
||||
// ErrNotExist once the retries are exhausted.
|
||||
lastErr = err
|
||||
if data, rerr := readSecretFileRetry(path); rerr == nil {
|
||||
return data, nil
|
||||
} else {
|
||||
lastErr = rerr
|
||||
}
|
||||
time.Sleep(secretRetryDelay)
|
||||
}
|
||||
return nil, fmt.Errorf("oauth: timed out waiting for token secret %s: %w", path, lastErr)
|
||||
if lastErr != nil {
|
||||
return nil, fmt.Errorf("oauth: timed out waiting for token secret %s: %w", path, lastErr)
|
||||
}
|
||||
return nil, fmt.Errorf("oauth: timed out waiting for token secret lock %s", lockPath)
|
||||
}
|
||||
|
||||
func writeNewSecretFile(path string) ([]byte, error) {
|
||||
|
||||
+2
-10
@@ -38,16 +38,8 @@ func ValidateKey(key string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
// ProviderKey builds the store key for a provider login.
|
||||
func ProviderKey(name string) string { return KeyPrefixProvider + 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
|
||||
|
||||
@@ -126,20 +126,3 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -200,23 +200,20 @@ func normalizeHostScope(raw string) string {
|
||||
// directory itself or any descendant; a host grant matches its exact normalized
|
||||
// host. A narrower grant never covers a tool-wide request (reqScope == ""), so
|
||||
// such a request re-prompts (fail-safe).
|
||||
//
|
||||
// File and dir comparisons go through filepath.Rel (via scopePathEqual and the
|
||||
// shared pathWithinRoot helper) rather than a plain string comparison, so they
|
||||
// apply the same case-folding as the workspace-boundary check on Windows --
|
||||
// otherwise a persisted grant (including a deny) could be silently bypassed by
|
||||
// spelling the same path with different case.
|
||||
func grantCovers(grant Grant, reqScope string) bool {
|
||||
switch grant.ScopeKind {
|
||||
case ScopeToolWide:
|
||||
return true
|
||||
case ScopeFile:
|
||||
return reqScope != "" && scopePathEqual(grant.Scope, reqScope)
|
||||
return reqScope != "" && reqScope == grant.Scope
|
||||
case ScopeDir:
|
||||
if reqScope == "" || grant.Scope == "" {
|
||||
return false
|
||||
}
|
||||
return pathWithinRoot(grant.Scope, reqScope)
|
||||
if reqScope == grant.Scope {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(reqScope, grant.Scope+string(filepath.Separator))
|
||||
case ScopeHost:
|
||||
return reqScope != "" && normalizeHostScope(reqScope) == normalizeHostScope(grant.Scope)
|
||||
default:
|
||||
@@ -224,14 +221,6 @@ func grantCovers(grant Grant, reqScope string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// scopePathEqual reports whether two absolute, cleaned scope paths refer to
|
||||
// the same file, applying the same case-folding filepath.Rel already uses for
|
||||
// directory-boundary checks (case-insensitive path components on Windows).
|
||||
func scopePathEqual(a, b string) bool {
|
||||
rel, err := filepath.Rel(a, b)
|
||||
return err == nil && rel == "."
|
||||
}
|
||||
|
||||
// scopeSpecificity ranks scope kinds so the most precise covering allow wins when
|
||||
// several grants match the same request.
|
||||
func scopeSpecificity(kind ScopeKind) int {
|
||||
|
||||
@@ -2,8 +2,6 @@ package sandbox
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -236,25 +234,3 @@ func TestGrantCovers(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantCoversCaseInsensitiveOnWindows guards against a persisted grant
|
||||
// (including a deny) being silently bypassed by spelling the same path with
|
||||
// different case, since Windows path components are case-insensitive.
|
||||
func TestGrantCoversCaseInsensitiveOnWindows(t *testing.T) {
|
||||
if runtime.GOOS != "windows" {
|
||||
t.Skip("path case-insensitivity is a Windows-specific filesystem property")
|
||||
}
|
||||
dir := filepath.Join(string(filepath.Separator)+"proj", "src")
|
||||
file := filepath.Join(dir, "main.go")
|
||||
descendant := filepath.Join(dir, "api", "z.go")
|
||||
|
||||
fileGrant := Grant{Scope: file, ScopeKind: ScopeFile}
|
||||
dirGrant := Grant{Scope: dir, ScopeKind: ScopeDir}
|
||||
|
||||
if !grantCovers(fileGrant, strings.ToUpper(file)) {
|
||||
t.Fatalf("file grant %q should cover differently-cased request %q on Windows", file, strings.ToUpper(file))
|
||||
}
|
||||
if !grantCovers(dirGrant, strings.ToUpper(descendant)) {
|
||||
t.Fatalf("dir grant %q should cover differently-cased descendant %q on Windows", dir, strings.ToUpper(descendant))
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,6 @@ const (
|
||||
secretRetryDelay = 2 * time.Millisecond
|
||||
)
|
||||
|
||||
var openSecretLockFile = os.OpenFile
|
||||
|
||||
// Crypter encrypts a blob at rest with AES-256-GCM under a per-user random secret
|
||||
// persisted (0600) at secretPath.
|
||||
type Crypter struct {
|
||||
@@ -109,7 +107,7 @@ func createSecretFile(path string) ([]byte, error) {
|
||||
lockPath := path + ".lock"
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < secretRetryAttempts; attempt++ {
|
||||
lock, err := openSecretLockFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
lock, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err == nil {
|
||||
_ = lock.Close()
|
||||
defer os.Remove(lockPath)
|
||||
@@ -120,25 +118,20 @@ func createSecretFile(path string) ([]byte, error) {
|
||||
}
|
||||
return writeNewSecretFile(path)
|
||||
}
|
||||
// On Windows a concurrent holder's os.Remove leaves the lock file in a
|
||||
// "delete pending" state, so an O_EXCL create races it with
|
||||
// ERROR_ACCESS_DENIED (os.ErrPermission) rather than ErrExist. Treat that
|
||||
// as contention and retry too -- mirroring oauth's createSecretFile --
|
||||
// otherwise concurrent secret creation spuriously fails on Windows.
|
||||
if !errors.Is(err, os.ErrExist) && !errors.Is(err, os.ErrPermission) {
|
||||
if !errors.Is(err, os.ErrExist) {
|
||||
return nil, fmt.Errorf("securefile: create secret lock: %w", err)
|
||||
}
|
||||
// Remember the lock-creation error itself rather than a subsequent
|
||||
// "secret file doesn't exist yet" read error -- otherwise a real
|
||||
// contention/ACL problem is masked by the expected-while-waiting
|
||||
// ErrNotExist once the retries are exhausted.
|
||||
lastErr = err
|
||||
if data, rerr := readSecretFileRetry(path); rerr == nil {
|
||||
return data, nil
|
||||
} else {
|
||||
lastErr = rerr
|
||||
}
|
||||
time.Sleep(secretRetryDelay)
|
||||
}
|
||||
return nil, fmt.Errorf("securefile: timed out waiting for secret %s: %w", path, lastErr)
|
||||
if lastErr != nil {
|
||||
return nil, fmt.Errorf("securefile: timed out waiting for secret %s: %w", path, lastErr)
|
||||
}
|
||||
return nil, fmt.Errorf("securefile: timed out waiting for secret lock %s", lockPath)
|
||||
}
|
||||
|
||||
func writeNewSecretFile(path string) ([]byte, error) {
|
||||
|
||||
@@ -7,33 +7,6 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreateSecretFileRetriesOnPermissionContention(t *testing.T) {
|
||||
secretPath := filepath.Join(t.TempDir(), "k.secret")
|
||||
attempts := 0
|
||||
origOpen := openSecretLockFile
|
||||
openSecretLockFile = func(path string, flag int, perm os.FileMode) (*os.File, error) {
|
||||
attempts++
|
||||
if attempts == 1 {
|
||||
return nil, os.ErrPermission
|
||||
}
|
||||
return os.OpenFile(path, flag, perm)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
openSecretLockFile = origOpen
|
||||
})
|
||||
|
||||
secret, err := createSecretFile(secretPath)
|
||||
if err != nil {
|
||||
t.Fatalf("createSecretFile should retry on permission contention: %v", err)
|
||||
}
|
||||
if len(secret) != secretBytes {
|
||||
t.Fatalf("secret length = %d, want %d", len(secret), secretBytes)
|
||||
}
|
||||
if attempts < 2 {
|
||||
t.Fatalf("expected at least 2 lock attempts, got %d", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSealOpenRoundTrip(t *testing.T) {
|
||||
secret := filepath.Join(t.TempDir(), "k.secret")
|
||||
c := NewCrypter(secret)
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type ExecMode string
|
||||
@@ -222,24 +221,11 @@ func summarizePayload(payload any) string {
|
||||
text = string(data)
|
||||
}
|
||||
if len(text) > 500 {
|
||||
return truncateUTF8(text, 500)
|
||||
return text[:500]
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
// truncateUTF8 returns the longest prefix of s that is at most n bytes,
|
||||
// backing off to the nearest rune boundary so a multi-byte character isn't
|
||||
// split — a split rune here would embed invalid UTF-8 into the exec prompt.
|
||||
func truncateUTF8(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
for n > 0 && !utf8.RuneStart(s[n]) {
|
||||
n--
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
|
||||
func extractText(value any) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestStoreCreatesAppendsListsAndReadsEvents(t *testing.T) {
|
||||
@@ -641,23 +640,6 @@ func TestFormatExecPromptTruncatesConversationMessagesAfterFilteringNoise(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
// A truncation cut at a raw byte offset can land in the middle of a
|
||||
// multi-byte UTF-8 rune (e.g. CJK text), embedding invalid UTF-8 into the
|
||||
// exec prompt. summarizePayload must back off to a rune boundary instead.
|
||||
func TestSummarizePayloadTruncatesOnRuneBoundary(t *testing.T) {
|
||||
content := strings.Repeat("中文", 300) // 900 bytes of 3-byte runes
|
||||
payload := json.RawMessage(fmt.Sprintf(`{"role":"user","content":%q}`, content))
|
||||
|
||||
got := summarizePayload(payload)
|
||||
|
||||
if !utf8.ValidString(got) {
|
||||
t.Fatalf("summarizePayload produced invalid UTF-8: %q", got)
|
||||
}
|
||||
if len(got) > 500 {
|
||||
t.Fatalf("expected summary to be at most 500 bytes, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareExecPersistsSpecialistMetadataForNewSession(t *testing.T) {
|
||||
store := NewStore(StoreOptions{RootDir: t.TempDir(), Now: fixedClock("2026-06-04T14:30:00Z")})
|
||||
|
||||
|
||||
@@ -207,31 +207,6 @@ func TestDetectShellCommandIssueAllowsUnrelatedCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Operator/utility-name text that only appears inside a quoted argument must
|
||||
// not be mistaken for real shell syntax — e.g. `echo "foo; ls -la"` is a
|
||||
// harmless echo, not a bash-style `ls` invocation, and quoted `head`/`grep`
|
||||
// text isn't a piped POSIX utility.
|
||||
func TestDetectShellCommandIssueIgnoresQuotedContent(t *testing.T) {
|
||||
for _, command := range []string{
|
||||
`echo "foo; ls -la"`,
|
||||
`echo "run head over the file"`,
|
||||
`echo 'ls -la is a common bash command'`,
|
||||
`echo "grep for the pattern"`,
|
||||
} {
|
||||
if issue := detectShellCommandIssue(command, "windows"); issue != nil {
|
||||
t.Fatalf("expected quoted content to be ignored for %q, got %#v", command, issue)
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity: the same utility names still get flagged when they're NOT quoted.
|
||||
// cmd.exe (unlike bash) does not treat ; as a statement separator, so
|
||||
// `echo "foo" ; ls -la` is a single echo invocation with literal
|
||||
// arguments, not a real ls invocation, and must not be flagged.
|
||||
if issue := detectShellCommandIssue(`git log | head`, "windows"); issue == nil {
|
||||
t.Fatalf("expected unquoted shell syntax to still be flagged for %q", `git log | head`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectShellOutputIssueAddsWindowsSyntaxHint(t *testing.T) {
|
||||
issue := detectShellOutputIssue("The syntax of the command is incorrect.", "windows")
|
||||
if issue == nil {
|
||||
|
||||
@@ -289,98 +289,3 @@ func TestDispatchOptionO(t *testing.T) {
|
||||
t.Errorf("model.keyMatch should NOT match ctrl+o when option+o is configured")
|
||||
}
|
||||
}
|
||||
|
||||
// A configured binding equal to a reserved hardcoded chord must be reverted
|
||||
// to default rather than silently making the hardcoded action unreachable
|
||||
// (e.g. configuring toggleDetailed to ctrl+f would otherwise swallow the
|
||||
// /model picker's "favorite" shortcut).
|
||||
func TestSanitizeKeyBindingsDropsReservedCollision(t *testing.T) {
|
||||
cfg := config.KeyBindingsConfig{
|
||||
ToggleDetailed: "ctrl+f", // collides with the hardcoded favorite-model shortcut
|
||||
ToggleMouse: "ctrl+e", // unaffected, should survive untouched
|
||||
}
|
||||
sanitized, warnings := sanitizeKeyBindings(resolveKeyBindings(cfg))
|
||||
|
||||
if !sanitized.toggleDetailed.isZero() {
|
||||
t.Errorf("toggleDetailed should be reverted to default, got %q", sanitized.toggleDetailed.Label())
|
||||
}
|
||||
if sanitized.toggleMouse.isZero() || sanitized.toggleMouse.Label() != "Ctrl+E" {
|
||||
t.Errorf("toggleMouse should be untouched, got %q", sanitized.toggleMouse.Label())
|
||||
}
|
||||
if len(warnings) != 1 {
|
||||
t.Fatalf("want exactly 1 warning, got %d: %v", len(warnings), warnings)
|
||||
}
|
||||
}
|
||||
|
||||
// Two configurable actions bound to the same chord must not both fire —
|
||||
// sanitizeKeyBindings should keep the first and revert the rest to default.
|
||||
func TestSanitizeKeyBindingsDropsMutualCollision(t *testing.T) {
|
||||
cfg := config.KeyBindingsConfig{
|
||||
ToggleDetailed: "ctrl+o",
|
||||
CycleReasoning: "ctrl+o", // collides with toggleDetailed above
|
||||
}
|
||||
sanitized, warnings := sanitizeKeyBindings(resolveKeyBindings(cfg))
|
||||
|
||||
if sanitized.toggleDetailed.isZero() || sanitized.toggleDetailed.Label() != "Ctrl+O" {
|
||||
t.Errorf("toggleDetailed (first claimant) should keep ctrl+o, got %q", sanitized.toggleDetailed.Label())
|
||||
}
|
||||
if !sanitized.cycleReasoning.isZero() {
|
||||
t.Errorf("cycleReasoning (second claimant) should be reverted to default, got %q", sanitized.cycleReasoning.Label())
|
||||
}
|
||||
if len(warnings) != 1 {
|
||||
t.Fatalf("want exactly 1 warning, got %d: %v", len(warnings), warnings)
|
||||
}
|
||||
}
|
||||
|
||||
// Non-colliding configured bindings must pass through unchanged with no
|
||||
// warnings.
|
||||
func TestSanitizeKeyBindingsNoCollisions(t *testing.T) {
|
||||
cfg := config.KeyBindingsConfig{
|
||||
ToggleDetailed: "option+o",
|
||||
ToggleSidebar: "option+b",
|
||||
}
|
||||
sanitized, warnings := sanitizeKeyBindings(resolveKeyBindings(cfg))
|
||||
|
||||
if len(warnings) != 0 {
|
||||
t.Fatalf("want no warnings, got %v", warnings)
|
||||
}
|
||||
if sanitized.toggleDetailed.Label() != "Alt+O" || sanitized.toggleSidebar.Label() != "Alt+B" {
|
||||
t.Fatalf("bindings should be unchanged: toggleDetailed=%q toggleSidebar=%q",
|
||||
sanitized.toggleDetailed.Label(), sanitized.toggleSidebar.Label())
|
||||
}
|
||||
}
|
||||
|
||||
// A configured binding must not shadow another action that is still on its
|
||||
// built-in default chord.
|
||||
func TestSanitizeKeyBindingsDropsCollisionWithOtherDefault(t *testing.T) {
|
||||
cfg := config.KeyBindingsConfig{
|
||||
ToggleDetailed: "ctrl+t", // collides with cycleReasoning default
|
||||
}
|
||||
sanitized, warnings := sanitizeKeyBindings(resolveKeyBindings(cfg))
|
||||
|
||||
if !sanitized.toggleDetailed.isZero() {
|
||||
t.Errorf("toggleDetailed should be reverted to default, got %q", sanitized.toggleDetailed.Label())
|
||||
}
|
||||
if !sanitized.cycleReasoning.isZero() {
|
||||
t.Errorf("cycleReasoning should remain default (zero), got %q", sanitized.cycleReasoning.Label())
|
||||
}
|
||||
if len(warnings) != 1 {
|
||||
t.Fatalf("want exactly 1 warning, got %d: %v", len(warnings), warnings)
|
||||
}
|
||||
}
|
||||
|
||||
// Bare hardcoded keys handled directly in updateModel (e.g. Tab navigation)
|
||||
// are reserved too and must not be shadowed by configurable bindings.
|
||||
func TestSanitizeKeyBindingsDropsBareHardcodedCollision(t *testing.T) {
|
||||
cfg := config.KeyBindingsConfig{
|
||||
ToggleDetailed: "tab",
|
||||
}
|
||||
sanitized, warnings := sanitizeKeyBindings(resolveKeyBindings(cfg))
|
||||
|
||||
if !sanitized.toggleDetailed.isZero() {
|
||||
t.Errorf("toggleDetailed should be reverted to default, got %q", sanitized.toggleDetailed.Label())
|
||||
}
|
||||
if len(warnings) != 1 {
|
||||
t.Fatalf("want exactly 1 warning, got %d: %v", len(warnings), warnings)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package tui
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -11,10 +10,8 @@ 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"
|
||||
@@ -496,37 +493,29 @@ 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.
|
||||
// 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) {
|
||||
func (m model) switchProviderModel(providerName, modelID string) (model, string, tea.Cmd) {
|
||||
if m.pending {
|
||||
return m, "Model\nCannot switch providers while a run is active.", false, nil
|
||||
return m, "Model\nCannot switch providers while a run is active.", nil
|
||||
}
|
||||
if m.newProvider == nil {
|
||||
return m, "Model\nProvider rebuild is not available for this TUI session.", false, nil
|
||||
return m, "Model\nProvider rebuild is not available for this TUI session.", nil
|
||||
}
|
||||
target, ok := m.savedProviderByName(providerName)
|
||||
if !ok {
|
||||
return m, "Model\nunknown provider " + strconv.Quote(providerName), false, nil
|
||||
return m, "Model\nunknown provider " + strconv.Quote(providerName), 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,
|
||||
// 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
|
||||
// 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
|
||||
}
|
||||
next, err := m.newProvider(target)
|
||||
if err != nil {
|
||||
return m, "Model\n" + redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{target.APIKey}}), false, nil
|
||||
return m, "Model\n" + redaction.RedactString(err.Error(), redaction.Options{ExtraSecretValues: []string{target.APIKey}}), nil
|
||||
}
|
||||
m.provider = next
|
||||
m.providerProfile = target
|
||||
@@ -554,80 +543,32 @@ func (m model) switchProviderModel(providerName, modelID string) (model, string,
|
||||
if warn := m.visionDropWarning(); warn != "" {
|
||||
status += "\n" + warn
|
||||
}
|
||||
return m, status, true, tea.Batch(cmds...)
|
||||
return m, status, 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.
|
||||
// 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.
|
||||
// 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.
|
||||
func (m model) profileWithCredential(profile config.ProviderProfile) config.ProviderProfile {
|
||||
if strings.TrimSpace(profile.APIKey) == "" {
|
||||
if store, err := m.providerKeyStore(); err == nil {
|
||||
if store, err := config.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 {
|
||||
|
||||
@@ -419,23 +419,12 @@ 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: "+credentialState,
|
||||
"api key: "+apiKeyState(snapshot.APIKeySet),
|
||||
)
|
||||
if snapshot.Message != "" {
|
||||
profileLines = append(profileLines, "provider status: "+snapshot.Status+" - "+snapshot.Message)
|
||||
@@ -443,7 +432,7 @@ func (m model) providerText() string {
|
||||
|
||||
status := commandStatusOK
|
||||
actionLines := providerNextActionLines(m.providerProfile, snapshot, m.providerName)
|
||||
if providerCredentialRequired(m.providerProfile, snapshot.ProviderKind) && !providerProfileHasCredential(m.providerProfile) && !snapshot.OAuthLogin {
|
||||
if providerCredentialRequired(m.providerProfile, snapshot.ProviderKind) && !providerProfileHasCredential(m.providerProfile) {
|
||||
status = commandStatusWarning
|
||||
}
|
||||
return renderCommandOutput(commandOutput{
|
||||
|
||||
@@ -79,9 +79,9 @@ type parsedCommand struct {
|
||||
var commandDefinitions = []commandDefinition{
|
||||
{
|
||||
name: "/provider",
|
||||
usage: "/provider [add|status]",
|
||||
usage: "/provider [status]",
|
||||
group: commandGroupModel,
|
||||
description: "Manage providers: activate, add, edit, delete.",
|
||||
description: "Open provider setup.",
|
||||
kind: commandProvider,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -45,7 +45,7 @@ func TestFormatCommandHelpLinesGroupsCommandsByStableOrder(t *testing.T) {
|
||||
|
||||
for _, want := range []string{
|
||||
"model:",
|
||||
" /provider [add|status] - Manage providers: activate, add, edit, delete.",
|
||||
" /provider [status] - Open provider setup.",
|
||||
" /model [list|id] - Show or switch the active model.",
|
||||
" /effort [list|low|medium|high|auto] - Show or set reasoning effort for supported models.",
|
||||
"session:",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -264,96 +263,3 @@ func (m model) keyMatch(b parsedBinding, msg tea.KeyMsg, defaultFn func(tea.KeyM
|
||||
}
|
||||
return defaultFn(msg)
|
||||
}
|
||||
|
||||
// reservedBindings lists hardcoded (non-configurable) chords handled directly in
|
||||
// model.go's key dispatch. If a configurable binding uses one of these chords,
|
||||
// one of the actions becomes unreachable (depending on switch order), so
|
||||
// sanitizeKeyBindings reverts the configurable binding back to its default.
|
||||
var reservedBindings = []struct {
|
||||
binding parsedBinding
|
||||
description string
|
||||
}{
|
||||
{parseBinding("ctrl+c"), "cancel / exit"},
|
||||
{parseBinding("esc"), "cancel / close"},
|
||||
{parseBinding("enter"), "submit"},
|
||||
{parseBinding("shift+tab"), "cycle permission mode"},
|
||||
{parseBinding("tab"), "navigation / completion"},
|
||||
{parseBinding("backspace"), "composer edit / attachment removal"},
|
||||
{parseBinding("up"), "history/navigation"},
|
||||
{parseBinding("down"), "history/navigation"},
|
||||
{parseBinding("pgup"), "transcript scroll"},
|
||||
{parseBinding("pgdown"), "transcript scroll"},
|
||||
{parseBinding("ctrl+f"), "favorite model (in the /model picker)"},
|
||||
{parseBinding("?"), "help overlay"},
|
||||
}
|
||||
|
||||
// sanitizeKeyBindings drops (reverts to default) any configured binding that
|
||||
// collides with a reserved hardcoded chord above, or with another
|
||||
// configured binding, since either collision would silently make one of the
|
||||
// two actions permanently unreachable. Returns the sanitized bindings plus a
|
||||
// human-readable warning for each dropped binding, for the caller to surface
|
||||
// as a startup notice.
|
||||
func sanitizeKeyBindings(b keyBindings) (keyBindings, []string) {
|
||||
entries := []struct {
|
||||
name string
|
||||
binding *parsedBinding
|
||||
defaultBinding parsedBinding
|
||||
}{
|
||||
{"toggleDetailed", &b.toggleDetailed, parseBinding("ctrl+o")},
|
||||
{"toggleMouse", &b.toggleMouse, parseBinding("ctrl+e")},
|
||||
{"cycleReasoning", &b.cycleReasoning, parseBinding("ctrl+t")},
|
||||
{"togglePlan", &b.togglePlan, parseBinding("ctrl+p")},
|
||||
{"toggleSidebar", &b.toggleSidebar, parseBinding("ctrl+b")},
|
||||
}
|
||||
|
||||
var warnings []string
|
||||
for _, e := range entries {
|
||||
if e.binding.isZero() {
|
||||
continue
|
||||
}
|
||||
for _, other := range entries {
|
||||
if other.name == e.name || !other.binding.isZero() {
|
||||
continue
|
||||
}
|
||||
if *e.binding == other.defaultBinding {
|
||||
warnings = append(warnings, fmt.Sprintf(
|
||||
"keybindings.%s (%s) conflicts with keybindings.%s default (%s); using the default instead.",
|
||||
e.name, e.binding.Label(), other.name, other.defaultBinding.Label()))
|
||||
*e.binding = parsedBinding{}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if e.binding.isZero() {
|
||||
continue
|
||||
}
|
||||
for _, reserved := range reservedBindings {
|
||||
if *e.binding == reserved.binding {
|
||||
warnings = append(warnings, fmt.Sprintf(
|
||||
"keybindings.%s (%s) conflicts with the built-in %s shortcut; using the default instead.",
|
||||
e.name, e.binding.Label(), reserved.description))
|
||||
*e.binding = parsedBinding{}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
claimedBy := map[parsedBinding]string{}
|
||||
for _, e := range entries {
|
||||
if e.binding.isZero() {
|
||||
continue
|
||||
}
|
||||
if other, ok := claimedBy[*e.binding]; ok {
|
||||
warnings = append(warnings, fmt.Sprintf(
|
||||
"keybindings.%s (%s) conflicts with keybindings.%s; using the default instead.",
|
||||
e.name, e.binding.Label(), other))
|
||||
*e.binding = parsedBinding{}
|
||||
continue
|
||||
}
|
||||
claimedBy[*e.binding] = e.name
|
||||
}
|
||||
|
||||
return b, warnings
|
||||
}
|
||||
+8
-27
@@ -62,7 +62,6 @@ const dragEdgeScrollStep = 1
|
||||
type model struct {
|
||||
ctx context.Context
|
||||
cwd string
|
||||
appVersion string
|
||||
userCommands []usercommands.Command // file-sourced /commands (.zero/commands)
|
||||
loadSkills func() []skills.Skill // lazy installed-skills loader for /skills + /<skill-name>
|
||||
userConfigPath string
|
||||
@@ -745,12 +744,9 @@ func newModel(ctx context.Context, options Options) model {
|
||||
notify.MaybeAddWebhookSink(notifier, os.Getenv, nil)
|
||||
notifier.SetFocused(true)
|
||||
|
||||
resolvedKeyBindings, keyBindingWarnings := sanitizeKeyBindings(resolveKeyBindings(options.KeyBindings))
|
||||
|
||||
m := model{
|
||||
ctx: ctx,
|
||||
cwd: cwd,
|
||||
appVersion: strings.TrimSpace(options.Version),
|
||||
swarmDoneAt: map[string]time.Time{},
|
||||
userCommands: loadedUserCommands,
|
||||
loadSkills: options.LoadSkills,
|
||||
@@ -785,7 +781,7 @@ func newModel(ctx context.Context, options Options) model {
|
||||
permissionMode: permissionMode,
|
||||
reasoningEffort: options.ReasoningEffort,
|
||||
responseStyle: defaultedResponseStyle(options.ResponseStyle),
|
||||
keyBindings: resolvedKeyBindings,
|
||||
keyBindings: resolveKeyBindings(options.KeyBindings),
|
||||
themeMode: resolveThemeMode(options.Theme, os.Getenv("ZERO_THEME"), options.SavedTheme),
|
||||
hasDarkBg: true,
|
||||
userAgent: options.UserAgent,
|
||||
@@ -821,9 +817,6 @@ func newModel(ctx context.Context, options Options) model {
|
||||
m.lspManager = lsp.NewManager(cwd)
|
||||
}
|
||||
m.refreshMCPViewState()
|
||||
for _, warning := range keyBindingWarnings {
|
||||
m = m.appendSystemNotice(warning)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -1104,10 +1097,6 @@ 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
|
||||
@@ -1234,10 +1223,8 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m.resolvePermission(permissionDecisionDeny)
|
||||
}
|
||||
if m.providerWizard != 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)
|
||||
m.providerWizard = nil
|
||||
return m, nil
|
||||
}
|
||||
if m.mcpAddWizard != nil {
|
||||
m.mcpAddWizard = nil
|
||||
@@ -3768,7 +3755,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)
|
||||
}
|
||||
@@ -3961,20 +3948,14 @@ func (m model) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
case commandSandboxSetup:
|
||||
return m.startSandboxSetupCommand(command.text)
|
||||
case commandProvider:
|
||||
arg := strings.ToLower(strings.TrimSpace(command.text))
|
||||
if arg == "" || arg == "add" {
|
||||
if strings.TrimSpace(command.text) == "" {
|
||||
if m.pending {
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: pickerBusyText(command.name)})
|
||||
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.providerWizard = m.newProviderWizard()
|
||||
m.clearSuggestions()
|
||||
return m, nil
|
||||
}
|
||||
m.transcript = reduceTranscript(m.transcript, transcriptAction{kind: actionAppendSystem, text: m.providerText()})
|
||||
return m, nil
|
||||
|
||||
@@ -586,13 +586,6 @@ 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
|
||||
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
// Options configures the reusable Zero terminal UI shell.
|
||||
type Options struct {
|
||||
Cwd string
|
||||
Version string // CLI build version, shown on the home screen; empty hides it
|
||||
UserConfigPath string
|
||||
DoctorUserConfigPath string
|
||||
ProjectConfigPath string
|
||||
|
||||
@@ -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 := m.providerKeyStore(); err == nil {
|
||||
if store, err := config.ProviderKeyStore(); err == nil {
|
||||
authed = config.ApplyStoredAPIKey(authed, store)
|
||||
}
|
||||
key := strings.TrimSpace(authed.APIKey)
|
||||
|
||||
@@ -14,7 +14,6 @@ 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"
|
||||
@@ -966,9 +965,9 @@ func TestSwitchProviderModelWarmsDiscoveryForTheNewProvider(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
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)
|
||||
@@ -1023,76 +1022,3 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -1,828 +0,0 @@
|
||||
// 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),
|
||||
}
|
||||
}
|
||||
@@ -1,647 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -50,18 +50,6 @@ 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.
|
||||
@@ -153,48 +141,6 @@ 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.
|
||||
@@ -303,12 +249,6 @@ 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.
|
||||
@@ -379,20 +319,6 @@ 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 {
|
||||
@@ -698,26 +624,6 @@ 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 {
|
||||
@@ -726,10 +632,6 @@ 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 {
|
||||
@@ -897,13 +799,6 @@ 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
|
||||
}
|
||||
@@ -1117,9 +1012,6 @@ 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
|
||||
@@ -1239,23 +1131,13 @@ 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()),
|
||||
)
|
||||
|
||||
title := "Provider setup"
|
||||
if wizard.managerStep() {
|
||||
title = "Providers"
|
||||
}
|
||||
block := styledBlockFillTitle(overlayWidth, title, lines, zeroTheme.lineStrong, lipgloss.NewStyle())
|
||||
block := styledBlockFillTitle(overlayWidth, "Provider setup", lines, zeroTheme.lineStrong, lipgloss.NewStyle())
|
||||
if width > overlayWidth {
|
||||
return indentBlock(block, (width-overlayWidth)/2)
|
||||
}
|
||||
@@ -1263,30 +1145,8 @@ 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:
|
||||
@@ -1329,7 +1189,7 @@ func providerWizardOverlayWidth(width int, step providerWizardStep) int {
|
||||
switch step {
|
||||
case providerWizardStepProvider:
|
||||
target = providerWizardProviderWidth
|
||||
case providerWizardStepModel, providerWizardStepManage:
|
||||
case providerWizardStepModel:
|
||||
target = providerWizardModelWidth
|
||||
}
|
||||
target = minInt(target, width)
|
||||
@@ -1343,14 +1203,6 @@ 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,14 +1,10 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/providercatalog"
|
||||
)
|
||||
|
||||
@@ -388,64 +384,3 @@ 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)
|
||||
}
|
||||
}
|
||||
+3
-21
@@ -92,14 +92,10 @@ func (m model) emptyStateLines(width int) []string {
|
||||
// emptyStateExamples seeds the first prompt with a few representative asks.
|
||||
const emptyStateExamples = `Try "explain this codebase" · "fix the failing test" · "add a --json flag"`
|
||||
|
||||
// emptyStateOrientation renders a faint "version · cwd · branch · model" line
|
||||
// for the home screen, omitting any piece that's unknown. Empty when nothing
|
||||
// is known.
|
||||
// emptyStateOrientation renders a faint "cwd · branch · model" line for the home
|
||||
// screen, omitting any piece that's unknown. Empty when nothing is known.
|
||||
func (m model) emptyStateOrientation() string {
|
||||
parts := make([]string, 0, 4)
|
||||
if version := displayVersion(m.appVersion); version != "" {
|
||||
parts = append(parts, version)
|
||||
}
|
||||
parts := make([]string, 0, 3)
|
||||
if cwd := strings.TrimSpace(m.cwd); cwd != "" {
|
||||
parts = append(parts, shortenPath(cwd))
|
||||
}
|
||||
@@ -115,20 +111,6 @@ func (m model) emptyStateOrientation() string {
|
||||
return zeroTheme.faint.Render(strings.Join(parts, " · "))
|
||||
}
|
||||
|
||||
// displayVersion formats the CLI build version for display: numeric releases
|
||||
// gain a "v" prefix (0.2.0 → v0.2.0), anything else (dev, git SHAs) passes
|
||||
// through unchanged. Empty stays empty so unset builds show nothing.
|
||||
func displayVersion(version string) string {
|
||||
version = strings.TrimSpace(version)
|
||||
if version == "" {
|
||||
return ""
|
||||
}
|
||||
if version[0] >= '0' && version[0] <= '9' {
|
||||
return "v" + version
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
func zeroWordmarkLines() []string {
|
||||
lines := make([]string, 0, minInt(len(zeroWordmarkPrefixLines), len(zeroWordmarkOLines)))
|
||||
for index := 0; index < len(zeroWordmarkPrefixLines) && index < len(zeroWordmarkOLines); index++ {
|
||||
|
||||
@@ -21,29 +21,6 @@ func TestEmptyStateShowsBrandAndTaglineOnly(t *testing.T) {
|
||||
assertNotContains(t, view, "fix the failing test in internal/tools")
|
||||
}
|
||||
|
||||
func TestEmptyStateShowsVersion(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{Version: "0.2.0"})
|
||||
m.width, m.height = 100, 30
|
||||
|
||||
view := plainRender(t, m.View())
|
||||
assertContains(t, view, "v0.2.0")
|
||||
}
|
||||
|
||||
func TestDisplayVersion(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"0.2.0", "v0.2.0"},
|
||||
{"v0.2.0", "v0.2.0"},
|
||||
{"dev", "dev"},
|
||||
{" ", ""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := displayVersion(tc.in); got != tc.want {
|
||||
t.Fatalf("displayVersion(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyStateDisappearsAfterFirstRow(t *testing.T) {
|
||||
m := newModel(context.Background(), Options{})
|
||||
m.width, m.height = 100, 30
|
||||
|
||||
@@ -148,8 +148,8 @@ func applyStandaloneUpdate(ctx context.Context, result Result, executablePath st
|
||||
if err := downloadFile(downloadCtx, asset.ChecksumURL, checksumPath); err != nil {
|
||||
return nil, fmt.Errorf("download release checksum: %w", err)
|
||||
}
|
||||
if err := verifyArchiveChecksum(checksumPath, asset.ArchiveName); err != nil {
|
||||
return nil, err
|
||||
if _, err := release.VerifySHA256Checksum(checksumPath); err != nil {
|
||||
return nil, fmt.Errorf("verify release checksum: %w", err)
|
||||
}
|
||||
|
||||
extractDir := filepath.Join(tempDir, "extracted")
|
||||
@@ -200,23 +200,6 @@ func applyStandaloneUpdate(ctx context.Context, result Result, executablePath st
|
||||
return warnings, nil
|
||||
}
|
||||
|
||||
// verifyArchiveChecksum verifies the checksum file at checksumPath and
|
||||
// cross-checks it names expectedArchiveName. VerifySHA256Checksum hashes
|
||||
// whichever file the checksum text names, not necessarily the archive we
|
||||
// downloaded — this mirrors the cross-check VerifyReleaseChecksums already
|
||||
// does, so a checksum file that names a different file can't be used to
|
||||
// verify (and thereby vouch for) the wrong bytes before extraction.
|
||||
func verifyArchiveChecksum(checksumPath string, expectedArchiveName string) error {
|
||||
verified, err := release.VerifySHA256Checksum(checksumPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("verify release checksum: %w", err)
|
||||
}
|
||||
if verified.ArchiveName != expectedArchiveName {
|
||||
return fmt.Errorf("checksum file references %s, expected %s", verified.ArchiveName, expectedArchiveName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// installBinary stages sourcePath next to targetPath (same directory, so the
|
||||
// final rename is atomic/same-filesystem) and then swaps it into place.
|
||||
func installBinary(sourcePath string, targetPath string) error {
|
||||
|
||||
@@ -300,38 +300,3 @@ func TestApplyStandaloneUpdateRejectsChecksumMismatch(t *testing.T) {
|
||||
t.Fatalf("executable should be untouched after checksum failure, got %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
// VerifySHA256Checksum hashes whichever file the checksum text names, which
|
||||
// isn't necessarily the archive the caller asked to verify. A checksum file
|
||||
// that correctly verifies against a DIFFERENT (but real, correctly-hashed)
|
||||
// file must still be rejected, not treated as vouching for the requested
|
||||
// archive.
|
||||
func TestVerifyArchiveChecksumRejectsFilenameMismatch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
decoyName := "decoy.tar.gz"
|
||||
decoyPath := filepath.Join(dir, decoyName)
|
||||
if err := os.WriteFile(decoyPath, []byte("decoy contents"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile decoy: %v", err)
|
||||
}
|
||||
decoyChecksum, err := release.SHA256File(decoyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("SHA256File: %v", err)
|
||||
}
|
||||
checksumText, err := release.FormatSHA256Checksum(decoyChecksum, decoyName)
|
||||
if err != nil {
|
||||
t.Fatalf("FormatSHA256Checksum: %v", err)
|
||||
}
|
||||
checksumPath := filepath.Join(dir, "real.tar.gz.sha256")
|
||||
if err := os.WriteFile(checksumPath, []byte(checksumText), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile checksum: %v", err)
|
||||
}
|
||||
|
||||
err = verifyArchiveChecksum(checksumPath, "real.tar.gz")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when checksum file references a different archive")
|
||||
}
|
||||
if !strings.Contains(err.Error(), decoyName) || !strings.Contains(err.Error(), "real.tar.gz") {
|
||||
t.Fatalf("expected error to name both the referenced file and the expected one, got %q", err)
|
||||
}
|
||||
}
|
||||
@@ -87,14 +87,6 @@ func extractZip(archivePath string, destDir string) error {
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Release archives only ever contain regular files and directories;
|
||||
// reject anything else (symlinks, devices) rather than silently write
|
||||
// the link-target string (or other special content) out as an
|
||||
// ordinary file — mirrors extractTarGz's rejection of non-regular tar
|
||||
// entries.
|
||||
if !entry.Mode().IsRegular() {
|
||||
return fmt.Errorf("unsupported archive entry type for %s", entry.Name)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -148,46 +148,6 @@ func TestExtractZipRejectsPathTraversal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A zip entry with the Unix symlink mode bit set must be rejected, not
|
||||
// silently written out as an ordinary file containing the link-target
|
||||
// string — mirroring extractTarGz's rejection of non-regular tar entries.
|
||||
func TestExtractZipRejectsSymlinkEntry(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
archivePath := filepath.Join(dir, "archive.zip")
|
||||
|
||||
file, err := os.Create(archivePath)
|
||||
if err != nil {
|
||||
t.Fatalf("Create archive: %v", err)
|
||||
}
|
||||
zipWriter := zip.NewWriter(file)
|
||||
header := &zip.FileHeader{Name: "zero"}
|
||||
header.SetMode(os.ModeSymlink | 0o777)
|
||||
writer, err := zipWriter.CreateHeader(header)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateHeader: %v", err)
|
||||
}
|
||||
if _, err := writer.Write([]byte("/some/other/path")); err != nil {
|
||||
t.Fatalf("Write symlink target: %v", err)
|
||||
}
|
||||
if err := zipWriter.Close(); err != nil {
|
||||
t.Fatalf("close zip writer: %v", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatalf("close archive file: %v", err)
|
||||
}
|
||||
|
||||
destDir := filepath.Join(dir, "extracted")
|
||||
if err := os.MkdirAll(destDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := extractArchive(archivePath, destDir); err == nil {
|
||||
t.Fatal("expected extractArchive to reject a symlink entry")
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(destDir, "zero")); err == nil {
|
||||
t.Fatal("symlink entry should not have been written to the destination")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindByBasenameSearchesRecursively(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
nested := filepath.Join(dir, "helpers")
|
||||
|
||||
@@ -5,12 +5,6 @@ package update
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
restoreRenameRetryAttempts = 10
|
||||
restoreRenameRetryDelay = 100 * time.Millisecond
|
||||
)
|
||||
|
||||
// replaceBinary installs newPath over targetPath. Windows will not let a
|
||||
@@ -24,11 +18,7 @@ func replaceBinary(targetPath string, newPath string) error {
|
||||
return fmt.Errorf("rename running binary aside: %w", err)
|
||||
}
|
||||
if err := os.Rename(newPath, targetPath); err != nil {
|
||||
// Retry the restore: a transient Windows file lock (antivirus/indexer
|
||||
// scanning the just-renamed file, a lingering handle) can make a rename
|
||||
// fail momentarily, and here failure means targetPath is left missing
|
||||
// entirely rather than merely stale — worth a short retry to avoid that.
|
||||
if restoreErr := renameWithRetry(oldPath, targetPath); restoreErr != nil {
|
||||
if restoreErr := os.Rename(oldPath, targetPath); restoreErr != nil {
|
||||
return fmt.Errorf("install new binary: %w; additionally failed to restore the original binary: %v (original preserved at %s)", err, restoreErr, oldPath)
|
||||
}
|
||||
return fmt.Errorf("install new binary: %w", err)
|
||||
@@ -36,19 +26,6 @@ func replaceBinary(targetPath string, newPath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func renameWithRetry(oldPath string, newPath string) error {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < restoreRenameRetryAttempts; attempt++ {
|
||||
if err := os.Rename(oldPath, newPath); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
time.Sleep(restoreRenameRetryDelay)
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// CleanupStaleBinary best-effort removes a "<path>.old" file left behind by a
|
||||
// previous replaceBinary call once the old process holding it has exited.
|
||||
// Callers should invoke this once at startup for the current executable.
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package update
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReplaceBinaryReplacesRunningBinary(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
targetPath := filepath.Join(dir, "zero.exe")
|
||||
newPath := filepath.Join(dir, "zero.exe.new")
|
||||
|
||||
if err := os.WriteFile(targetPath, []byte("old-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile target: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(newPath, []byte("new-binary"), 0o755); err != nil {
|
||||
t.Fatalf("WriteFile new: %v", err)
|
||||
}
|
||||
|
||||
if err := replaceBinary(targetPath, newPath); err != nil {
|
||||
t.Fatalf("replaceBinary: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile target: %v", err)
|
||||
}
|
||||
if string(data) != "new-binary" {
|
||||
t.Fatalf("target content = %q, want %q", data, "new-binary")
|
||||
}
|
||||
if _, err := os.Stat(targetPath + ".old"); err != nil {
|
||||
t.Fatalf("expected the original binary to be preserved at %s.old: %v", targetPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenameWithRetrySucceedsImmediately(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := filepath.Join(dir, "src")
|
||||
dst := filepath.Join(dir, "dst")
|
||||
if err := os.WriteFile(src, []byte("data"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile src: %v", err)
|
||||
}
|
||||
|
||||
if err := renameWithRetry(src, dst); err != nil {
|
||||
t.Fatalf("renameWithRetry: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(dst); err != nil {
|
||||
t.Fatalf("expected dst to exist after rename: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A permanently-failing rename (source never appears) must exhaust its
|
||||
// retries and surface the underlying error, rather than retrying forever.
|
||||
func TestRenameWithRetryFailsAfterExhaustingAttempts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
missing := filepath.Join(dir, "does-not-exist")
|
||||
dst := filepath.Join(dir, "dst")
|
||||
|
||||
if err := renameWithRetry(missing, dst); err == nil {
|
||||
t.Fatal("expected renameWithRetry to fail for a source that never appears")
|
||||
}
|
||||
}
|
||||
@@ -49,12 +49,8 @@ type ProviderSnapshot struct {
|
||||
APIModel string `json:"apiModel,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
APIKeySet bool `json:"apiKeySet"`
|
||||
// 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"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type ModelSnapshotOptions struct {
|
||||
@@ -168,13 +164,10 @@ func ProviderSnapshotFromProfile(profile config.ProviderProfile, active bool) Pr
|
||||
BaseURL: redactProviderBaseURL(profile.BaseURL, profile.APIKey, profile.AuthHeaderValue),
|
||||
Model: profile.Model,
|
||||
Active: active,
|
||||
// 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(),
|
||||
// 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) != "",
|
||||
Status: "ok",
|
||||
}
|
||||
metadata, err := providers.ResolveRuntimeMetadata(profile, providers.Options{})
|
||||
|
||||
@@ -340,9 +340,6 @@ 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},
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@gitlawb/zero",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@gitlawb/zero",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.0",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@gitlawb/zero",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.0",
|
||||
"description": "Zero — a fast, multi-provider terminal coding agent.",
|
||||
"type": "module",
|
||||
"module": "bin/zero.js",
|
||||
|
||||
Reference in new issue
Block a user