provider: a Copilot CLI sign-in is found — who from ~/.copilot/config.json (JSONC, COPILOT_HOME honoured), the token from the keychain's copilot-cli item or the file's plaintext copilotTokens; the CLI's token is not traded for a session token (that answers 403) but sent as is under the CLI's own integration, at the API its account is served from

This commit is contained in:
yetone committed 2026-09-24 18:24:03 +08:00
1 parent 7a030fa91c
commit e1d211cc4b
3 files changed
+254 -7

No files matched your search

+25 -7
View File
@@ -781,6 +781,15 @@ type copilotSession struct {
Endpoints struct {
API string `json:"api"`
} `json:"endpoints"`
direct bool // the Copilot CLI's token, sent as is
}
// headers are what a request with this session carries.
func (s copilotSession) headers() map[string]string {
if s.direct {
return copilotCLIHeaders
}
return copilotHeaders
}
var (
@@ -791,6 +800,7 @@ var (
type copilotApp struct {
User string `json:"user"`
Token string `json:"oauth_token"`
cli bool // the standalone Copilot CLI's sign-in
}
// copilotLogin finds the GitHub token Copilot's editors and CLI keep.
@@ -811,7 +821,15 @@ func copilotLogin(cfg string) (copilotApp, bool) {
}
}
}
return copilotApp{}, false
return copilotCLILogin()
}
// session is what this sign-in's requests carry.
func (a copilotApp) session(ctx context.Context) (copilotSession, error) {
if a.cli {
return copilotDirect(ctx, a.Token)
}
return copilotToken(ctx, a.Token)
}
func copilotAccount(cfg string) (Provider, bool) {
@@ -824,7 +842,7 @@ func copilotAccount(cfg string) (Provider, bool) {
acct.User = "GitHub"
}
acct.sign = func(ctx context.Context, req *http.Request, body []byte) error {
s, err := copilotToken(ctx, app.Token)
s, err := app.session(ctx)
if err != nil {
return err
}
@@ -834,7 +852,7 @@ func copilotAccount(cfg string) (Provider, bool) {
}
}
req.Header.Set("Authorization", "Bearer "+s.Token)
for k, v := range copilotHeaders {
for k, v := range s.headers() {
req.Header.Set(k, v)
}
req.Header.Set("Openai-Intent", "conversation-panel")
@@ -849,7 +867,7 @@ func copilotAccount(cfg string) (Provider, bool) {
return nil
}
acct.fetch = func(ctx context.Context) ([]catalog.Model, error) {
ms, err := copilotModels(ctx, app.Token)
ms, err := copilotModels(ctx, app)
if err != nil {
return nil, err
}
@@ -904,8 +922,8 @@ func copilotToken(ctx context.Context, github string) (copilotSession, error) {
var copilotInternal = regexp.MustCompile(`^(copilot-search|exec-agent|trajectory)|-(secondary|tertiary|4th|free-auto)$`)
// copilotModels asks Copilot which chat models this account may use.
func copilotModels(ctx context.Context, github string) ([]catalog.Model, error) {
s, err := copilotToken(ctx, github)
func copilotModels(ctx context.Context, app copilotApp) ([]catalog.Model, error) {
s, err := app.session(ctx)
if err != nil {
return nil, err
}
@@ -918,7 +936,7 @@ func copilotModels(ctx context.Context, github string) ([]catalog.Model, error)
return nil, err
}
req.Header.Set("Authorization", "Bearer "+s.Token)
for k, v := range copilotHeaders {
for k, v := range s.headers() {
req.Header.Set(k, v)
}
res, err := http.DefaultClient.Do(req)
+154
View File
@@ -0,0 +1,154 @@
package provider
// The standalone Copilot CLI keeps its sign-in apart from the editors: who
// is signed in in ~/.copilot/config.json, the token in the system keychain
// (service copilot-cli), or in that file when the user chose plaintext. Its
// token is not traded for a session token the way an editor's is (that
// exchange answers 403): the CLI sends it as is, under its own integration.
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"sync"
"time"
"github.com/tidwall/jsonc"
)
// copilotCLIHeaders are what the CLI sends with its token.
var copilotCLIHeaders = map[string]string{
"Editor-Version": "copilot/1.0.88",
"Copilot-Integration-Id": "copilot-developer-cli",
"X-GitHub-Api-Version": "2026-07-01",
"User-Agent": "copilot/1.0.88",
}
// CopilotUserURL says where an account's Copilot API is; a var so tests can
// point it elsewhere.
var CopilotUserURL = "https://api.github.com/copilot_internal/user"
type copilotCLIUser struct {
Host string `json:"host"`
Login string `json:"login"`
}
// copilotCLIHome is where the CLI keeps its settings.
func copilotCLIHome() string {
if h := os.Getenv("COPILOT_HOME"); h != "" {
return h
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".copilot")
}
// copilotCLILogin is the account the Copilot CLI is signed in to, with its
// token.
func copilotCLILogin() (copilotApp, bool) {
raw, err := os.ReadFile(filepath.Join(copilotCLIHome(), "config.json"))
if err != nil {
return copilotApp{}, false
}
var cfg struct {
Last *copilotCLIUser `json:"lastLoggedInUser"`
Users []copilotCLIUser `json:"loggedInUsers"`
Tokens map[string]string `json:"copilotTokens"`
}
if json.Unmarshal(jsonc.ToJSON(raw), &cfg) != nil {
return copilotApp{}, false
}
users := cfg.Users
if cfg.Last != nil {
users = append([]copilotCLIUser{*cfg.Last}, users...)
}
for _, u := range users {
if u.Login == "" || (u.Host != "" && u.Host != "https://github.com") {
continue // GitHub Enterprise has an API of its own
}
key := "https://github.com:" + u.Login
tok := cfg.Tokens[key]
if tok == "" {
tok = copilotCLISecret(key)
}
if tok != "" {
return copilotApp{User: u.Login, Token: tok, cli: true}, true
}
}
return copilotApp{}, false
}
var copilotSecrets struct {
sync.Mutex
at map[string]time.Time
tok map[string]string
}
// copilotCLISecret reads the CLI's token from the keychain, once in a
// while: each read may ask the user to allow it.
var copilotCLISecret = func(account string) string {
c := &copilotSecrets
c.Lock()
defer c.Unlock()
if c.at == nil {
c.at, c.tok = map[string]time.Time{}, map[string]string{}
}
ttl := time.Hour
if c.tok[account] == "" {
ttl = 10 * time.Minute // denied or absent; don't ask again at once
}
if at, ok := c.at[account]; ok && time.Since(at) < ttl {
return c.tok[account]
}
var out []byte
switch runtime.GOOS {
case "darwin":
out, _ = exec.Command("security", "find-generic-password", "-s", "copilot-cli", "-a", account, "-w").Output()
case "linux":
if p, err := exec.LookPath("secret-tool"); err == nil {
out, _ = exec.Command(p, "lookup", "service", "copilot-cli", "account", account).Output()
}
}
c.at[account], c.tok[account] = time.Now(), string(bytes.TrimSpace(out))
return c.tok[account]
}
// copilotDirect is the session a CLI token makes: the token itself, at the
// API its account is served from.
func copilotDirect(ctx context.Context, github string) (copilotSession, error) {
copilotMu.Lock()
defer copilotMu.Unlock()
if s, ok := copilotSessions[github]; ok && time.Until(time.Unix(s.ExpiresAt, 0)) > 2*time.Minute {
return s, nil
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, CopilotUserURL, nil)
if err != nil {
return copilotSession{}, err
}
req.Header.Set("Authorization", "token "+github)
req.Header.Set("Accept", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return copilotSession{}, errors.New("Copilot sign-in: " + err.Error())
}
defer res.Body.Close()
b, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
var u struct {
Endpoints struct {
API string `json:"api"`
} `json:"endpoints"`
}
if res.StatusCode != 200 || json.Unmarshal(b, &u) != nil {
return copilotSession{}, errors.New("the Copilot CLI's sign-in was refused (" + APIError(b, res.Status) + "); run `copilot login` again")
}
s := copilotSession{Token: github, ExpiresAt: time.Now().Add(30 * time.Minute).Unix(), direct: true}
s.Endpoints.API = u.Endpoints.API
copilotSessions[github] = s
return s, nil
}
+75
View File
@@ -0,0 +1,75 @@
package provider
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// The standalone CLI's sign-in: who in a JSONC config.json, the token in
// the keychain, sent as is under the CLI's integration.
func TestCopilotCLILogin(t *testing.T) {
home := signIn(t)
os.Remove(filepath.Join(home, ".config", "github-copilot", "apps.json"))
cli := filepath.Join(home, "copilot-home")
t.Setenv("COPILOT_HOME", cli)
os.MkdirAll(cli, 0o700)
os.WriteFile(filepath.Join(cli, "config.json"), []byte(`// written by copilot
{
"lastLoggedInUser": {"host": "https://github.com", "login": "octocat"},
"loggedInUsers": [{"host": "https://github.com", "login": "octocat"}], // one
}`), 0o600)
oldSecret := copilotCLISecret
var asked string
copilotCLISecret = func(account string) string { asked = account; return "gho_cli" }
defer func() { copilotCLISecret = oldSecret }()
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer gho_cli" || r.Header.Get("Copilot-Integration-Id") != "copilot-developer-cli" {
w.WriteHeader(401)
return
}
w.Write([]byte(`{"data":[{"id":"claude-sonnet-5","name":"Claude Sonnet 5","model_picker_enabled":true,"capabilities":{"type":"chat"}}]}`))
}))
defer api.Close()
user := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "token gho_cli" {
w.WriteHeader(401)
return
}
json.NewEncoder(w).Encode(map[string]any{"endpoints": map[string]string{"api": api.URL}})
}))
defer user.Close()
oldUser, oldToken := CopilotUserURL, CopilotTokenURL
CopilotUserURL, CopilotTokenURL = user.URL, "http://127.0.0.1:1/never"
defer func() { CopilotUserURL, CopilotTokenURL = oldUser, oldToken }()
copilotSessions = map[string]copilotSession{}
p, ok := find(All(), "copilot")
if !ok || p.Account.User != "octocat" || asked != "https://github.com:octocat" {
t.Fatalf("copilot: %+v, asked %q", p, asked)
}
ms, err := p.Fetch(context.Background())
if err != nil || len(ms) != 1 || ms[0].ID != "claude-sonnet-5" {
t.Fatalf("models: %+v %v", ms, err)
}
req, _ := http.NewRequest("POST", p.Chat+"/chat/completions", nil)
if err := p.Sign(context.Background(), req, Chat, []byte(`{"messages":[{"role":"user","content":"hi"}]}`)); err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(req.URL.String(), api.URL) || req.Header.Get("Authorization") != "Bearer gho_cli" || req.Header.Get("Copilot-Integration-Id") != "copilot-developer-cli" {
t.Fatalf("signed: %s %v", req.URL, req.Header)
}
// signed in with the token kept in plaintext: no keychain
asked = ""
os.WriteFile(filepath.Join(cli, "config.json"), []byte(`{"lastLoggedInUser":{"host":"https://github.com","login":"hubot"},"copilotTokens":{"https://github.com:hubot":"gho_plain"}}`), 0o600)
if app, ok := copilotLogin(filepath.Join(home, ".config")); !ok || app.User != "hubot" || app.Token != "gho_plain" || asked != "" {
t.Fatalf("plaintext: %+v asked %q", app, asked)
}
}