agent: Copilot in JetBrains IDEs takes magpie's models as a Bring Your Own Key custom endpoint provider, written into the Copilot language server's byok.json beside the user's own (KamToHung on Discord)

This commit is contained in:
yetone committed 2026-10-08 18:12:10 +08:00
1 parent a2a7fb1123
commit d2241a5798
7 files changed
+486 -2

No files matched your search

+1 -1
View File
@@ -295,7 +295,7 @@ Find them under Plugins › Discover › Gateway middleware. See [Gateway middle
<table>
<tr><td>
Claude Code · Claude Desktop · Codex · Gemini CLI · Antigravity CLI · OpenCode · OpenChamber · MiMo Code · Pi · oh-my-pi · Aside · OmO · Goose · Cursor CLI · Cursor Private Inference · Zed · VS Code Chat · VS Code Insiders · VSCodium Chat · JetBrains Air · Copilot CLI · Crush · DeepSeek Harness · Reasonix Studio · Command Code · fx · Devin · Hermes Agent · Mister Morph · Kimi Code · Muse Code · Empryo · MiniMax Code · Droid · Cline · Qoder · Qoder CN · Grok Build · ZCode · WorkBuddy · CodeBuddy Code · Pencil · T3 Code · OpenHanako · AtomCode · Alma · Cindy
Claude Code · Claude Desktop · Codex · Gemini CLI · Antigravity CLI · OpenCode · OpenChamber · MiMo Code · Pi · oh-my-pi · Aside · OmO · Goose · Cursor CLI · Cursor Private Inference · Zed · VS Code Chat · VS Code Insiders · VSCodium Chat · JetBrains Air · Copilot (JetBrains) · Copilot CLI · Crush · DeepSeek Harness · Reasonix Studio · Command Code · fx · Devin · Hermes Agent · Mister Morph · Kimi Code · Muse Code · Empryo · MiniMax Code · Droid · Cline · Qoder · Qoder CN · Grok Build · ZCode · WorkBuddy · CodeBuddy Code · Pencil · T3 Code · OpenHanako · AtomCode · Alma · Cindy
</td></tr>
</table>
+1 -1
View File
@@ -295,7 +295,7 @@ magpie plugin options model-map '{"mapping": {"fast": "deepseek/deepseek-chat"}}
<table>
<tr><td>
Claude Code · Claude Desktop · Codex · Gemini CLI · Antigravity CLI · OpenCode · OpenChamber · MiMo Code · Pi · oh-my-pi · Aside · OmO · Goose · Cursor CLI · Cursor Private Inference · Zed · VS Code Chat · VS Code Insiders · VSCodium Chat · JetBrains Air · Copilot CLI · Crush · DeepSeek Harness · Reasonix Studio · Command Code · fx · Devin · Hermes Agent · Mister Morph · Kimi Code · Muse Code · Empryo · MiniMax Code · Droid · Cline · Qoder · Qoder CN · Grok Build · ZCode · WorkBuddy · CodeBuddy Code · Pencil · T3 Code · OpenHanako · AtomCode · Alma · Cindy
Claude Code · Claude Desktop · Codex · Gemini CLI · Antigravity CLI · OpenCode · OpenChamber · MiMo Code · Pi · oh-my-pi · Aside · OmO · Goose · Cursor CLI · Cursor Private Inference · Zed · VS Code Chat · VS Code Insiders · VSCodium Chat · JetBrains Air · Copilot (JetBrains) · Copilot CLI · Crush · DeepSeek Harness · Reasonix Studio · Command Code · fx · Devin · Hermes Agent · Mister Morph · Kimi Code · Muse Code · Empryo · MiniMax Code · Droid · Cline · Qoder · Qoder CN · Grok Build · ZCode · WorkBuddy · CodeBuddy Code · Pencil · T3 Code · OpenHanako · AtomCode · Alma · Cindy
</td></tr>
</table>
+1
View File
@@ -95,6 +95,7 @@ line; agents connected to magpie lose it when it quits.
| VS Code (Chat) | `~/Library/Application Support/Code/User/settings.json` + `chatLanguageModels.json` (`~/.config/Code/User` on Linux, `%APPDATA%\Code\User` on Windows) | model (`chat.defaultModel`; a `magpie` Custom Endpoint group, its catalog in Chat's model picker; VS Code 1.122+, no Copilot sign-in or key needed). Each profile's own pair under `User/profiles/<id>/` (listed in `globalStorage/storage.json`) gets the same, unless the profile uses the default's |
| VS Code Insiders (Chat) | the same files under `Code - Insiders/User` in place of `Code/User` | as VS Code, a row of its own; its models send the token `magpie-vscode-insiders`, since its chat's User-Agent is VS Code's |
| VSCodium (Chat) | the same files under `VSCodium/User` in place of `Code/User` | as VS Code, a row of its own; its models send the token `magpie-vscodium`, so Usage counts them as VSCodium; its Chat features must be turned on (`chat.disableAIFeatures=false`, and `defaultChatAgent` plus `trustedExtensionAuthAccess` for GitHub.copilot-chat in its product.json). With the Marketplace's GitHub Copilot Chat 0.48.1, which has no Custom Endpoint provider, the group is written for its OpenAI Compatible provider (`customoai`, the token sent as `x-api-key`), which lists models only while Copilot Chat is signed in to GitHub with a personal Copilot plan |
| Copilot (JetBrains) | `byok.json` in `~/.config/github-copilot` (`$XDG_CONFIG_HOME/github-copilot` when set, `%USERPROFILE%\AppData\Local\github-copilot` on Windows), the Copilot language server's own file, which Xcode's and Eclipse's Copilot read too | provider (a `magpie` custom endpoint provider beside the user's: every magpie model at the gateway's `/v1/chat/completions`, sent with `magpie-copilot-jetbrains`; a model hidden in Manage Models stays hidden through a sync; off removes magpie's three keys alone). Found by the `github-copilot-intellij` plugin in a JetBrains IDE or Android Studio. Listed after the IDE restarts, and only while Copilot is signed in to GitHub on a plan with Bring Your Own Key (Free, Pro, Pro+; Business and Enterprise when the organisation allows it); the model is picked in Copilot Chat |
| JetBrains Air | `acp.json` in `~/Library/Application Support/JetBrains/Air` (`~/.config/JetBrains/Air` on Linux, `%APPDATA%\JetBrains\Air` on Windows) + `magpie-opencode.json` beside it | model (a `Magpie` ACP agent: OpenCode's `opencode acp` on magpie's provider alone, its models and routing groups in Air's model menu; needs OpenCode installed) |
| Copilot CLI | `~/.copilot/settings.json` | model |
| Crush | `~/.config/crush/crush.json` | large, small |
+1
View File
@@ -35,6 +35,7 @@ something else shows up as drift.
- magpie only removes what it wrote. Anything else in an agent's config stays as it was.
- JetBrains Air's `magpie-opencode.json` keeps user-added fields in `provider.magpie`, including nested options, on sync and model picks (`air` in [`air.go`](../../internal/agent/air.go), using `theirsKept` in [`sync.go`](../../internal/agent/sync.go)). The gateway fields and complete model list are still replaced, so removed models leave the list.
- Copilot in JetBrains IDEs keeps its Bring Your Own Key providers in the Copilot language server's `byok.json`, shared with Xcode's and Eclipse's Copilot (`copilotJetBrains` in [`copilotjetbrains.go`](../../internal/agent/copilotjetbrains.go)). magpie writes its own three keys there, `magpie-provider-config`, `magpie-api-key` and `magpie-models-config`, in the bytes the server itself writes, and only when they differ; the user's providers and keys stay. A magpie model the user hid in Manage Models (`isRegistered: false`) stays hidden through a sync. A new file is made 0600 in a 0700 folder, as the server makes it.
- Ante's `catalog.json` keeps user-added fields in `providers.magpie` the same way (`ante` in [`ante.go`](../../internal/agent/ante.go)); its `auth`, `http_headers`, `extra_body` and `stream_idle_timeout_secs` are what a gateway shared on the local network or reached from WSL needs to send a key. Ante starts a session on the `provider` and `model` of `settings.json`, not on what its catalog holds (#1210), so magpie sets that pair and puts back what it found there; a pick the user makes in Ante while magpie is wired is theirs and stands.
- JSON/JSONC config edits and their previews reject malformed input rather than editing a partially parsed document. Object edits and VS Code's model-group array edits leave the original file untouched on a parse error; valid comments and trailing commas remain supported (`internal/edit`).
- Codex signed in to ChatGPT stays beside its sign-in (`openai_base_url`) unless the user picks magpie API. magpie becomes its provider only while the Codex app holds the active account, which it sends nothing for: `/wham/usage` says it isn't allowed, and it has no credits and isn't a workspace still within its overage (`codexHeld`, read by `provider.CodexUsedUp`). Where the app goes by what usage can't show (an experiment's gate, a reserve, a Team plan's overage under the reserve experiment), it counts as held: a wrong move costs ChatGPT extras, a missed one every turn. A saved reading kept through a failed read stops saying held once its used-up window has started again; a hold with no window used up stays till a fresh read. The app then loses its ChatGPT state (durable threads, remote control), so a window at 100% alone, which credits get past, doesn't move it (`codex.out` marks the move, and Sync undoes it once the account has room).
+1
View File
@@ -84,6 +84,7 @@ func All() []*Agent {
vscode(home, cfg),
vscodeInsidersAgent(home, cfg),
vscodium(home, cfg),
copilotJetBrains(home),
air(home, cfg),
copilot(home),
crush(home, cfg),
+246
View File
@@ -0,0 +1,246 @@
package agent
// GitHub Copilot in JetBrains IDEs (IntelliJ IDEA, PyCharm, GoLand, …, and
// Android Studio; the github-copilot-intellij plugin) takes models of other
// vendors through Bring Your Own Key: Copilot Chat's model picker, Manage
// Models…, Add Custom Endpoint Provider (KamToHung on Discord). The plugin
// keeps none of it itself: it asks the Copilot language server it bundles
// (copilot-language-server, the npm @github/copilot-language-server), which
// keeps every provider in one JSON object, byok.json in
// $XDG_CONFIG_HOME/github-copilot when that is set (and absolute),
// %USERPROFILE%\AppData\Local\github-copilot on Windows and
// ~/.config/github-copilot elsewhere, read again at every use. A custom
// endpoint provider <name> is three keys there, as the server's own
// copilot/byok/saveCustomProviderConfig and saveModel write them:
//
// "<name>-provider-config": {"groupName": <name>, "apiType": "chatCompletions"}
// "<name>-api-key": "<key, in plain text>"
// "<name>-models-config": {"<model id>": {"deploymentUrl": …, "isRegistered": true,
// "isCustomModel": true, "modelCapabilities": {"name": …, "maxInputTokens": …,
// "maxOutputTokens": …, "toolCalling": true, "vision": …}}}
//
// So magpie adds the provider "magpie" there: its catalog, each model at the
// gateway's /v1/chat/completions, sent with the key as a Bearer token (the
// key is the file's, not the IDE's password safe). Tool calling is on, as
// Copilot's agent mode lists only models that call tools; a model's
// maxInputTokens is the window the server fills, its output kept within it.
// The user's other providers and keys stay as they are.
//
// The IDE lists the server's providers when Copilot signs in, so the models
// show after the IDE restarts. The model is picked in Copilot Chat, kept in
// the IDE's project files, so what magpie sets is whether its models are in
// that picker. BYOK needs a GitHub sign-in with Copilot: the server offers
// it to a personal plan (Free, Pro, Pro+) and to Business or Enterprise
// where the organisation's policy allows it. Its requests say
// "GithubCopilot/<server version>" as every client of that server does, so
// they are told apart by their key. Xcode's and Eclipse's Copilot run the
// same server and read the same file: they list magpie's models too.
//
// Checked with the language server 1.545.1 bundled in plugin 1.18.0, under
// a sandbox HOME signed in to a real Copilot account: the file magpie
// writes is listed by copilot/byok/listCustomProviderConfigs and
// listModels, and a Chat turn on one of its models reached the gateway with
// magpie's key. Not with a JetBrains IDE's own window.
import (
"os"
"path/filepath"
"runtime"
"github.com/tidwall/gjson"
"github.com/yetone/magpie/internal/appdir"
"github.com/yetone/magpie/internal/edit"
"github.com/yetone/magpie/internal/gateway"
)
// copilotJBID is the agent's id, and what its token names.
const copilotJBID = "copilot-jetbrains"
// The keys of magpie's provider in byok.json.
const (
copilotJBConfig = magpieID + "-provider-config"
copilotJBKey = magpieID + "-api-key"
copilotJBModels = magpieID + "-models-config"
)
// copilotJBPlugin is the folder the plugin is installed in, under an IDE's
// plugins folder.
const copilotJBPlugin = "github-copilot-intellij"
func copilotJetBrains(home string) *Agent {
return copilotJetBrainsAt(copilotBYOKPath(home), func() bool { return copilotJBInstalled(home) })
}
// copilotBYOKPath is byok.json where the language server keeps it
// (getXdgConfigPath).
func copilotBYOKPath(home string) string {
if x := appdir.Getenv("XDG_CONFIG_HOME"); x != "" && filepath.IsAbs(x) {
return filepath.Join(x, "github-copilot", "byok.json")
}
if runtime.GOOS == "windows" {
// the server's os.homedir(), USERPROFILE, which home is too
return filepath.Join(home, "AppData", "Local", "github-copilot", "byok.json")
}
return filepath.Join(home, ".config", "github-copilot", "byok.json")
}
// copilotJBInstalled reports whether some JetBrains IDE, or Android Studio,
// has the Copilot plugin installed: in the plugins folder of an IDE's
// config folder on macOS and Windows, in the IDE's data folder itself on
// Linux.
func copilotJBInstalled(home string) bool {
var base, sub string
switch runtime.GOOS {
case "darwin":
base, sub = filepath.Join(home, "Library", "Application Support"), "plugins"
case "windows":
if base = appdir.Getenv("APPDATA"); base == "" {
base = filepath.Join(home, "AppData", "Roaming")
}
sub = "plugins"
default:
if base = appdir.Getenv("XDG_DATA_HOME"); base == "" {
base = filepath.Join(home, ".local", "share")
}
}
for _, vendor := range []string{"JetBrains", "Google"} {
ms, _ := filepath.Glob(filepath.Join(base, vendor, "*", sub, copilotJBPlugin))
for _, m := range ms {
if isDir(m) {
return true
}
}
}
return false
}
func copilotJetBrainsAt(path string, installed func() bool) *Agent {
wired := func() bool { _, ok := edit.GetJSON(path, copilotJBConfig); return ok }
return atomic(&Agent{
ID: copilotJBID, Name: "Copilot (JetBrains)", Icon: "githubcopilot",
Aliases: []string{"jetbrains", "jetbrains-copilot", "copilot-idea", "copilot-intellij", "intellij", "idea"},
Dir: filepath.Dir(path), Path: path,
// the folder is every Copilot's (VS Code's sign-in too): the
// plugin installed in an IDE, or magpie's provider written, says
// it is here
detect: func() bool { return installed() || wired() },
Notice: func() string {
if !wired() {
return ""
}
return "magpie's models are in Copilot Chat's model picker in your JetBrains IDE, under magpie, once the IDE restarts. They need Copilot signed in to GitHub on a plan with Bring Your Own Key: Copilot Free, Pro and Pro+ have it, Business and Enterprise when the organisation's policy allows it."
},
Sync: func() error {
if !wired() {
return nil
}
return copilotJBWrite(path)
},
Check: func() string {
if !wired() {
return ""
}
// every model at the gateway: the first that isn't, if any (none
// listed while the catalog is empty)
raw, _ := edit.GetJSON(path, copilotJBModels)
url := vscodeURL()
for _, m := range gjson.Parse(raw).Map() {
if url = m.Get("deploymentUrl").String(); url != vscodeURL() {
break
}
}
return wiringOff("Copilot (JetBrains)", path, func(k string) (string, bool) {
if k == "deploymentUrl" {
return url, url != ""
}
return edit.GetJSON(path, k)
}, "deploymentUrl", vscodeURL(), copilotJBKey, gateway.TokenFor(copilotJBID))
},
Fields: []Field{{
Key: "provider", Label: "provider",
Get: func() string {
if wired() {
return magpieID
}
return ""
},
Set: func(v string) error {
if v == "" {
return edit.DelJSON(path, copilotJBConfig, copilotJBKey, copilotJBModels)
}
return copilotJBWrite(path)
},
Options: func(map[string]string) []Option {
return []Option{{Value: magpieID, Label: "magpie", Icon: "magpie", Note: "every magpie model in Copilot Chat's model picker"}}
},
}},
}, path)
}
// copilotJBWrite writes magpie's provider into byok.json, made private as
// the language server makes it when there is none. A model of magpie's the
// user hid in the IDE's Manage Models stays hidden.
func copilotJBWrite(path string) error {
if _, err := os.Stat(path); os.IsNotExist(err) {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
if err := os.WriteFile(path, []byte("{}\n"), 0o600); err != nil {
return err
}
}
hidden := map[string]bool{}
if raw, ok := edit.GetJSON(path, copilotJBModels); ok {
for id, m := range gjson.Parse(raw).Map() {
if r := m.Get("isRegistered"); r.Exists() && !r.Bool() {
hidden[id] = true
}
}
}
models := copilotJBModelsJSON(hidden)
kvs := []edit.KV{
{Path: copilotJBConfig, Value: map[string]string{"groupName": magpieID, "apiType": "chatCompletions"}},
{Path: copilotJBKey, Value: gateway.TokenFor(copilotJBID)},
{Path: copilotJBModels, Value: models},
}
// written only where it differs, so a sync with nothing new leaves the
// file as the server last wrote it
var set []edit.KV
for _, kv := range kvs {
if cur, ok := edit.GetJSON(path, kv.Path); !ok || !sameJSON(cur, kv.Value) {
set = append(set, kv)
}
}
if len(set) == 0 {
return nil
}
return edit.SetJSON(path, set...)
}
// copilotJBModelsJSON is magpie's models-config: the catalog as Copilot
// (JetBrains) is shown it, keyed by model id.
func copilotJBModelsJSON(hidden map[string]bool) map[string]any {
out := map[string]any{}
for _, m := range magpieModels(copilotJBID) {
context := m.Context
if context == 0 {
context = 128000 // the server's own default for a custom endpoint
}
// the output is reserved out of the window the prompt fills
output := maxTokens(m)
if output <= 0 {
output = 16000
}
output = min(output, context/4)
out[m.ID] = map[string]any{
"deploymentUrl": vscodeURL(),
"isRegistered": !hidden[m.ID],
"isCustomModel": true,
"modelCapabilities": map[string]any{
"name": m.Name, "maxInputTokens": context, "maxOutputTokens": output,
"toolCalling": true, "vision": m.Images,
},
}
}
return out
}
+235
View File
@@ -0,0 +1,235 @@
package agent
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"runtime"
"slices"
"testing"
"github.com/yetone/magpie/internal/gateway"
)
// copilotJBUser is byok.json as the Copilot language server 1.545.1 (in the
// JetBrains plugin 1.18.0) wrote it for copilot/byok/saveCustomProviderConfig
// and saveModel of a custom endpoint provider of the user's: its bytes as
// they were, one line and a newline.
const copilotJBUser = `{"refprov-api-key":"refkey","refprov-provider-config":{"groupName":"refprov","apiType":"chatCompletions"},"refprov-models-config":{"r1":{"deploymentUrl":"http://127.0.0.1:3519/v1/chat/completions","isRegistered":true,"isCustomModel":true,"modelCapabilities":{"name":"Ref One","maxInputTokens":100000,"maxOutputTokens":8000,"toolCalling":true,"vision":false}}}}
`
// copilotJBModel is a model of a custom endpoint provider as the server
// keeps one; nothing else taken.
type copilotJBModel struct {
DeploymentURL string `json:"deploymentUrl"`
IsRegistered *bool `json:"isRegistered"`
IsCustomModel *bool `json:"isCustomModel"`
ModelCapabilities struct {
Name string `json:"name"`
MaxInputTokens int `json:"maxInputTokens"`
MaxOutputTokens int `json:"maxOutputTokens"`
ToolCalling bool `json:"toolCalling"`
Vision *bool `json:"vision"`
} `json:"modelCapabilities"`
}
func strictJSON(t *testing.T, raw []byte, v any) {
t.Helper()
d := json.NewDecoder(bytes.NewReader(raw))
d.DisallowUnknownFields()
if err := d.Decode(v); err != nil {
t.Fatalf("not the server's format: %v\n%s", err, raw)
}
}
// Copilot in JetBrains IDEs takes magpie as a custom endpoint provider in
// the language server's byok.json, written as the server writes its own,
// beside the user's providers and keys, which stay; a model hidden in the
// IDE stays hidden through a sync; off takes out magpie's keys alone.
func TestCopilotJetBrains(t *testing.T) {
home := syncHome(t)
path := filepath.Join(home, ".config", "github-copilot", "byok.json")
os.MkdirAll(filepath.Dir(path), 0o700)
os.WriteFile(path, []byte(copilotJBUser), 0o600)
if !slices.ContainsFunc(All(), func(x *Agent) bool { return x.ID == copilotJBID }) {
t.Fatal("Copilot (JetBrains) is not among the agents")
}
a := copilotJetBrainsAt(path, func() bool { return false })
f := a.Field("provider")
if f == nil || f.Get() != "" || a.Wired() || a.Detected() {
t.Fatalf("before: %v %v %v", f, a.Wired(), a.Detected())
}
if err := a.Apply("provider", magpieID); err != nil {
t.Fatal(err)
}
if f.Get() != magpieID || !a.Wired() || !a.Detected() {
t.Fatalf("after: %q %v %v", f.Get(), a.Wired(), a.Detected())
}
if d := a.Check(); d != "" {
t.Fatalf("check: %s", d)
}
if st, _ := os.Stat(path); runtime.GOOS != "windows" && st.Mode().Perm() != 0o600 {
t.Errorf("mode %v", st.Mode().Perm())
}
file := map[string]json.RawMessage{}
user := map[string]json.RawMessage{}
if err := json.Unmarshal([]byte(readFile(path)), &file); err != nil {
t.Fatalf("%v\n%s", err, readFile(path))
}
json.Unmarshal([]byte(copilotJBUser), &user)
for k, v := range user {
if !sameJSON(string(file[k]), v) {
t.Errorf("the user's %s changed: %s", k, file[k])
}
}
var cfg struct {
GroupName string `json:"groupName"`
APIType string `json:"apiType"`
}
strictJSON(t, file["magpie-provider-config"], &cfg)
if cfg.GroupName != magpieID || cfg.APIType != "chatCompletions" {
t.Errorf("provider config: %s", file["magpie-provider-config"])
}
var key string
json.Unmarshal(file["magpie-api-key"], &key)
if key != gateway.TokenFor(copilotJBID) {
t.Errorf("key %q", key)
}
var models map[string]copilotJBModel
strictJSON(t, file["magpie-models-config"], &models)
m, ok := models["relay/glm-4.6"]
if !ok {
t.Fatalf("relay/glm-4.6 not listed: %s", file["magpie-models-config"])
}
c := m.ModelCapabilities
if m.DeploymentURL != gatewayV1()+"/chat/completions" || m.IsRegistered == nil || !*m.IsRegistered ||
m.IsCustomModel == nil || !*m.IsCustomModel || !c.ToolCalling || c.Vision == nil || c.Name == "" ||
c.MaxInputTokens != 204800 || c.MaxOutputTokens <= 0 || c.MaxOutputTokens > c.MaxInputTokens/4 {
t.Errorf("model: %+v", m)
}
// hidden in the IDE's Manage Models, it stays hidden through a sync
var all map[string]map[string]any
json.Unmarshal(file["magpie-models-config"], &all)
all["relay/glm-4.6"]["isRegistered"] = false
file["magpie-models-config"], _ = json.Marshal(all)
b, _ := json.Marshal(file)
os.WriteFile(path, append(b, '\n'), 0o600)
if err := a.Sync(); err != nil {
t.Fatal(err)
}
json.Unmarshal([]byte(readFile(path)), &file)
models = nil
strictJSON(t, file["magpie-models-config"], &models)
if r := models["relay/glm-4.6"].IsRegistered; r == nil || *r {
t.Errorf("a hidden model was shown again: %s", file["magpie-models-config"])
}
// the key changed elsewhere: told
if err := os.WriteFile(path, bytes.Replace([]byte(readFile(path)), []byte(gateway.TokenFor(copilotJBID)), []byte("other"), 1), 0o600); err != nil {
t.Fatal(err)
}
if d := a.Check(); d == "" {
t.Error("a changed key isn't told")
}
if err := a.Apply("provider", ""); err != nil {
t.Fatal(err)
}
if f.Get() != "" || a.Wired() {
t.Fatalf("after off: %s", readFile(path))
}
file = map[string]json.RawMessage{}
json.Unmarshal([]byte(readFile(path)), &file)
if len(file) != len(user) {
t.Errorf("after off: %s", readFile(path))
}
for k, v := range user {
if !sameJSON(string(file[k]), v) {
t.Errorf("the user's %s changed: %s", k, file[k])
}
}
// off, a sync leaves it off
if err := a.Sync(); err != nil || a.Wired() {
t.Fatalf("sync after off: %v %s", err, readFile(path))
}
}
// With no byok.json yet (Copilot never given a key), magpie's makes it as
// the server does: private, in a private folder.
func TestCopilotJetBrainsNewFile(t *testing.T) {
home := syncHome(t)
path := filepath.Join(home, ".config", "github-copilot", "byok.json")
a := copilotJetBrainsAt(path, func() bool { return true })
if err := a.Apply("provider", magpieID); err != nil {
t.Fatal(err)
}
var file map[string]json.RawMessage
if err := json.Unmarshal([]byte(readFile(path)), &file); err != nil || len(file) != 3 {
t.Fatalf("%v: %s", err, readFile(path))
}
if runtime.GOOS == "windows" {
return
}
if st, _ := os.Stat(path); st.Mode().Perm() != 0o600 {
t.Errorf("file mode %v", st.Mode().Perm())
}
if st, _ := os.Stat(filepath.Dir(path)); st.Mode().Perm() != 0o700 {
t.Errorf("folder mode %v", st.Mode().Perm())
}
}
// byok.json is where the server keeps it: under an absolute
// XDG_CONFIG_HOME, else ~/.config/github-copilot, or
// %USERPROFILE%\AppData\Local\github-copilot on Windows.
func TestCopilotBYOKPath(t *testing.T) {
home := t.TempDir()
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg"))
if got, want := copilotBYOKPath(home), filepath.Join(home, "xdg", "github-copilot", "byok.json"); got != want {
t.Errorf("XDG: %s, want %s", got, want)
}
t.Setenv("XDG_CONFIG_HOME", "relative/xdg")
want := filepath.Join(home, ".config", "github-copilot", "byok.json")
if runtime.GOOS == "windows" {
want = filepath.Join(home, "AppData", "Local", "github-copilot", "byok.json")
}
if got := copilotBYOKPath(home); got != want {
t.Errorf("default: %s, want %s", got, want)
}
}
// The agent is found by the plugin installed in a JetBrains IDE (or
// Android Studio), not by ~/.config/github-copilot, which every Copilot
// signed in on the machine has.
func TestCopilotJetBrainsDetected(t *testing.T) {
home := syncHome(t)
t.Setenv("APPDATA", filepath.Join(home, "AppData", "Roaming"))
t.Setenv("XDG_DATA_HOME", "")
os.MkdirAll(filepath.Join(home, ".config", "github-copilot"), 0o700)
os.WriteFile(filepath.Join(home, ".config", "github-copilot", "apps.json"), []byte("{}"), 0o600)
if copilotJetBrains(home).Detected() {
t.Fatal("found with no IDE")
}
var ide string
switch runtime.GOOS {
case "darwin":
ide = filepath.Join(home, "Library", "Application Support", "JetBrains", "IntelliJIdea2026.1", "plugins")
case "windows":
ide = filepath.Join(home, "AppData", "Roaming", "JetBrains", "IntelliJIdea2026.1", "plugins")
default:
ide = filepath.Join(home, ".local", "share", "JetBrains", "IntelliJIdea2026.1")
}
os.MkdirAll(ide, 0o755)
if copilotJetBrains(home).Detected() {
t.Fatal("found with an IDE without the plugin")
}
os.MkdirAll(filepath.Join(ide, copilotJBPlugin), 0o755)
if !copilotJetBrains(home).Detected() {
t.Fatal("not found with the plugin installed")
}
}