diff --git a/accounts_cli.go b/accounts_cli.go index 7dd3c16a..93349cf7 100644 --- a/accounts_cli.go +++ b/accounts_cli.go @@ -24,7 +24,7 @@ import ( // — the subscriptions magpie remembers, how much of each one's allowance is // used, and switching the agent between them. func accountsCmd(args []string) error { - const usage = "usage: magpie accounts [claude|codex|grok|copilot|gemini|antigravity|zed|factory|mimo|] [--json] | magpie accounts add > | magpie accounts add copilot [--host .ghe.com] | magpie accounts refresh [--json] | magpie accounts checkin [--json] | magpie accounts switch|forget > | magpie accounts project " + const usage = "usage: magpie accounts [claude|codex|grok|copilot|gemini|antigravity|zed|factory|mimo|] [--json] | magpie accounts add > | magpie accounts add copilot [--host .ghe.com] | magpie accounts refresh [--json] | magpie accounts checkin [--json] | magpie accounts switch|forget > | magpie accounts switch|forget copilot [@.ghe.com] [--host .ghe.com] | magpie accounts project " agentID := func(s string) (string, error) { switch strings.ToLower(s) { case "claude", "cc": @@ -41,12 +41,14 @@ func accountsCmd(args []string) error { return "factory", nil case "mimo", "mimo-app", "xiaomi-mimo": return provider.MiMoID, nil + case "copilot": + return "copilot", nil } // a plugin's subscription, by its provider's id or name, as a built-in's if pp, err := pluginProvider(context.Background(), s); err == nil { return provider.PluginID(pp.ID), nil } - return "", fmt.Errorf("%q: only Claude Code, Codex, Gemini CLI, Antigravity, Zed, Factory, Xiaomi MiMo and plugins' accounts can be added and switched\n%s", s, usage) + return "", fmt.Errorf("%q: only Claude Code, Codex, Copilot, Gemini CLI, Antigravity, Zed, Factory, Xiaomi MiMo and plugins' accounts can be added and switched\n%s", s, usage) } if len(args) > 1 && args[1] == "project" { if len(args) != 5 { @@ -104,6 +106,20 @@ func accountsCmd(args []string) error { return addAccount(id) } if len(args) > 1 && (args[1] == "switch" || args[1] == "forget") { + // a Copilot account's host, for a login on more than one (#1220) + host, hasHost := "", false + for i := 2; i < len(args); i++ { + if v, ok := strings.CutPrefix(args[i], "--host="); ok { + host, hasHost = v, true + args = slices.Delete(slices.Clone(args), i, i+1) + break + } + if args[i] == "--host" && i+1 < len(args) { + host, hasHost = args[i+1], true + args = slices.Delete(slices.Clone(args), i, i+2) + break + } + } if len(args) != 4 { return fmt.Errorf("%s", usage) } @@ -111,6 +127,17 @@ func accountsCmd(args []string) error { if err != nil { return err } + if hasHost { + if id != "copilot" { + return fmt.Errorf("--host names a Copilot account's host\n%s", usage) + } + h, err := provider.CopilotHost(host) + if err != nil { + return err + } + login, _, _ := strings.Cut(args[3], "@") + args[3] = provider.CopilotAccountName(login, h) + } if args[3], err = accountNamed(id, args[3]); err != nil { return err } @@ -126,7 +153,7 @@ func accountsCmd(args []string) error { if err := provider.SwitchLogin(id, args[3]); err != nil { return err } - if _, plug := provider.PluginOf(id); plug || id == "gemini" || id == "antigravity" { + if _, plug := provider.PluginOf(id); plug || id == "gemini" || id == "antigravity" || id == "copilot" { fmt.Println(green.Render("✓"), "magpie now uses", args[3], "for", id) return nil } @@ -543,6 +570,14 @@ func checkinWorkBuddy(asJSON bool) error { // the agent's accounts has it. func accountNamed(agent, user string) (string, error) { user = strings.TrimSpace(user) + if agent == "copilot" { + // "mona@github.com" is github.com's mona, named "mona" + if login, host, ok := strings.Cut(user, "@"); ok { + if h, err := provider.CopilotHost(host); err == nil { + user = provider.CopilotAccountName(login, h) + } + } + } ls := provider.Logins(agent) var names, same []string for _, l := range ls { @@ -552,11 +587,17 @@ func accountNamed(agent, user string) (string, error) { names = append(names, l.User) if email, _, _ := strings.Cut(l.User, " · "); strings.EqualFold(strings.TrimSpace(email), user) { same = append(same, l.User) + } else if login, _, _ := strings.Cut(l.User, "@"); agent == "copilot" && strings.EqualFold(login, user) { + // a Copilot login alone names its one account on an + // enterprise's host (#1220) + same = append(same, l.User) } } switch { case len(same) == 1: return same[0], nil + case len(same) > 1 && agent == "copilot": + return "", fmt.Errorf("copilot has %d accounts of %s: %q — name one in full (@.ghe.com) or add --host .ghe.com", len(same), user, same) case len(same) > 1: return "", fmt.Errorf("%s has %d accounts of %s: %q — name one in full", agent, len(same), user, same) case len(names) == 0: diff --git a/accounts_copilot_cli_test.go b/accounts_copilot_cli_test.go new file mode 100644 index 00000000..c2c673e2 --- /dev/null +++ b/accounts_copilot_cli_test.go @@ -0,0 +1,86 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/yetone/magpie/internal/provider" +) + +// magpie accounts switch|forget copilot tells one login on two hosts apart +// (#1220): by @ or --host, the login alone only when it names +// one account. +func TestAccountsCopilotHosts(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) + t.Setenv("CLAUDE_CONFIG_DIR", filepath.Join(home, ".claude")) + t.Setenv("COPILOT_HOME", filepath.Join(home, ".copilot")) + logins := filepath.Join(filepath.Dir(provider.Path()), "logins.json") + if err := os.MkdirAll(filepath.Dir(logins), 0o700); err != nil { + t.Fatal(err) + } + // a.ghe.com's as magpie saved it before #1220, b.ghe.com's as after + if err := os.WriteFile(logins, []byte(`[ + {"agent": "copilot", "user": "mona", "seen": "2026-10-05T10:00:00Z", "on": true, "auth": {"host": "a.ghe.com", "oauth_token": "gho_a"}}, + {"agent": "copilot", "user": "mona@b.ghe.com", "seen": "2026-10-06T10:00:00Z", "on": true, "auth": {"host": "b.ghe.com", "oauth_token": "gho_b"}}, + {"agent": "copilot", "user": "hubot", "seen": "2026-10-06T10:00:00Z", "on": true, "auth": {"oauth_token": "gho_h"}} +]`), 0o600); err != nil { + t.Fatal(err) + } + run := func(args ...string) (string, error) { + t.Helper() + return said(t, func() error { return accountsCmd(append([]string{"accounts"}, args...)) }) + } + first := func() string { + t.Helper() + for _, l := range provider.Logins("copilot") { + if l.Active { + return l.User + } + } + return "" + } + if _, err := run("switch", "copilot", "mona"); err == nil || !strings.Contains(err.Error(), "mona@a.ghe.com") || !strings.Contains(err.Error(), "--host") { + t.Fatalf("an ambiguous login was taken: %v", err) + } + if _, err := run("switch", "copilot", "mona@b.ghe.com"); err != nil || first() != "mona@b.ghe.com" { + t.Fatalf("switch mona@b.ghe.com: %v, first %s", err, first()) + } + if out, err := run("switch", "copilot", "MONA", "--host", "https://a.ghe.com/"); err != nil || first() != "mona@a.ghe.com" || !strings.Contains(out, "magpie now uses mona@a.ghe.com") { + t.Fatalf("switch --host: %v %q, first %s", err, out, first()) + } + if _, err := run("switch", "copilot", "hubot", "--host", "evil.example.com"); err == nil { + t.Fatal("--host took a host that isn't GitHub's") + } + if _, err := run("switch", "claude", "a@example.com", "--host", "a.ghe.com"); err == nil { + t.Fatal("--host taken for Claude Code") + } + if _, err := run("forget", "copilot", "mona", "--host=a.ghe.com"); err != nil { + t.Fatal(err) + } + // the login alone now names its one account + if _, err := run("switch", "copilot", "mona"); err != nil || first() != "mona@b.ghe.com" { + t.Fatalf("switch mona: %v, first %s", err, first()) + } + var saved []struct { + User string `json:"user"` + Auth map[string]string `json:"auth"` + } + b, _ := os.ReadFile(logins) + if err := json.Unmarshal(b, &saved); err != nil { + t.Fatal(err) + } + var got []string + for _, l := range saved { + got = append(got, l.User+"="+l.Auth["oauth_token"]) + } + if strings.Join(got, " ") != "hubot=gho_h mona@b.ghe.com=gho_b" { + t.Fatalf("logins.json: %v", got) + } +} diff --git a/docs/subsystems/providers-accounts.md b/docs/subsystems/providers-accounts.md index 95dac638..be8e8e0e 100644 --- a/docs/subsystems/providers-accounts.md +++ b/docs/subsystems/providers-accounts.md @@ -44,6 +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 .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. diff --git a/internal/provider/copilot_accounts.go b/internal/provider/copilot_accounts.go index 3200c344..b9b60b7a 100644 --- a/internal/provider/copilot_accounts.go +++ b/internal/provider/copilot_accounts.go @@ -15,6 +15,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "github.com/yetone/magpie/internal/appdir" ) @@ -28,6 +29,47 @@ func copilotConfigDir() string { return filepath.Join(home, ".config") } +// CopilotAccountName is how a Copilot account is known in magpie: its +// GitHub login on github.com (host ""), "@.ghe.com" on an +// enterprise's (#1220). One login on two hosts is two accounts, each with +// its own token, plan and usage, so the name every list, switch, card and +// routing setting goes by carries the host. A GitHub login never has an @. +func CopilotAccountName(login, host string) string { + if host == "" || login == "" { + return login + } + return login + "@" + host +} + +// copilotSavedName is a saved Copilot account's name as CopilotAccountName +// gives it. One magpie signed in on an enterprise's host before #1220 was +// saved under its login alone, its host only in its auth: read, it takes +// the host into its name, kept under it from its next write. The agent's +// own (no auth) and one on github.com are as they were. +func copilotSavedName(l savedLogin) string { + if l.own() || strings.Contains(l.User, "@") { + return l.User + } + var a struct { + Host string `json:"host"` + } + if json.Unmarshal(l.Auth, &a) != nil || a.Host == "" { + return l.User + } + if h, err := CopilotHost(a.Host); err != nil || h != a.Host { + return l.User // copilotSaved leaves it out anyway + } + return CopilotAccountName(l.User, a.Host) +} + +// copilotRenamed says the agent's own account, remembered by its login +// alone before #1220, is the one now named with its enterprise's host: +// the same account, not a new sign-in. +func copilotRenamed(was, now string) bool { + login, _, ok := strings.Cut(now, "@") + return ok && !strings.Contains(was, "@") && strings.EqualFold(login, was) +} + // copilotSaved is the sign-in of an account magpie keeps. func copilotSaved(l savedLogin) (copilotApp, bool) { var app copilotApp @@ -73,7 +115,7 @@ func copilotOwnUser(cfg string) string { if !ok { return "" } - return firstNonEmpty(own.User, "GitHub") + return CopilotAccountName(firstNonEmpty(own.User, "GitHub"), own.Host) } func copilotSide() []sideLogin { @@ -99,14 +141,16 @@ func forgetCopilotLogin(user string) error { } // addCopilotLogin keeps an account magpie just signed in, on host ("" for -// github.com). +// github.com), under its login and host: a same-named account on another +// host is another account, neither written over nor dropped as the +// agent's own (#1220). func addCopilotLogin(user, plan, token, host string) error { a := map[string]string{"oauth_token": token} if host != "" { a["host"] = host } auth, _ := json.Marshal(a) - return addSideLogin(savedLogin{Agent: "copilot", User: user, Plan: plan, Auth: auth}, copilotOwnUser(copilotConfigDir()), func(savedLogin) {}) + return addSideLogin(savedLogin{Agent: "copilot", User: CopilotAccountName(user, host), Plan: plan, Auth: auth}, copilotOwnUser(copilotConfigDir()), func(savedLogin) {}) } // copilotAccount is the Copilot account in use first. diff --git a/internal/provider/copilot_entitlement.go b/internal/provider/copilot_entitlement.go index 63a86e0c..44b94dc5 100644 --- a/internal/provider/copilot_entitlement.go +++ b/internal/provider/copilot_entitlement.go @@ -44,7 +44,7 @@ func refreshCopilotEntitlement(app copilotApp, plan, sku string) { current, valid := copilotSaved(*l) if l.own() { current, valid = own, ownOK - valid = valid && strings.EqualFold(firstNonEmpty(current.User, "GitHub"), app.User) + valid = valid && strings.EqualFold(CopilotAccountName(firstNonEmpty(current.User, "GitHub"), current.Host), app.User) } if !valid || current.Token != app.Token { continue diff --git a/internal/provider/copilot_ghe_test.go b/internal/provider/copilot_ghe_test.go index 26a2eca7..f9e2ea2b 100644 --- a/internal/provider/copilot_ghe_test.go +++ b/internal/provider/copilot_ghe_test.go @@ -132,12 +132,12 @@ func TestCopilotGHESignIn(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() st, err = WaitSignIn(ctx, st.ID) - if err != nil || st.State != "done" || st.User != "mona_acme" || st.Plan != "Enterprise" || st.Using { + if err != nil || st.State != "done" || st.User != "mona_acme@acme.ghe.com" || st.Plan != "Enterprise" || st.Using { t.Fatalf("signed in: %+v %v", st, err) } var saved *savedLogin for _, l := range readLogins() { - if l.Agent == "copilot" && l.User == "mona_acme" { + if l.Agent == "copilot" && l.User == "mona_acme@acme.ghe.com" { saved = &l } } @@ -148,7 +148,7 @@ func TestCopilotGHESignIn(t *testing.T) { var app copilotApp for _, c := range copilotLogins(copilotConfigDir()) { - if c.User == "mona_acme" { + if c.User == "mona_acme@acme.ghe.com" { app = c.app } } @@ -206,7 +206,7 @@ func TestCopilotCLIGHELogin(t *testing.T) { "loggedInUsers":[{"host":"https://acme.ghe.com","login":"mona_acme"}], "copilotTokens":{"https://acme.ghe.com:mona_acme":"gho_ent"}}`) p, ok := find(All(), "copilot") - if !ok || p.Account.User != "mona_acme" { + if !ok || p.Account.User != "mona_acme@acme.ghe.com" { t.Fatalf("copilot: %+v", p) } ms, err := p.Fetch(context.Background()) diff --git a/internal/provider/copilot_hosts_test.go b/internal/provider/copilot_hosts_test.go new file mode 100644 index 00000000..97243388 --- /dev/null +++ b/internal/provider/copilot_hosts_test.go @@ -0,0 +1,263 @@ +package provider + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// hostsServer stands in for github.com and any .ghe.com at once: a +// request to https://./ arrives as /./, +// github.com's as /github.com/... Every host's device flow hands over the +// token "gho_", whose account is "mona" on every host. +func hostsServer(t *testing.T) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host, path, _ := strings.Cut(strings.TrimPrefix(r.URL.Path, "/"), "/") + host = strings.TrimPrefix(host, "api.") + auth := strings.TrimPrefix(r.Header.Get("Authorization"), "token ") + switch path { + case "login/device/code": + json.NewEncoder(w).Encode(map[string]any{"device_code": "dc-" + host, "user_code": "ABCD-1234", "verification_uri": "https://" + host + "/login/device", "interval": 0}) + case "login/oauth/access_token": + r.ParseForm() + if r.Form.Get("device_code") != "dc-"+host { + w.WriteHeader(400) + return + } + json.NewEncoder(w).Encode(map[string]any{"access_token": "gho_" + host}) + case "user": + if auth != "gho_"+host { + w.WriteHeader(401) + return + } + json.NewEncoder(w).Encode(map[string]any{"login": "mona"}) + case "copilot_internal/user": + if auth != "gho_"+host { + w.WriteHeader(401) + return + } + json.NewEncoder(w).Encode(map[string]any{"copilot_plan": map[bool]string{true: "individual", false: "enterprise"}[host == "github.com"]}) + default: + w.WriteHeader(404) + } + })) + t.Cleanup(srv.Close) + oldOrigin := copilotGHEOrigin + copilotGHEOrigin = func(host, sub string) string { + if sub != "" { + host = sub + "." + host + } + return srv.URL + "/" + host + } + oldDev, oldTok, oldCU, oldGU := gitHubDeviceURL, gitHubTokenURL, CopilotUserURL, GitHubUserURL + gitHubDeviceURL, gitHubTokenURL = srv.URL+"/github.com/login/device/code", srv.URL+"/github.com/login/oauth/access_token" + CopilotUserURL, GitHubUserURL = srv.URL+"/api.github.com/copilot_internal/user", srv.URL+"/api.github.com/user" + t.Cleanup(func() { + copilotGHEOrigin = oldOrigin + gitHubDeviceURL, gitHubTokenURL, CopilotUserURL, GitHubUserURL = oldDev, oldTok, oldCU, oldGU + }) +} + +func copilotSignInAt(t *testing.T, host string) SignInState { + t.Helper() + st, err := StartSignInAt("copilot", host) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + st, err = WaitSignIn(ctx, st.ID) + if err != nil || st.State != "done" { + t.Fatalf("sign-in at %s: %+v %v", host, st, err) + } + return st +} + +// copilotTokens is each listed Copilot account's token and host, by name. +func copilotTokens() map[string]string { + out := map[string]string{} + for _, c := range copilotLogins(copilotConfigDir()) { + out[c.User] = c.app.Token + " " + c.app.Host + } + return out +} + +func copilotNames() string { + var s []string + for _, l := range Logins("copilot") { + s = append(s, l.User+map[bool]string{true: "*", false: ""}[l.Active]) + } + return strings.Join(s, " ") +} + +// One GitHub login on two enterprises' hosts is two accounts (#1220): both +// are kept, each with its own token, and one switched to or removed leaves +// the other as it was. +func TestCopilotSameLoginOnTwoHosts(t *testing.T) { + signIn(t) // the editors' own: octocat on github.com + hostsServer(t) + + if st := copilotSignInAt(t, "a.ghe.com"); st.User != "mona@a.ghe.com" || st.Plan != "Enterprise" || st.Using { + t.Fatalf("a: %+v", st) + } + if st := copilotSignInAt(t, "b.ghe.com"); st.User != "mona@b.ghe.com" || st.Using { + t.Fatalf("b: %+v", st) + } + if got := copilotNames(); got != "octocat* mona@a.ghe.com mona@b.ghe.com" { + t.Fatalf("listed: %s", got) + } + toks := copilotTokens() + if toks["mona@a.ghe.com"] != "gho_a.ghe.com a.ghe.com" || toks["mona@b.ghe.com"] != "gho_b.ghe.com b.ghe.com" { + t.Fatalf("tokens: %v", toks) + } + // each is served as itself, by its own name + p, _ := find(All(), "copilot") + var also []string + for _, a := range p.AlsoOn() { + also = append(also, a.Account.User) + } + if strings.Join(also, " ") != "mona@a.ghe.com mona@b.ghe.com" { + t.Fatalf("also on: %v", also) + } + if LoginID("copilot", "mona@a.ghe.com") == LoginID("copilot", "mona@b.ghe.com") { + t.Fatal("two accounts share one routing id") + } + + if err := SwitchLogin("copilot", "mona@b.ghe.com"); err != nil { + t.Fatal(err) + } + if got := copilotNames(); got != "mona@b.ghe.com* mona@a.ghe.com octocat" { + t.Fatalf("after switch: %s", got) + } + if err := SetLoginOn("copilot", "mona@a.ghe.com", false); err != nil { + t.Fatal(err) + } + if err := ForgetLogin("copilot", "mona@a.ghe.com"); err != nil { + t.Fatal(err) + } + if got := copilotNames(); got != "mona@b.ghe.com* octocat" { + t.Fatalf("after forget: %s", got) + } + if toks := copilotTokens(); toks["mona@b.ghe.com"] != "gho_b.ghe.com b.ghe.com" { + t.Fatalf("b's token after a was removed: %v", toks) + } + // signed in again on a, b is still left alone + copilotSignInAt(t, "a.ghe.com") + if err := ForgetLogin("copilot", "mona@b.ghe.com"); err != nil { + t.Fatal(err) + } + if toks := copilotTokens(); toks["mona@a.ghe.com"] != "gho_a.ghe.com a.ghe.com" || toks["mona@b.ghe.com"] != "" { + t.Fatalf("after b was removed: %v", toks) + } +} + +// The editors signed in to mona on github.com, mona on an enterprise's +// host signed in from magpie is another account, kept beside it, not +// dropped as the editors' own (#1220). +func TestCopilotOwnAndSameNamedGHE(t *testing.T) { + home := signIn(t) + writeFile(t, filepath.Join(home, ".config", "github-copilot", "apps.json"), map[string]any{ + "github.com:Iv1.x": map[string]any{"user": "mona", "oauth_token": "gho_own"}, + }) + hostsServer(t) + if got := copilotNames(); got != "mona*" { + t.Fatalf("before: %s", got) + } + st := copilotSignInAt(t, "a.ghe.com") + if st.User != "mona@a.ghe.com" || st.Using { + t.Fatalf("signed in: %+v", st) + } + if got := copilotNames(); got != "mona* mona@a.ghe.com" { + t.Fatalf("listed: %s", got) + } + if toks := copilotTokens(); toks["mona"] != "gho_own " || toks["mona@a.ghe.com"] != "gho_a.ghe.com a.ghe.com" { + t.Fatalf("tokens: %v", toks) + } + // github.com's mona signed in from magpie is still the editors' own + if st := copilotSignInAt(t, "github.com"); st.User != "mona" || !st.Using { + t.Fatalf("github.com: %+v", st) + } + if got := copilotNames(); got != "mona* mona@a.ghe.com" { + t.Fatalf("after github.com: %s", got) + } +} + +// A logins.json written before #1220 reads as it did: an enterprise's +// account saved under its login alone is named with its host and keeps its +// id and token, github.com's are unchanged, and a same-named account added +// on github.com stands beside it rather than over it. +func TestCopilotLoginsBeforeHosts(t *testing.T) { + home := signIn(t) + os.Remove(filepath.Join(home, ".config", "github-copilot", "apps.json")) + t.Setenv("COPILOT_HOME", filepath.Join(home, "copilot-home")) + 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"}}, + {"id": "2222222222222222", "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 got := copilotNames(); got != "mona@a.ghe.com* hubot" { + t.Fatalf("listed: %s", got) + } + if LoginID("copilot", "mona@a.ghe.com") != "1111111111111111" || LoginID("copilot", "hubot") != "2222222222222222" { + t.Fatalf("ids: %s %s", LoginID("copilot", "mona@a.ghe.com"), LoginID("copilot", "hubot")) + } + if err := addCopilotLogin("mona", "Pro", "gho_m", ""); err != nil { + t.Fatal(err) + } + toks := copilotTokens() + if toks["mona@a.ghe.com"] != "gho_a a.ghe.com" || toks["mona"] != "gho_m " || toks["hubot"] != "gho_h " { + t.Fatalf("tokens: %v", toks) + } + if LoginID("copilot", "mona@a.ghe.com") != "1111111111111111" { + t.Fatal("the enterprise account's id changed") + } +} + +// The editors' own account on an enterprise's host, remembered by its +// login alone before #1220, is the same account named with its host: still +// hidden when it was removed in magpie, with its id. +func TestCopilotOwnGHERenamed(t *testing.T) { + home := signIn(t) + t.Setenv("COPILOT_HOME", filepath.Join(home, "copilot-home")) + 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"}, + }) + old := `[{"id": "3333333333333333", "agent": "copilot", "user": "mona", "plan": "Enterprise", "seen": "2026-10-05T10:00:00Z", "auth": null, "hidden": "hidden"}]` + os.MkdirAll(filepath.Dir(loginsPath()), 0o700) + if err := os.WriteFile(loginsPath(), []byte(old), 0o600); err != nil { + t.Fatal(err) + } + if got := copilotNames(); got != "" { + t.Fatalf("a hidden account came back: %s", got) + } + var own []savedLogin + for _, l := range readLogins() { + if l.Agent == "copilot" { + own = append(own, l) + } + } + if len(own) != 1 || own[0].User != "mona@a.ghe.com" || own[0].ID != "3333333333333333" || own[0].Hidden == "" || own[0].Plan != "Enterprise" { + t.Fatalf("own: %+v", own) + } + // its plan, read again, is kept on it + refreshCopilotEntitlement(copilotApp{User: "mona@a.ghe.com", Token: "gho_own", Host: "a.ghe.com"}, "Business", "") + for _, l := range readLogins() { + if l.Agent == "copilot" && l.Plan != "Business" { + t.Fatalf("plan not kept: %+v", l) + } + } +} diff --git a/internal/provider/copilot_signin.go b/internal/provider/copilot_signin.go index f762a691..5e167ccc 100644 --- a/internal/provider/copilot_signin.go +++ b/internal/provider/copilot_signin.go @@ -122,7 +122,8 @@ func startCopilotSignIn(s *signInFlow) error { if own, ok := copilotLogin(copilotConfigDir()); ok && strings.EqualFold(own.User, user) && own.Host == host { using = true } - s.finish(SignInState{State: "done", User: user, Plan: plan, Using: using}) + // named as the account list names it, so the window finds it + s.finish(SignInState{State: "done", User: CopilotAccountName(user, host), Plan: plan, Using: using}) return } }() diff --git a/internal/provider/logins.go b/internal/provider/logins.go index c27fe782..b7b1ffad 100644 --- a/internal/provider/logins.go +++ b/internal/provider/logins.go @@ -420,12 +420,16 @@ func codexName(ls []savedLogin, l savedLogin) string { // nameAlike gives each Codex account in ls a name of its own (codexName), // the first by a name keeping it: two saved by one name before are told -// apart from the next write on. +// apart from the next write on. A Copilot account on an enterprise's host +// is named with it (copilotSavedName, #1220). func nameAlike(ls []savedLogin) []savedLogin { for i := range ls { if ls[i].Agent == "codex" { ls[i].User = codexName(ls[:i], ls[i]) } + if ls[i].Agent == "copilot" { + ls[i].User = copilotSavedName(ls[i]) + } } return ls } diff --git a/internal/provider/side_logins.go b/internal/provider/side_logins.go index cfd03179..28c3bc19 100644 --- a/internal/provider/side_logins.go +++ b/internal/provider/side_logins.go @@ -42,7 +42,12 @@ func sideLogins(agent, ownUser string, usable func(savedLogin) bool) []sideLogin for i := range ls { if ls[i].Agent == agent && ls[i].own() { found = true - changed := false + 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 + } if !strings.EqualFold(ls[i].User, ownUser) { ls[i].User, ls[i].Seen = ownUser, time.Now().UTC().Truncate(time.Second) if agent == "copilot" { @@ -59,7 +64,7 @@ func sideLogins(agent, ownUser string, usable func(savedLogin) bool) []sideLogin changed = true } } - if changed { + if changed || renamed { _ = writeLogins(ls) } }