Files
magpie/internal/provider/pluginsignin.go
T
yetone 14af0f100b provider, plugins: a plugin's browser sign-in finishes from the address the browser ended on, for magpie on a server or in Docker (Chicring)
The built-in sign-ins that come back to a port on this machine (Codex,
ChatGPT API, Devin, Kiro, Zed, Gemini, Antigravity) already took the
pasted address. Their plugins didn't: a subscription moved onto its plugin
(Devin, Kiro, Zed) or a plugin's own browser sign-in (Trae) showed only
"the account shows up here as soon as you're done", and a browser on
another computer than magpie's ended on a page that won't load with no way
to finish.

Now a plugin's "auto" sign-in whose page names a port on this machine to
come back to (redirect_uri, auth_callback_url, callback on localhost, or
Zed's native_app_port: loopbackPorts) sets pasteCallback, so the GUI shows
the same Callback URL field as the built-ins, and `magpie plugin login` /
`magpie accounts add` ask for it too. SubmitSignInCallback opens the pasted
address on 127.0.0.1 at that port, never through a proxy, so the plugin's
own listener finishes the sign-in as it would for the browser; an address
on another port or host is refused, a page that sends the browser on to a
page that comes back to the same port (Kiro's AWS sign-in) becomes the next
page to open. Command Code's page posts its key from script, which leaves
no address to paste, so it keeps offering the plugin's API key way.

Verified: TestPluginSignInTakesAPastedCallback (fake plugin with a loopback
sign-in, $FAKE_LOOPBACK) fails without the wiring in pluginBegin ("a
sign-in that comes back to a port here takes no pasted address:
{... PasteCallback:false ...}") and passes with it, -race -count=10;
TestLoopbackPorts covers the community plugins' page addresses.
go test -race ./internal/provider ./internal/plugin and the root package's
Plugin|Login|SignIn tests pass; vet, gofmt, darwin/linux nogui/windows
builds pass. With the real community Devin, Zed and Kiro plugins in a
sandbox HOME, each sign-in offered the field and a pasted address reached
the plugin's listener and the vendor (bogus code refused by Devin 401, Zed
"couldn't be decrypted", Kiro 400). signin-paste-plugin.test.cjs (new),
signin-callback and signin-paste-codex pass in Chromium and WebKit, en and
zh, at 360px; no new strings. Not tried: a real successful sign-in from a
browser on another computer.
2026-10-07 00:13:03 +08:00

197 lines
6.3 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
}
clearPluginLapse(saved)
_ = ShowAccount(PluginID(saved.Provider))
return PluginID(saved.Provider), nil
}
// 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 ""
}