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:
3 files changed
+254
-7
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user