diff --git a/internal/provider/devin.go b/internal/provider/devin.go index 047fc7df..1f3ebe56 100644 --- a/internal/provider/devin.go +++ b/internal/provider/devin.go @@ -473,7 +473,35 @@ func devinFamiliesCached(families []DevinFamily) { devinFamiliesCache.Unlock() } -func askDevinFamilies(ctx context.Context) ([]DevinFamily, error) { return askDevinFamiliesAt(ctx, "") } +// askDevinFamilies is the model list as the accounts signed in give it, the +// one in use first: an account magpie signed in is asked in its own home, +// not the CLI's, which may be signed out (蓝猫 on Discord: with only a +// magpie sign-in every request failed, as the family's id went to Devin +// unturned into a variant, until `devin auth login`). The CLI's own is +// asked last when it isn't among them, as it was before there were homes. +func askDevinFamilies(ctx context.Context) ([]DevinFamily, error) { + var homes []string + for _, l := range devinLogins() { + homes = append(homes, l.Home) + } + if !slices.Contains(homes, "") { + homes = append(homes, "") + } + var first error + for _, home := range homes { + families, err := askDevinFamiliesAt(ctx, home) + if err == nil { + return families, nil + } + if first == nil { + first = err + } + if ctx.Err() != nil { + break + } + } + return nil, first +} // askDevinFamiliesAt is the model list of the account signed in in home // ("" for the CLI's own). diff --git a/internal/provider/devin_cli_signed_out_test.go b/internal/provider/devin_cli_signed_out_test.go new file mode 100644 index 00000000..ad5fb517 --- /dev/null +++ b/internal/provider/devin_cli_signed_out_test.go @@ -0,0 +1,100 @@ +package provider + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +// TestDevinSignedInOnlyInMagpie is 蓝猫's Devin on Discord: the CLI kept a +// sign-in Devin no longer took, so magpie signed the account in in a home +// of its own. Every request failed until `devin auth login`: the model +// list, which turns a family (claude-sonnet-5-5) into the variant Devin's +// API takes, was asked of the CLI's own sign-in only. And once the CLI +// signed in to the same account, it was listed twice. +func TestDevinSignedInOnlyInMagpie(t *testing.T) { + home := claudeHome(t) + data := filepath.Join(home, "data") + t.Setenv("XDG_DATA_HOME", data) + os.MkdirAll(filepath.Join(data, "devin"), 0o700) + cliSignIn := func(key string) { + t.Helper() + if err := os.WriteFile(filepath.Join(data, "devin", "credentials.toml"), devinCredentials(key, "", "", ""), 0o600); err != nil { + t.Fatal(err) + } + forgetDevinStatus() + } + + // a CLI that answers for the key in its data folder's credentials.toml: + // an old account's, one Devin refused, or the account signed in (by + // magpie, or by `devin auth login` after) + exe := filepath.Join(home, "devin") + os.WriteFile(exe, []byte(`#!/bin/sh +f="$XDG_DATA_HOME/devin/credentials.toml" +[ -f "$f" ] || { echo "Not logged in"; exit 1; } +if grep -q refused "$f"; then + if [ "$1" = models ]; then echo "Authentication required: Invalid token" >&2; exit 1; fi + `+devinKeyRefused+` + exit 0 +fi +if [ "$1" = models ]; then + echo '{"families":[{"family_uid":"claude-sonnet-5-5","family_label":"Claude Sonnet 5.5","variants":[{"model_uid":"claude-sonnet-5-5-medium"},{"model_uid":"claude-sonnet-5-5-high"}]}]}' + exit 0 +fi +if grep -q old "$f"; then who=old@example.com; else who=dev@example.com; fi +printf 'Logged in (via Devin).\n\nUser:\n Email: %s\n\nAccount:\n Tier: Devin Pro\n' "$who" +`), 0o755) + fakeDevin(t, exe) + devinFamiliesCached(nil) + t.Cleanup(func() { devinFamiliesCached(nil) }) + + // the CLI was signed in to another account once, remembered as its own + cliSignIn("devin-session-token$old") + if ls := devinLogins(); len(ls) != 1 || ls[0].User != "old@example.com" || ls[0].Home != "" { + t.Fatalf("CLI's own %+v", ls) + } + // then Devin refused its key: nobody is signed in there + cliSignIn("devin-session-token$refused") + if ls := devinLogins(); len(ls) != 0 { + t.Fatalf("refused %+v", ls) + } + + fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{"sessionToken": "devin-session-token$magpie"}) + })) + defer fake.Close() + oldTok := devinExchangeURL + devinExchangeURL = fake.URL + t.Cleanup(func() { devinExchangeURL = oldTok }) + st, err := StartSignIn("devin") + if err != nil { + t.Fatal(err) + } + finishInBrowser(t, st, "dv-code") + if st = waitDone(t, st.ID); st.State != "done" || st.User != "dev@example.com" || !st.Using { + t.Fatalf("state %+v", st) + } + ls := devinLogins() + if len(ls) != 1 || ls[0].User != "dev@example.com" || ls[0].Home == "" { + t.Fatalf("signed in in magpie %+v", ls) + } + + // a request for the family goes to Devin as its variant at the effort, + // read from the account magpie signed in + if got := DevinVariant(context.Background(), "claude-sonnet-5-5", "high"); got != "claude-sonnet-5-5-high" { + t.Fatalf("variant %q, want claude-sonnet-5-5-high", got) + } + + // `devin auth login` to the same account: still one account + cliSignIn("devin-session-token$cli") + if ls := Logins("devin"); len(ls) != 1 || ls[0].User != "dev@example.com" || !ls[0].Active { + t.Fatalf("after the CLI signed in %+v", ls) + } + if p, ok := devinAccount(); !ok || p.Account.User != "dev@example.com" || p.AlsoOn() != nil { + t.Fatalf("account %+v also %+v", p.Account, p.AlsoOn()) + } +} diff --git a/internal/provider/side_logins.go b/internal/provider/side_logins.go index 4910f82f..6c3087e6 100644 --- a/internal/provider/side_logins.go +++ b/internal/provider/side_logins.go @@ -75,6 +75,16 @@ func sideLogins(agent, ownUser string, usable func(savedLogin) bool) []sideLogin if l.own() && (ownUser == "" || l.Hidden != "") || !l.own() && !usable(l) { continue // signed out there, or removed in magpie } + // the agent's own that is one of magpie's already, as when the + // agent was signed in to another account (or signed out) when + // magpie signed this one in, and the agent signed in to it after: + // that one stands for it, as above, not listed twice (蓝猫 on + // Discord, Devin) + if l.own() && slices.ContainsFunc(ls, func(m savedLogin) bool { + return m.Agent == agent && !m.own() && strings.EqualFold(m.User, l.User) && usable(m) + }) { + continue + } if l.First || (first < 0 && l.own()) { first = len(out) }