From 1721fac5307fa6ed149ccac2bb89e8ef4face8ad Mon Sep 17 00:00:00 2001 From: yetone Date: Wed, 30 Sep 2026 14:24:51 +0800 Subject: [PATCH] =?UTF-8?q?devin:=20an=20account=20signed=20in=20only=20in?= =?UTF-8?q?=20magpie=20works=20without=20the=20Devin=20CLI=20signed=20in,?= =?UTF-8?q?=20and=20is=20listed=20once=20(=E8=93=9D=E7=8C=AB=20on=20Discor?= =?UTF-8?q?d:=20every=20request=20from=20Claude=20Code=20failed=20until=20?= =?UTF-8?q?`devin=20auth=20login`,=20then=20the=20same=20account=20showed?= =?UTF-8?q?=20twice)=20=E2=80=94=20the=20model=20list=20that=20turns=20a?= =?UTF-8?q?=20family=20and=20effort=20into=20the=20variant=20Devin=20takes?= =?UTF-8?q?=20(claude-sonnet-5-5=20+=20high=20=E2=86=92=20claude-sonnet-5-?= =?UTF-8?q?5-high)=20was=20asked=20only=20of=20the=20CLI's=20own=20sign-in?= =?UTF-8?q?,=20so=20with=20that=20signed=20out=20or=20its=20key=20refused?= =?UTF-8?q?=20the=20bare=20family=20id=20went=20to=20Devin=20and=20was=20r?= =?UTF-8?q?efused.=20It=20is=20now=20asked=20of=20the=20signed-in=20accoun?= =?UTF-8?q?ts=20in=20their=20own=20homes,=20the=20one=20in=20use=20first,?= =?UTF-8?q?=20the=20CLI's=20own=20last.=20And=20the=20CLI's=20own=20sign-i?= =?UTF-8?q?n=20isn't=20listed=20beside=20a=20working=20magpie=20account=20?= =?UTF-8?q?of=20the=20same=20user=20(an=20old=20logins.json=20entry=20rena?= =?UTF-8?q?med=20to=20that=20user=20when=20the=20CLI=20signed=20in=20again?= =?UTF-8?q?).=20Tested=20with=20a=20new=20TestDevinSignedInOnlyInMagpie=20?= =?UTF-8?q?in=20a=20sandbox=20HOME=20with=20a=20fake=20devin=20CLI=20and?= =?UTF-8?q?=20exchange=20server,=20following=20=E8=93=9D=E7=8C=AB's=20step?= =?UTF-8?q?s:=20it=20fails=20without=20either=20change=20(variant=20stays?= =?UTF-8?q?=20claude-sonnet-5-5;=20dev@example.com=20listed=20twice).=20go?= =?UTF-8?q?=20vet,=20go=20build=20(-tags=20nogui,=20GOOS=3Dlinux,=20GOOS?= =?UTF-8?q?=3Dwindows),=20go=20test=20-tags=20nogui=20./...=20pass=20(Test?= =?UTF-8?q?UserPath=20and=20TestClaudeAccountNamedAsItsLogin=20flake=20und?= =?UTF-8?q?er=20load,=20pass=20alone).=20Not=20tried=20against=20the=20rea?= =?UTF-8?q?l=20Devin=20API.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/provider/devin.go | 30 +++++- .../provider/devin_cli_signed_out_test.go | 100 ++++++++++++++++++ internal/provider/side_logins.go | 10 ++ 3 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 internal/provider/devin_cli_signed_out_test.go 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) }