mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 13:11:30 -10:00
* fix(auth): propagate credentials to every provider-build surface and pin children to the live provider Sub-agents, swarm members, ACP sessions, and mid-run escalation switches could build providers without the credentials the parent was running with. This consolidates credential resolution so every surface gets the same auth: - Wrap deps.newProvider once in fillAppDeps to fold the stored API key in centrally; ACP, exec escalation, and buildProvider no longer patch it per call site (an apiKeyStored profile previously went out keyless on those paths and 401'd). - Export ZERO_PROVIDER at startup (buildProvider) and keep exporting on every runtime switch (wizard, onboarding, /model, /provider), so spawned children resolve the parent's live provider instead of whatever another process last persisted to config.json. Injected via appDeps so tests never mutate the process environment. - Share one OAuth login selection end to end: ProviderProfile. OAuthLoginCandidates (name, then catalog-ID fallback; nothing for a profile with its own configured credential) + oauth.FirstStored resolve the login ONCE, and the bound store key is threaded through providers.Options.OAuthLoginKey so the Codex account-id header always reads the same login that issued the bearer token. A renamed profile (name != catalog ID) now finds its login; an explicit API key is never silently overridden by a same-name or catalog-shared OAuth login. - Export ProviderProfile.HasConfiguredCredential as the single "profile is key-authed" predicate (cli/tui/config previously had three copies). - applyProviderWizard commits live state (provider, profile, name, env export) only after BOTH the build and the config persist succeed, so a persist failure no longer leaves the chat running on a provider the status line and children disagree with. Covered by red-green tests for each path: stored-key wrap, escalation switch, catalog-ID fallback (incl. case-variant names), explicit-key protection, same-login account selection, wizard persist-failure rollback, onboarding/startup env export. * test: isolate OAuth/config test storage and fix the flaky MCP response-id test Review + CI fixes, all test-only: - factory/cli OAuth tests: pin ZERO_OAUTH_STORAGE=file next to the temp ZERO_OAUTH_TOKENS_PATH — an inherited "keyring" value sends NewStore to the OS keychain and ignores the temp path entirely, so the tests could read (or clobber) the developer's real logins. newOAuthStoreForTest now owns both pins so a future call site can't forget them. - TestApplyProviderWizardExportsActiveProviderEnv: point m.userConfigPath at a temp config and stub newProvider, so the SUCCESS path exercises the full build+persist+commit sequence in isolation (the default empty path silently skipped persist; a changed default must never reach the real user config). - TestClientRequestWaitsForMatchingResponseID (ubuntu smoke flake): the responses were pre-loaded into a bytes.Buffer, so the client's reader goroutine could consume the matching response — and hit EOF — before request() registered its pending id, failing with "request() error = EOF" on fast runners. The fake server now sits on pipes and responds only after reading the outgoing request, matching production ordering; survives -race -count=50.
74 lines
2.6 KiB
Go
74 lines
2.6 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/Gitlawb/zero/internal/config"
|
|
"github.com/Gitlawb/zero/internal/oauth"
|
|
"github.com/Gitlawb/zero/internal/providers/providerio"
|
|
)
|
|
|
|
// oauthLoginForProfile resolves the user's OAuth login for a provider ONCE and
|
|
// returns both a TokenResolver that authenticates model calls with it and the
|
|
// credential-store key it bound to. It returns (nil, "") when no login exists —
|
|
// keeping API-key users free of any per-request store lookups, since the resolver
|
|
// is only attached when a login is present at construction time.
|
|
//
|
|
// The returned key is the single source of truth for "which login is this
|
|
// provider using": callers pass it to providers.Options.OAuthLoginKey so the
|
|
// Codex chatgpt-account-id header reads its account from the exact login that
|
|
// issued the bearer token, instead of doing a second, independent lookup that
|
|
// could select a different login (a backend-rejected mismatch).
|
|
//
|
|
// Candidate login names (profile name, then a catalog-ID fallback, both gated on
|
|
// the profile having no own configured credential) come from the shared
|
|
// ProviderProfile.OAuthLoginCandidates so the runtime resolver, the Codex account
|
|
// resolver, and the onboarding presence check never diverge.
|
|
func oauthLoginForProfile(profile config.ProviderProfile) (providerio.TokenResolver, string) {
|
|
candidates := profile.OAuthLoginCandidates()
|
|
if len(candidates) == 0 {
|
|
return nil, ""
|
|
}
|
|
store, err := oauth.NewStore(oauth.StoreOptions{})
|
|
if err != nil {
|
|
return nil, ""
|
|
}
|
|
_, key, ok := oauth.FirstStored(store, candidates)
|
|
if !ok {
|
|
// No login under any candidate (or unreadable/invalid keys) → API-key
|
|
// auth, no resolver.
|
|
return nil, ""
|
|
}
|
|
manager, err := oauth.NewManager(oauth.ManagerOptions{
|
|
Store: store,
|
|
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
|
// Refreshing a token the user logged into (possibly a preset provider like
|
|
// xAI) re-resolves that provider's OAuth config, which needs the preset.
|
|
AllowPresets: true,
|
|
})
|
|
if err != nil {
|
|
return nil, ""
|
|
}
|
|
resolver := func(ctx context.Context, forceRefresh bool) (string, string, bool, error) {
|
|
var token string
|
|
var rerr error
|
|
if forceRefresh {
|
|
token, rerr = manager.Handle401(ctx, key)
|
|
} else {
|
|
token, rerr = manager.GetFresh(ctx, key)
|
|
}
|
|
if errors.Is(rerr, oauth.ErrNoToken) {
|
|
// The login was removed since construction → fall back to the API key.
|
|
return "", "", false, nil
|
|
}
|
|
if rerr != nil {
|
|
return "", "", false, rerr
|
|
}
|
|
return "Authorization", "Bearer " + token, true, nil
|
|
}
|
|
return resolver, key
|
|
}
|