provider, cli: a Copilot account is told apart by its host as well as its login, so one GitHub login on github.com and on enterprises' *.ghe.com is kept as separate accounts, none written over or dropped as the editors' own (#1220)

A Copilot account is now named "<login>" on github.com and
"<login>@<name>.ghe.com" on an enterprise's host (CopilotAccountName).
That name is its logins.json user, so addSideLogin, the own-account dup
check, switch, on/off, forget, the account lists, usage cards, routing
ids and per-account settings all go by login and host. The device-code
sign-in reports the account under that name, so the window opens the one
just added. Plan refresh for the editors' own account on an enterprise's
host matches it under the same name.

logins.json written before keeps working: a saved account on an
enterprise's host stored under its login alone (host only in its auth) is
read as login@host, keeping its id and token (copilotSavedName in
nameAlike). The editors' own account remembered by its login alone is
renamed in place, keeping its plan and its hidden mark (copilotRenamed).
github.com accounts are unchanged.

CLI: `magpie accounts switch|forget copilot` now works. It takes
login@<name>.ghe.com, the login with --host <name>.ghe.com (or
--host=), or the login alone when it names one account. An ambiguous
login lists the matches and asks for one. Switching says "magpie now
uses X", since the editors' own sign-in isn't changed.

Tests (new, each FAILs on the old code):
- TestCopilotSameLoginOnTwoHosts: mona on a.ghe.com and b.ghe.com,
  through the device flow. Old: the sign-in reports "mona" and the second
  overwrites the first.
- TestCopilotOwnAndSameNamedGHE: the editors' github.com mona plus
  a.ghe.com's mona.
- TestCopilotLoginsBeforeHosts: an old-format logins.json. Old: listed
  "mona* hubot".
- TestCopilotOwnGHERenamed. Old: own row still "mona".
- TestAccountsCopilotHosts. Old: "copilot": only Claude Code, Codex, ...
  can be added and switched.
TestCopilotGHESignIn and TestCopilotCLIGHELogin now expect
mona_acme@acme.ghe.com.

Ran gofmt; go vet -tags nogui . ./internal/provider for GOOS=darwin,
linux and windows; go build ./...; GOOS=linux and windows go build
-tags nogui; go test -tags nogui -count=1 ./internal/provider
./internal/gui ./internal/gateway . under a temp HOME with pinned
caches, all ok; -race -count=5 on the Copilot account tests, ok. No GUI
asset changed, so no Playwright run. Not tried with a real GHE.com
account.
This commit is contained in:
yetone committed 2026-10-08 07:27:03 +08:00
1 parent b6f33753ce
commit 74a809dcdf
10 files changed
+460 -15

No files matched your search

+44 -3
View File
@@ -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|<plugin>] [--json] | magpie accounts add <claude|codex|gemini|antigravity|zed|factory|mimo|<plugin>> | magpie accounts add copilot [--host <name>.ghe.com] | magpie accounts refresh [--json] | magpie accounts checkin [--json] | magpie accounts switch|forget <claude|codex|gemini|antigravity|zed|factory|mimo|<plugin>> <email> | magpie accounts project <gemini|antigravity> <email> <gcp-project-id>"
const usage = "usage: magpie accounts [claude|codex|grok|copilot|gemini|antigravity|zed|factory|mimo|<plugin>] [--json] | magpie accounts add <claude|codex|gemini|antigravity|zed|factory|mimo|<plugin>> | magpie accounts add copilot [--host <name>.ghe.com] | magpie accounts refresh [--json] | magpie accounts checkin [--json] | magpie accounts switch|forget <claude|codex|gemini|antigravity|zed|factory|mimo|<plugin>> <email> | magpie accounts switch|forget copilot <login>[@<name>.ghe.com] [--host <name>.ghe.com] | magpie accounts project <gemini|antigravity> <email> <gcp-project-id>"
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 (<login>@<name>.ghe.com) or add --host <name>.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:
+86
View File
@@ -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 <login>@<host> 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)
}
}
+1
View File
@@ -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 <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.
+47 -3
View File
@@ -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 ""), "<login>@<name>.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.
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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())
+263
View File
@@ -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 <name>.ghe.com at once: a
// request to https://<sub>.<host>/<path> arrives as /<sub>.<host>/<path>,
// github.com's as /github.com/... Every host's device flow hands over the
// token "gho_<host>", 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)
}
}
}
+2 -1
View File
@@ -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
}
}()
+5 -1
View File
@@ -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
}
+7 -2
View File
@@ -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)
}
}