diff --git a/README.md b/README.md index 315371a5..e3ed7e76 100644 --- a/README.md +++ b/README.md @@ -295,7 +295,7 @@ Find them under Plugins › Discover › Gateway middleware. See [Gateway middle
-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
diff --git a/README.zh-CN.md b/README.zh-CN.md index 5d428aa5..fc916d3c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -295,7 +295,7 @@ magpie plugin options model-map '{"mapping": {"fast": "deepseek/deepseek-chat"}}
-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
diff --git a/docs/reference.md b/docs/reference.md index cf8fe62a..31859dc8 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -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//` (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 | diff --git a/docs/subsystems/agent-wiring.md b/docs/subsystems/agent-wiring.md index 953f072c..2febccfa 100644 --- a/docs/subsystems/agent-wiring.md +++ b/docs/subsystems/agent-wiring.md @@ -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). diff --git a/internal/agent/agents.go b/internal/agent/agents.go index c55c0658..79cfeee2 100644 --- a/internal/agent/agents.go +++ b/internal/agent/agents.go @@ -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), diff --git a/internal/agent/copilotjetbrains.go b/internal/agent/copilotjetbrains.go new file mode 100644 index 00000000..bc948b1a --- /dev/null +++ b/internal/agent/copilotjetbrains.go @@ -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 is three keys there, as the server's own +// copilot/byok/saveCustomProviderConfig and saveModel write them: +// +// "-provider-config": {"groupName": , "apiType": "chatCompletions"} +// "-api-key": "" +// "-models-config": {"": {"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/" 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 +} diff --git a/internal/agent/copilotjetbrains_test.go b/internal/agent/copilotjetbrains_test.go new file mode 100644 index 00000000..85783b84 --- /dev/null +++ b/internal/agent/copilotjetbrains_test.go @@ -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") + } +}