Files
forever fb9d1a4cc6 provider, plugins: a sign-in reads as done only once its account is listed again, and the command line's plugin sign-in brings the account back and takes its lapsed mark off (#1061)
* provider: a sign-in reads as done only once the account it brings back is listed again, so the window opens it rather than saying magpie can't list it (TestClaudeTeamSeatBesidePersonal failed so on macOS in #1046's CI)

Signing in brings back an account removed from magpie (ShowAccount clears
its Hidden mark). Since 440e535b, (*signInFlow).finish recorded the outcome
and let go of the sign-in's lock before it called ShowAccount, so a poll of
SignInStatus could read done while the account was still hidden. The
window polls every 800ms and, seeing done, looks for the account to open;
missing, it says "{user} signed in, but magpie can't list it — please
report this". #1046's macOS job (run 37502933081) hit the same gap:
  signin_team_test.go:52: account after sign-in: false []
WaitSignIn, which the command line uses, waits for the end of finish and
was not affected.

finish now marks the sign-in finished first. Only the first finish records
an outcome, as before: a cancel or the timeout that comes after it is
refused, and so is a callback (claim). A done sign-in then calls
ShowAccount, and only after that reads as done. ShowAccount's error is
ignored, as before: if providers.json can't be written, the account stays
hidden and the sign-in still ends done. Until then the sign-in reads as
waiting; that takes longer only when the account was hidden, since only
then does ShowAccount write providers.json (and sync the agents' catalogs,
as it did inside finish before).

Every built-in and plugin sign-in's done goes through finish, so this one
change covers them all. The other places that show an account again do it
before their result can be seen: PluginAPIKey returns after ShowAccount,
and ImportLogins and ImportGoogleAccounts call it before they return. The
command line's own plugin sign-in never showed the account again; that is
a change of its own. ImportFactoryKeys shows none either, but the window
offers a Factory key's import only for a Factory it lists, and a removed
one is left out of the Add sheet. providers-accounts.md's sign-in row now
says when a sign-in reads as done.

Verified: TestSignInDoneOnceTheAccountIsBack records what the sign-in reads
as at the moment providers.json shows the account again (catalog.Changed),
for magpie's own callback (Codex), Claude Code's login run by magpie
(Claude) and a plugin's (FakeCo, with Bun). With finish from main all three
fail with "the sign-in read as done before its account was shown again:
the window, seeing it done, can't list it"; with this they pass.
TestSignInCanceledWhileTheAccountComesBack cancels the sign-in at that
moment and checks it still ends done, the account listed; with this finish
guarded on the state alone, as main's was, it fails with a "close of
closed channel" panic in the callback. With a 300ms sleep where main's
finish lets go of its lock, TestClaudeTeamSeatBesidePersonal fails 10 of
10 runs, each in a process of its own, with #1046's message; main as it is
passes 10 of 10 here, the gap being that narrow. With the same sleep after
this finish publishes done, or before its ShowAccount, it passes 10 of 10.
Under a temporary HOME with Bun 1.4.2, -race -count=20 on those three
tests and -race -count=2 on the package's sign-in, plugin and login tests
pass, as do go vet and the darwin, GOOS=linux (nogui) and GOOS=windows
builds; this commit alone builds and passes internal/provider.
With the next commit, go test -tags nogui ./... passes on main at 443f1cbc
and at 0bf9d77d. Three earlier full runs failed, each only in
internal/plugin, which is unchanged and imports nothing changed here:
twice TestHostJSCancellation/ready-wait at its 10-second Node deadline
(signal: killed) while other sessions' tests loaded the machine, the
timeout 76dc5aed and #1036 also record, and once, with Bun 1.3.13,
TestPluginHTTP2TakesTheProxy.
The GUI suite was not run: nothing in internal/gui/assets changes, and its
tests answer /api/signin from fixtures. Not tried with a real vendor's
sign-in.

* plugins: magpie plugin login and magpie accounts add finish a plugin's sign-in as the window does, bringing back a provider removed from magpie and taking the account's lapsed mark off

The window's sign-in to a plugin's provider, once the plugin has saved
the account, takes the account's lapsed mark off and shows again the
agent's own account removed in magpie (clearPluginLapse), and brings the
provider back if it was removed from magpie (ShowAccount): pluginDone
through finish, and PluginAPIKey for a key. The command line's
(pluginLogin, which magpie plugin login runs, as magpie accounts add does
for a plugin's provider or a subscription moved onto one) said "✓ signed
in to …" and did none of it:
- a provider removed from magpie stayed out of magpie providers, the
  agents' catalogs and routing until it was added back (in the window, or
  with magpie provider add);
- an account signed in again after its vendor turned the sign-in away
  still said "sign in again" until a request or a usage read went through
  it;
- an agent's own account (Kiro's, Devin's, …) removed in magpie stayed
  hidden after it was signed in to again, though the window's sign-in
  shows it again (#320).

PluginSignedIn now does all three for a sign-in magpie doesn't follow
itself, and PluginAPIKey and pluginLogin call it. providers-accounts.md's
sign-in row says so.

Verified: TestAccountsAddBringsBackARemovedPlugin signs in to FakeCo, the
plugin host's test plugin, with a key, removes it from magpie and runs
magpie accounts add fakeco with another key.
TestPluginLoginTakesTheLapseOff signs in with magpie plugin login's
browser way (open and xdg-open stubbed), has a usage read mark the
account lapsed as FakeCo's answer says, and signs in again. With
plugins_cli.go from main they fail with "signed in again, but not listed:
no provider "fakeco" — magpie providers lists them" and "signed in again,
but still lapsed: [{… Lapsed:gone@fake's FakeCo sign-in has expired —
sign in again …}]"; with this they pass. With PluginSignedIn's
ShowAccount taken out only the first fails, and with its clearPluginLapse
taken out only the second. Neither covers the own account: FakeCo has
none, and no test covers that part of clearPluginLapse on main either.
Under a temporary HOME with Bun 1.4.2, -race -count=5 on the two and on
TestAccountsOfAPlugin pass. Like TestAccountsOfAPlugin, the two are in
the root package, which has no testenv.Offline, so the plugin host they
start asks models.dev for its catalog.
go test -tags nogui ./... passes on main at 443f1cbc and at 0bf9d77d. Two
earlier full runs failed only internal/plugin's
TestHostJSCancellation/ready-wait at its 10-second Node deadline (signal:
killed) while other sessions' tests loaded the machine, the timeout
76dc5aed and #1036 also record; internal/plugin is unchanged and imports
nothing changed here.
Not tried with a real plugin's sign-in.
2026-10-07 06:24:24 +08:00

207 lines
6.7 KiB
Go

package provider
import (
"context"
"errors"
"regexp"
"strings"
"time"
"github.com/yetone/magpie/internal/plugin"
)
// StartPluginSignIn begins signing in to a plugin's provider (OpenCode's
// id) with its OAuth method, the method's questions answered: the page to
// open, then either the plugin notices on its own or the code the page
// shows is pasted back (SubmitSignInCallback). SignInStatus follows it,
// as it does a built-in sign-in; its Agent is the provider's magpie id.
// The account goes beside those signed in already, or replaces the one it
// is. A built-in moved onto its plugin installs the CLI it runs through
// first, as the built-in's sign-in did.
func StartPluginSignIn(id string, method int, inputs map[string]string) (SignInState, error) {
s := &signInFlow{done: make(chan struct{})}
s.st = SignInState{ID: randomToken(9), Agent: PluginID(id), State: "waiting"}
var cli agentCLI
install := false
if Moved(s.st.Agent) {
cli, install = missingCLI(s.st.Agent)
} else if pluginRunsCLI[id] {
cli, install = missingCLI(id)
}
var installing context.Context
if install {
s.st.State, s.st.Installing = "installing", cli.Name
installing, s.stop = context.WithCancel(context.Background())
} else if err := s.pluginBegin(id, method, inputs); err != nil {
return SignInState{}, err
}
signIns.Lock()
for sid, o := range signIns.m {
if o.st.Agent == s.st.Agent {
o.finish(SignInState{State: "canceled"})
delete(signIns.m, sid)
}
}
signIns.m[s.st.ID] = s
signIns.Unlock()
if install {
go func() {
err := installCLI(installing, cli)
if err == nil {
forgetAccountCaches()
err = s.pluginBegin(id, method, inputs)
}
if err != nil {
s.finish(SignInState{State: "failed", Error: err.Error()})
}
}()
}
return s.status(), nil
}
// pluginRunsCLI are the plugins (by OpenCode's id) that sign in with the
// vendor's CLI themselves, as the built-in did: the community Grok plugin
// runs `grok login`. One installed beside the built-in, not moved onto,
// said "install Grok Build first: curl … | bash", which a magpie in Docker
// has no shell to run; its CLI is installed first, as a moved one's is.
var pluginRunsCLI = map[string]bool{"grok": true}
// pluginBegin asks the plugin for the page to open and waits for the
// sign-in to finish there, or for the code the page shows.
func (s *signInFlow) pluginBegin(id string, method int, inputs map[string]string) error {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
a, err := plugin.Authorize(ctx, id, method, inputs, plugin.NewAccount)
cancel()
if err != nil {
return err
}
wait, stop := context.WithTimeout(context.Background(), signInTimeout)
s.mu.Lock()
if s.st.State != "waiting" && s.st.State != "installing" {
// canceled while the CLI was installed
s.mu.Unlock()
stop()
return nil
}
s.st.State, s.st.Installing = "waiting", ""
s.st.URL, s.st.Instructions, s.st.Code = a.URL, a.Instructions, codeIn(a.Instructions)
s.stop = stop
if a.Method == "code" {
s.st.PasteCode, s.plugin = true, a.Session
} else if PluginPastesCallback(id, a.URL) {
// a browser that can't reach the plugin's port here ends on a page
// that won't load: its address finishes it
s.st.PasteCallback, s.pluginPorts = true, loopbackPorts(a.URL)
}
s.mu.Unlock()
if a.Method == "code" {
go func() {
<-wait.Done()
if errors.Is(wait.Err(), context.DeadlineExceeded) {
s.finish(SignInState{State: "failed", Error: "the sign-in timed out; start it again"})
}
}()
} else {
go func() {
saved, err := plugin.Finish(wait, a.Session, "")
s.pluginDone(saved, err)
}()
}
return nil
}
// deviceCode is the code a device sign-in's instructions ask to check on
// the vendor's page ("Confirm the code ABCD-EFGH on Factory's page").
var deviceCode = regexp.MustCompile(`(?i:\bcode):?\s+([A-Z0-9]+(?:-[A-Z0-9]+)+|[A-Z0-9]{6,})\b`)
// codeIn is that code, shown to copy as the built-in's was.
func codeIn(instructions string) string {
if m := deviceCode.FindStringSubmatch(instructions); m != nil {
return m[1]
}
return ""
}
// pluginCode finishes a plugin's sign-in with the code its page showed.
func (s *signInFlow) pluginCode(code string) error {
code = strings.TrimSpace(code)
if code == "" {
return errors.New("paste the code the sign-in page showed")
}
s.mu.Lock()
session := s.plugin
s.plugin = ""
waiting := s.st.State == "waiting"
s.mu.Unlock()
if session == "" || !waiting {
return errors.New("this sign-in is over; start it again")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
saved, err := plugin.Finish(ctx, session, code)
s.pluginDone(saved, err)
return err
}
func (s *signInFlow) pluginDone(saved plugin.Saved, err error) {
switch {
case errors.Is(err, context.Canceled):
s.finish(SignInState{State: "canceled"})
case errors.Is(err, context.DeadlineExceeded):
s.finish(SignInState{State: "failed", Error: "the sign-in timed out; start it again"})
case err != nil:
s.finish(SignInState{State: "failed", Error: err.Error()})
default:
clearPluginLapse(saved)
user := pluginUser(saved)
s.finish(SignInState{State: "done", User: user, Using: pluginUsing(saved, user)})
}
}
// PluginAPIKey signs in to a plugin's provider with a key, as its "api"
// method does, beside the accounts signed in already; it gives the
// provider's magpie id.
func PluginAPIKey(ctx context.Context, id string, method int, inputs map[string]string, key string) (string, error) {
if strings.TrimSpace(key) == "" {
return "", errors.New("enter the key")
}
saved, err := plugin.APIKey(ctx, id, method, inputs, strings.TrimSpace(key), plugin.NewAccount)
if err != nil {
return "", err
}
return PluginSignedIn(saved), nil
}
// PluginSignedIn finishes a sign-in to a plugin's provider that magpie
// doesn't follow, a key or the command line's own, once the plugin has
// saved the account: as for one it follows (pluginDone), the account's
// lapsed mark goes and, removed from magpie, it comes back. It gives the
// provider's magpie id.
func PluginSignedIn(saved plugin.Saved) string {
clearPluginLapse(saved)
id := PluginID(saved.Provider)
_ = ShowAccount(id)
return id
}
// pluginUsing is whether the account signed in to is the one in use, the
// first, as a built-in's sign-in says.
func pluginUsing(saved plugin.Saved, user string) bool {
for _, l := range Logins(PluginID(saved.Provider)) {
if l.Active {
return user != "" && strings.EqualFold(l.User, user)
}
}
return false
}
// pluginUser is the account signed in to, as the accounts list names it.
func pluginUser(saved plugin.Saved) string {
for _, pp := range plugin.Cached() {
if pp.ID == saved.Provider {
return pluginLabels(pp)[saved.Account]
}
}
return ""
}