provider: a Copilot account renamed with its host keeps its proxy, models, cap and concurrency, moved from its bare login before the new name is written (#1220)

Before #1220 a bare Copilot login named one account (a same-named one
wrote over it), so the per-account settings in providers.json kept under
it are that account's. When an account is renamed login -> login@host
(an old enterprise record read by nameAlike, or the editors' own
enterprise account renamed in sideLogins), renameTo remembers the old
name (savedLogin.was, never written) and pins the account's stable id
from it. writeLogins then moves accountProxies, accountModels,
accountCaps and accountConcurrency of the copilot provider from the old
key to the new (moveAccountSettings), and writes the new name only once
that has worked (renameSettled):
- a setting the new name has already stays, and the old with it;
- an old name another account still bears is left to that account;
- providers.json that can't be read or written stops the move, and the
  account is written under its old name, its settings with it, retried
  a minute later (renameDue) rather than at every listing.
sideLogins writes a pending rename the first time copilot is listed,
on a copy so the listing keeps its order.

Not moved: usage history, quota history, usage-alert marks and the
GUI's expanded/brief card state (localStorage) - records and view
state, not settings. Gateway keys' account lists go by the stable
login id, which is kept.

Tests (each FAILs with moveAccountSettings stubbed to do nothing):
- TestCopilotRenameKeepsSettings: proxy, cap, models, concurrency under
  "mona" are mona@a.ghe.com's after the upgrade; a github.com mona added
  after has neither proxy nor cap.
- TestCopilotOwnGHERenameKeepsSettings: the editors' own GHE account
  keeps cap and proxy.
- TestCopilotRenameWaitsForSettings: an unreadable providers.json is
  left as it is and logins.json keeps "mona"; once readable, the
  settings move and the new name is written.
- TestCopilotRenameKeepsNewNamesSetting.

Ran: rebased onto origin/main (b6f33753); gofmt -l clean; go vet -tags
nogui . ./internal/provider ./internal/gui ./internal/gateway for
GOOS=darwin, linux, windows; go build ./...; GOOS=linux/windows go
build -tags nogui .; go test -tags nogui -count=1 ./internal/provider
./internal/gui ./internal/gateway . under a temp HOME with pinned
caches and pipefail, all ok; -race -count=5 -run TestCopilot
./internal/provider ok.
This commit is contained in:
yetone committed 2026-10-08 07:27:03 +08:00
1 parent 74a809dcdf
commit 84f37ac977
5 files changed
+297 -5

No files matched your search

+1 -1
View File
@@ -44,7 +44,7 @@ keys and accounts per request is in [Gateway routing and fallback](gateway-routi
- Each refresh token has exactly one holder. Vendors rotate tokens on refresh, so two copies of one token would sign each other out. `savedTokenMu` stops two requests refreshing one saved account at once.
- A Claude subscription account is used only for Claude Code, through the genuine Claude Code binary (see [Claude subscription bridge](claude-subscription-bridge.md)).
- Claude Code's `/usage` prints a reset with no year when it falls this year ("Oct 9, 2:59pm (UTC)"), or, in older versions, as a time alone ("3pm"). `claudeResetTime` reads it as the end of its window, so never further ahead than the window's length (5 hours for the session, 7 days for a week) and `claudeResetSlack`: a date is in the first of last year, this year and next that is less than a day past and no further ahead, a time alone the next one no further ahead. Where none is, as for a reading days old, it is the latest one already past, so the window reads as renewed rather than full for a year. Where the clocks go back and show a time twice, the reading not yet past is taken. A week's time alone read just after its reset still reads as tomorrow's.
- A Copilot account is known by its GitHub login and host (`CopilotAccountName`, #1220): `mona` on github.com, `mona@acme.ghe.com` on an enterprise's GHE.com. That name is its `logins.json` user, its row in every list, its usage card and its per-account routing settings, and it is what `SwitchLogin`, `SetLoginOn` and `ForgetLogin` take, so one login on two hosts is two accounts, neither written over by the other nor dropped as the editors' own. One saved before on an enterprise's host under its login alone is read under its name with the host (`copilotSavedName`), keeping its id; the editors' own remembered so is renamed in place, still hidden if it was (`copilotRenamed`). `magpie accounts switch|forget copilot` takes the full name, the login with `--host <name>.ghe.com`, or the login alone when it names one account.
- A Copilot account is known by its GitHub login and host (`CopilotAccountName`, #1220): `mona` on github.com, `mona@acme.ghe.com` on an enterprise's GHE.com. That name is its `logins.json` user, its row in every list, its usage card and its per-account routing settings, and it is what `SwitchLogin`, `SetLoginOn` and `ForgetLogin` take, so one login on two hosts is two accounts, neither written over by the other nor dropped as the editors' own. One saved before on an enterprise's host under its login alone is read under its name with the host (`copilotSavedName`), keeping its id; the editors' own remembered so is renamed in place, still hidden if it was (`copilotRenamed`). Either rename takes the account's per-account settings in `providers.json` (proxy, models, cap, concurrency) from the bare login to the new name before the new name is written to `logins.json` (`renameSettled`): a setting the new name has already stays, and while `providers.json` can't be read or written the account stays saved under its old name, its settings with it. `magpie accounts switch|forget copilot` takes the full name, the login with `--host <name>.ghe.com`, or the login alone when it names one account.
- An unreadable `providers.json` is an error, never an empty list. Saving over an empty list would lose every provider.
- When `logins.json` cannot be decoded as saved accounts, reads keep the accounts read before. Before saving a change, `keepUnreadLogins` copies the original file aside, including valid JSON with invalid account field types, so its credentials can be recovered. If the existing file cannot be read or the backup fails, saving returns the error and leaves the original file unchanged. A missing file can be created normally.
- A switched-off provider (`Off`, #163) stays saved. The gateway says it is off instead of saying the model is unknown.
+117
View File
@@ -12,10 +12,14 @@ import (
"encoding/json"
"errors"
"io"
"log"
"net/http"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
"github.com/yetone/magpie/internal/appdir"
)
@@ -70,6 +74,119 @@ func copilotRenamed(was, now string) bool {
return ok && !strings.Contains(was, "@") && strings.EqualFold(login, was)
}
// renameTo names l now, remembering the name it was read under (was) so
// its settings can follow it (renameSettled). Its stable id is pinned
// first: a stand-in made of the new name would be another account's.
func (l *savedLogin) renameTo(now string) {
if l.ID == "" {
l.ID = loginID(*l)
}
if l.was == "" {
l.was = l.User
}
l.User = now
}
// renameFailedAt is when the settings of a renamed account last couldn't
// be moved; renames wait a while after it rather than write logins.json
// at every listing while providers.json can't be read or written.
var (
renameMu sync.Mutex
renameFailedAt time.Time
)
func renameDue() bool {
renameMu.Lock()
defer renameMu.Unlock()
return time.Since(renameFailedAt) > time.Minute
}
// renameSettled is ls as it is written: each account renamed on read
// (renameTo) under its new name once the per-account settings kept by
// its old one — proxy, models, cap, concurrency, in providers.json — are
// moved to the new one (moveAccountSettings), and under its old name
// still, its settings where they were, when they can't be: a
// providers.json that can't be read is not one without settings. Before
// #1220 a bare Copilot login named one account (another of that name
// wrote over it), so what it kept is that account's. One whose old name
// another account still bears leaves those settings to it.
func renameSettled(ls []savedLogin) []savedLogin {
moves := map[string]string{}
for _, l := range ls {
if l.was == "" || slices.ContainsFunc(ls, func(o savedLogin) bool {
return o.Agent == l.Agent && o.was == "" && accountKey(o.User) == accountKey(l.was)
}) {
continue
}
moves[l.was] = l.User
}
err := moveAccountSettings("copilot", moves)
if err != nil {
log.Printf("copilot accounts renamed with their host (#1220): their settings couldn't be moved (%v); kept under their old names for now", err)
renameMu.Lock()
renameFailedAt = time.Now()
renameMu.Unlock()
}
disk := slices.Clone(ls)
for i := range ls {
if ls[i].was == "" {
continue
}
if err != nil {
disk[i].User = ls[i].was
continue
}
ls[i].was, disk[i].was = "", ""
}
return disk
}
// moveAccountSettings moves the per-account settings of the subscription
// id from each old name in moves to its new one: a setting the new name
// has already stays, and the old one with it. providers.json is read for
// the edit, so one that can't be read stops it, and written only when a
// setting moved.
func moveAccountSettings(id string, moves map[string]string) error {
if len(moves) == 0 {
return nil
}
f, err := read()
if err != nil {
return err
}
moved := false
for i := range f.Providers {
p := &f.Providers[i]
if p.ID != id {
continue
}
for was, now := range moves {
from, to := accountKey(was), accountKey(now)
moved = moveKey(p.AccountProxies, from, to) || moved
moved = moveKey(p.AccountModels, from, to) || moved
moved = moveKey(p.AccountCaps, from, to) || moved
moved = moveKey(p.AccountConcurrency, from, to) || moved
}
}
if !moved {
return nil
}
return store(f)
}
func moveKey[V any](m map[string]V, from, to string) bool {
v, ok := m[from]
if !ok || from == to {
return false
}
if _, taken := m[to]; taken {
return false
}
m[to] = v
delete(m, from)
return true
}
// copilotSaved is the sign-in of an account magpie keeps.
func copilotSaved(l savedLogin) (copilotApp, bool) {
var app copilotApp
+160
View File
@@ -261,3 +261,163 @@ func TestCopilotOwnGHERenamed(t *testing.T) {
}
}
}
// oldGHEFiles writes logins.json as magpie kept it before #1220 — mona
// on a.ghe.com saved under her login alone — and providers.json with
// settings, data, under that login.
func oldGHEFiles(t *testing.T, providers string) {
t.Helper()
home := signIn(t)
os.Remove(filepath.Join(home, ".config", "github-copilot", "apps.json"))
t.Setenv("COPILOT_HOME", filepath.Join(home, "copilot-home"))
renameFailedAt = time.Time{}
old := `[
{"id": "1111111111111111", "agent": "copilot", "user": "mona", "plan": "Enterprise", "seen": "2026-10-05T10:00:00Z", "on": true,
"auth": {"host": "a.ghe.com", "oauth_token": "gho_a"}},
{"agent": "copilot", "user": "hubot", "plan": "Pro", "seen": "2026-10-05T10:00:00Z", "on": true,
"auth": {"oauth_token": "gho_h"}}
]`
if err := os.MkdirAll(filepath.Dir(loginsPath()), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(loginsPath(), []byte(old), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(Path(), []byte(providers), 0o600); err != nil {
t.Fatal(err)
}
}
func savedCopilotUsers(t *testing.T) string {
t.Helper()
b, err := os.ReadFile(loginsPath())
if err != nil {
t.Fatal(err)
}
var ls []savedLogin
if err := json.Unmarshal(b, &ls); err != nil {
t.Fatal(err)
}
var out []string
for _, l := range ls {
out = append(out, l.User)
}
return strings.Join(out, " ")
}
// An enterprise account's proxy, cap, models and concurrency, saved under
// its login alone before #1220, are its own still once it is named with
// its host; a github.com account of that login added after has none of
// them (#1220).
func TestCopilotRenameKeepsSettings(t *testing.T) {
oldGHEFiles(t, `{"providers": [{"id": "copilot",
"accountProxies": {"mona": "http://127.0.0.1:9"},
"accountCaps": {"mona": 70},
"accountModels": {"mona": ["gpt-5"]},
"accountConcurrency": {"mona": 2, "hubot": 3}}]}`)
hubotID := LoginID("copilot", "hubot")
if got := copilotNames(); got != "mona@a.ghe.com* hubot" {
t.Fatalf("listed: %s", got)
}
if got := savedCopilotUsers(t); got != "hubot mona@a.ghe.com" {
t.Fatalf("logins.json: %s", got)
}
if got := ProxyOfLogin("copilot", "mona@a.ghe.com"); got != "http://127.0.0.1:9" {
t.Fatalf("proxy: %q", got)
}
if got := AccountCapOf("copilot", "mona@a.ghe.com"); got != 70 {
t.Fatalf("cap: %d", got)
}
p, _ := storedPicks("copilot")
if got := p.AccountModels["mona@a.ghe.com"]; len(got) != 1 || got[0] != "gpt-5" {
t.Fatalf("models: %v", p.AccountModels)
}
if p.AccountConcurrency["mona@a.ghe.com"] != 2 || p.AccountConcurrency["hubot"] != 3 {
t.Fatalf("concurrency: %v", p.AccountConcurrency)
}
if LoginID("copilot", "mona@a.ghe.com") != "1111111111111111" || LoginID("copilot", "hubot") != hubotID {
t.Fatal("an account's id changed")
}
if err := addCopilotLogin("mona", "Pro", "gho_m", ""); err != nil {
t.Fatal(err)
}
if got := ProxyOfLogin("copilot", "mona"); got != "" {
t.Fatalf("github.com's mona took the enterprise one's proxy: %q", got)
}
if got := AccountCapOf("copilot", "mona"); got != 0 {
t.Fatalf("github.com's mona took the enterprise one's cap: %d", got)
}
if got := AccountCapOf("copilot", "mona@a.ghe.com"); got != 70 {
t.Fatalf("cap after adding github.com's mona: %d", got)
}
}
// The editors' own enterprise account, renamed with its host, keeps its
// cap too.
func TestCopilotOwnGHERenameKeepsSettings(t *testing.T) {
home := signIn(t)
t.Setenv("COPILOT_HOME", filepath.Join(home, "copilot-home"))
renameFailedAt = time.Time{}
writeFile(t, filepath.Join(home, ".config", "github-copilot", "apps.json"), map[string]any{
"a.ghe.com:Iv1.x": map[string]any{"user": "mona", "oauth_token": "gho_own"},
})
os.MkdirAll(filepath.Dir(loginsPath()), 0o700)
if err := os.WriteFile(loginsPath(), []byte(`[{"id": "3333333333333333", "agent": "copilot", "user": "mona", "seen": "2026-10-05T10:00:00Z", "auth": null}]`), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(Path(), []byte(`{"providers": [{"id": "copilot", "accountCaps": {"mona": 60}, "accountProxies": {"mona": "direct"}}]}`), 0o600); err != nil {
t.Fatal(err)
}
if got := copilotNames(); got != "mona@a.ghe.com*" {
t.Fatalf("listed: %s", got)
}
if AccountCapOf("copilot", "mona@a.ghe.com") != 60 || ProxyOfLogin("copilot", "mona@a.ghe.com") != "direct" {
t.Fatalf("settings: cap %d proxy %q", AccountCapOf("copilot", "mona@a.ghe.com"), ProxyOfLogin("copilot", "mona@a.ghe.com"))
}
if got := savedCopilotUsers(t); got != "mona@a.ghe.com" {
t.Fatalf("logins.json: %s", got)
}
}
// A providers.json that can't be read is not one without settings: the
// account isn't written under its new name, its settings stranded under
// the old, until they can be moved.
func TestCopilotRenameWaitsForSettings(t *testing.T) {
oldGHEFiles(t, `{"providers": [`)
if got := copilotNames(); got != "mona@a.ghe.com* hubot" {
t.Fatalf("listed: %s", got)
}
if got := savedCopilotUsers(t); got != "hubot mona" {
t.Fatalf("logins.json renamed without its settings: %s", got)
}
// an account written meanwhile keeps the old name on disk too
if err := addCopilotLogin("octocat", "Pro", "gho_o", ""); err != nil {
t.Fatal(err)
}
if got := savedCopilotUsers(t); !strings.Contains(got, "mona ") && !strings.HasSuffix(got, "mona") {
t.Fatalf("logins.json renamed without its settings: %s", got)
}
if b, _ := os.ReadFile(Path()); string(b) != `{"providers": [` {
t.Fatalf("providers.json written over: %s", b)
}
if err := os.WriteFile(Path(), []byte(`{"providers": [{"id": "copilot", "accountProxies": {"mona": "http://127.0.0.1:9"}, "accountCaps": {"mona": 70}}]}`), 0o600); err != nil {
t.Fatal(err)
}
renameFailedAt = time.Time{}
copilotNames()
if ProxyOfLogin("copilot", "mona@a.ghe.com") != "http://127.0.0.1:9" || AccountCapOf("copilot", "mona@a.ghe.com") != 70 {
t.Fatalf("settings not moved once readable: %+v", load().Providers)
}
if got := savedCopilotUsers(t); !strings.Contains(got, "mona@a.ghe.com") {
t.Fatalf("logins.json: %s", got)
}
}
// A setting the new name has already is the one kept.
func TestCopilotRenameKeepsNewNamesSetting(t *testing.T) {
oldGHEFiles(t, `{"providers": [{"id": "copilot", "accountCaps": {"mona": 70, "mona@a.ghe.com": 40}, "accountProxies": {"mona": "direct"}}]}`)
copilotNames()
if AccountCapOf("copilot", "mona@a.ghe.com") != 40 || ProxyOfLogin("copilot", "mona@a.ghe.com") != "direct" {
t.Fatalf("settings: %+v", load().Providers)
}
}
+11 -2
View File
@@ -110,6 +110,10 @@ type savedLogin struct {
// magpie last looked: its saved copy is that very sign-in, which
// Claude Code's /logout revokes (claudeLoggedOut).
Held bool `json:"held,omitempty"`
// was is the name a Copilot account read under a new one had in the
// file (#1220): its per-account settings are still kept by it, and
// move to the new one before the new name is written (renameSettled).
was string
}
var (
@@ -231,7 +235,10 @@ func writeLogins(ls []savedLogin) error {
}
return strings.ToLower(ls[i].User) < strings.ToLower(ls[j].User)
})
b, err := json.MarshalIndent(ls, "", " ")
// a Copilot account renamed on read is written under its new name
// once its settings have moved to it; until they can, under its old
disk := renameSettled(ls)
b, err := json.MarshalIndent(disk, "", " ")
if err != nil {
return err
}
@@ -428,7 +435,9 @@ func nameAlike(ls []savedLogin) []savedLogin {
ls[i].User = codexName(ls[:i], ls[i])
}
if ls[i].Agent == "copilot" {
ls[i].User = copilotSavedName(ls[i])
if now := copilotSavedName(ls[i]); now != ls[i].User {
ls[i].renameTo(now)
}
}
}
return ls
+8 -2
View File
@@ -45,8 +45,9 @@ func sideLogins(agent, ownUser string, usable func(savedLogin) bool) []sideLogin
changed, renamed := false, false
if agent == "copilot" && copilotRenamed(ls[i].User, ownUser) {
// the same account, its host now in its name (#1220):
// kept as it was, hidden or not
ls[i].User, renamed = ownUser, true
// kept as it was, hidden or not, its settings with it
ls[i].renameTo(ownUser)
renamed = renameDue()
}
if !strings.EqualFold(ls[i].User, ownUser) {
ls[i].User, ls[i].Seen = ownUser, time.Now().UTC().Truncate(time.Second)
@@ -76,6 +77,11 @@ func sideLogins(agent, ownUser string, usable func(savedLogin) bool) []sideLogin
_ = writeLogins(ls)
}
}
// a saved account renamed as it was read (nameAlike) is written under
// its new name, its settings moved to it, the first time it is listed
if agent == "copilot" && renameDue() && slices.ContainsFunc(ls, func(l savedLogin) bool { return l.was != "" }) {
_ = writeLogins(slices.Clone(ls)) // sorted there: listed here as added
}
var out []sideLogin
first := -1
for _, l := range ls {