diff --git a/internal/provider/account.go b/internal/provider/account.go index 711353a1..7bfe4e56 100644 --- a/internal/provider/account.go +++ b/internal/provider/account.go @@ -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) diff --git a/internal/provider/copilot_cli.go b/internal/provider/copilot_cli.go new file mode 100644 index 00000000..32f0c1cc --- /dev/null +++ b/internal/provider/copilot_cli.go @@ -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 +} diff --git a/internal/provider/copilot_cli_test.go b/internal/provider/copilot_cli_test.go new file mode 100644 index 00000000..07a5d364 --- /dev/null +++ b/internal/provider/copilot_cli_test.go @@ -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) + } +}