Files
magpie/internal/provider/copilot_cli.go
T

157 lines
4.6 KiB
Go

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"
"github.com/yetone/magpie/internal/proc"
)
// 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, _ = proc.Command("security", "find-generic-password", "-s", "copilot-cli", "-a", account, "-w").Output()
case "linux":
if p, err := exec.LookPath("secret-tool"); err == nil {
out, _ = proc.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
}