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:
1 parent
b6f33753ce
commit
74a809dcdf
10 files changed
+460
-15
No files matched your search
+44
-3
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user