agent: CodeBuddy Code, Cline, Gemini CLI, AtomCode, Goose and Reasonix installed in a WSL distro are found and wired there (lgtm on Discord: magpie 现在无法检测到安装在wsl中的codebuddy呀?)

wslKinds had none of these CLIs, so each was set up only on Windows.
Each now has a place-aware constructor (codebuddyIn, clineIn, geminiIn,
atomcodeIn, gooseIn, reasonixIn). It writes the distro's own config with
the gateway as the distro reaches it (mirrored or NAT) and the key it
takes from there. A distro reads none of Windows' variables
(CODEBUDDY_CONFIG_DIR, CLINE_DIR, APPDATA...). Kinds are found by their
folder or their command. Gemini CLI is found by its command alone,
because Antigravity keeps ~/.gemini too. Goose is found by its folder
alone, because a goose command can be pressly's. Reasonix also needs a
native version (new wslKind.accept). Gemini CLI also counts a .env that
names an old address with magpie's key as magpie's own. Before this, a
WSL restart that moved the distro's address made it read "custom", and
picking the model again stashed magpie's old address as the user's.

Verified:
- On the Windows test box (WSL2 magpie-desktop, NAT), with a sandboxed
  nogui magpie.exe (own USERPROFILE/APPDATA, port 3441), real CodeBuddy
  Code 2.161.4, Gemini CLI 0.63.0 and Cline CLI 3.0.68 were installed in
  the distro. `magpie ls` listed each as "· WSL magpie-desktop".
  Picking relay/glm-4.6 wrote ~/.codebuddy/models.json, ~/.gemini/.env +
  settings.json and ~/.cline/data/settings/providers.json in the distro
  at http://172.17.32.1:3441, and wrote nothing in the sandbox home's
  agent folders. The installs were removed afterwards.
- Not tried for real: AtomCode, Goose and Reasonix in a distro, and a
  chat request from a WSL CLI through the gateway.
- Tests: gofmt; go vet ./...; -race on internal/agent; the full
  `go test -tags nogui ./...`; GOOS=windows and GOOS=linux builds.
  TestWSLProbeFindsMoreAgents and the rest of TestWSLMore* panic with
  "no WSL agent codebuddy" on the old wsl.go.
  TestWSLGeminiAddressMoved fails ("auth custom") without the gemini
  routed change.
This commit is contained in:
yetone committed 2026-10-07 05:37:27 +08:00
1 parent 51626258d1
commit 6f65ffb5ff
11 files changed
+431 -101

No files matched your search

+1 -1
View File
@@ -20,7 +20,7 @@ something else shows up as drift.
| What came before | The stash (`stash.json`) remembers what the config said before magpie pointed it at the gateway, so Disconnect restores it rather than guessing. | [`stash.go`](../../internal/agent/stash.go) |
| Disconnect and preview | `Disconnect` runs `Unwire`, then returns each field still on magpie to its default. `DisconnectPreview` runs the real Disconnect on a copy under a temporary home, in a subprocess (`DryRunArg`), and diffs the files. The preview is never a second account of what Disconnect does. | [`applied.go`](../../internal/agent/applied.go), [`preview.go`](../../internal/agent/preview.go) |
| Keeping configs current | `Reseat` moves agents off a model magpie no longer serves (#200). `OnGateway` and `Rewire` write the new gateway address into every connected agent when the port changes. `Source` and `Stale` say what an unconnected agent runs on, and whether running copies still use the old list. | [`reseat.go`](../../internal/agent/reseat.go), [`rewire.go`](../../internal/agent/rewire.go), [`connectinfo.go`](../../internal/agent/connectinfo.go) |
| WSL twins | On Windows each running distro is probed once, without starting a stopped one. The `wslKinds` agents found there become agents of their own, `<id>@wsl:<distro>`, edited through `\\wsl.localhost\<distro>`. The gateway address and key follow the distro's networking: mirrored or NAT (`place`, `keyAt`). `ListsFor` keeps a twin's model picks under the Windows twin's id (#927). | [`wsl.go`](../../internal/agent/wsl.go), [`gatewaykey.go`](../../internal/agent/gatewaykey.go) |
| WSL twins | On Windows each running distro is probed once, without starting a stopped one. The `wslKinds` agents found there become agents of their own, `<id>@wsl:<distro>`, edited through `\\wsl.localhost\<distro>`. A kind is found by its folder under the distro's home or by its command there (not one on a Windows drive). Where another tool shares one of them, the kind leaves it out: Gemini CLI is found by its command alone, because Antigravity keeps `~/.gemini` too. Goose is found by its folder alone, because a `goose` command can be pressly's migration tool. Reasonix also needs the version its command gives (`accept`), because the old npm client is named `reasonix` too. The gateway address and key follow the distro's networking: mirrored or NAT (`place`, `keyAt`). `ListsFor` keeps a twin's model picks under the Windows twin's id (#927). | [`wsl.go`](../../internal/agent/wsl.go), [`gatewaykey.go`](../../internal/agent/gatewaykey.go) |
| Environment-only agents | Agents that read the gateway only from the environment get a `Launch` command. Cursor Private Inference instead gets user-level variables (`cursorLocalOn`, `KeepCursorLocalEnv`), and magpie writes none of Cursor's own files. Its effort field is kept in magpie (`provider.AgentEffort`) and asked by the gateway, its levels those of the models it lists (`cursorLocalEfforts`, #1003). | [`cursorlocal.go`](../../internal/agent/cursorlocal.go) |
## Runtime path
+18 -6
View File
@@ -884,9 +884,15 @@ func piLike(at place, id, name, dir string) *Agent {
}
}
func goose(home, cfg string) *Agent {
func goose(home, cfg string) *Agent { return gooseIn(here(home), cfg) }
// gooseIn is Goose at a place whose config folder is cfg: this machine's,
// or a WSL distro's ~/.config (see wsl.go), where Goose keeps Linux's
// layout and its custom provider names the gateway as the distro reaches
// it, with the key it takes from there.
func gooseIn(at place, cfg string) *Agent {
path := filepath.Join(cfg, "goose", "config.yaml")
if runtime.GOOS == "windows" {
if runtime.GOOS == "windows" && at.spell == nil {
if app := appdir.Getenv("APPDATA"); app != "" {
path = filepath.Join(app, "Block", "goose", "config", "config.yaml")
}
@@ -894,6 +900,12 @@ func goose(home, cfg string) *Agent {
get := func(k string) (string, bool) { return edit.GetYAMLTop(path, k) }
set := func(kvs ...edit.KV) error { return edit.SetYAMLTop(path, kvs...) }
provider := gooseProviderPath(path)
// the provider keys goose takes from its environment: this machine's
// for this machine's goose, none of a distro's
keyEnv := os.Getenv
if at.spell != nil {
keyEnv = func(string) string { return "" }
}
return &Agent{
ID: "goose", Name: "Goose", Icon: "goose", Bin: "goose", Dir: filepath.Dir(path), Path: path, Spelled: prefixed,
UA: []string{"goose"},
@@ -902,9 +914,9 @@ func goose(home, cfg string) *Agent {
return ""
}
return wiringOff("Goose", provider, func(k string) (string, bool) { return edit.GetJSON(provider, k) },
"base_url", gatewayV1(), "headers.Authorization", "Bearer "+gateway.Token)
"base_url", at.v1(), "headers.Authorization", "Bearer "+at.gwKey())
},
Sync: func() error { return syncGooseProvider(provider) },
Sync: func() error { return syncGooseProvider(provider, at) },
// goose loads custom_providers when it starts
Notice: func() string {
if p, _ := gooseActive(path); p == gooseProviderID && Running(`Goose\.app/`, `(^|/)goose( |$)`) {
@@ -936,7 +948,7 @@ func goose(home, cfg string) *Agent {
// Goose's, each model with its window and whether Goose
// can send it a thinking level (see goose.go)
if ref, ok := strings.CutPrefix(v, gooseProviderID+"/"); ok && isMagpie(ref) {
if err := writeGooseProvider(provider); err != nil {
if err := writeGooseProvider(provider, at); err != nil {
return err
}
}
@@ -946,7 +958,7 @@ func goose(home, cfg string) *Agent {
// only the native providers this goose is set up with (#987:
// every one of four was listed, OpenRouter's hundreds of
// models on a goose that had only magpie)
return append(ownOptions("", cur["model"], gooseConfigured(path)...), viaMagpie("goose", gooseProviderID+"/")...)
return append(ownOptions("", cur["model"], gooseConfigured(path, keyEnv)...), viaMagpie("goose", gooseProviderID+"/")...)
},
}, {
// GOOSE_THINKING_EFFORT, the effort goose asks of a model that
+18 -8
View File
@@ -23,7 +23,6 @@ import (
"strconv"
"strings"
"github.com/yetone/magpie/internal/appdir"
"github.com/yetone/magpie/internal/edit"
"github.com/yetone/magpie/internal/gateway"
)
@@ -50,9 +49,14 @@ func atomcodeKey(ref string) string { return magpieID + "/" + ref }
// (atomcodeOwnModels), not built ones.
func atomcodeTable(key string) string { return "models." + strconv.Quote(key) }
func atomcode(home string) *Agent {
dir := filepath.Join(home, ".atomcode")
if h := appdir.Getenv("ATOMCODE_HOME"); filepath.IsAbs(h) {
func atomcode(home string) *Agent { return atomcodeIn(here(home)) }
// atomcodeIn is AtomCode at a place: this machine's home, or a WSL distro's
// (see wsl.go), where ATOMCODE_HOME isn't read and its account names the
// gateway as the distro reaches it, with the key it takes from there.
func atomcodeIn(at place) *Agent {
dir := filepath.Join(at.home, ".atomcode")
if h := at.getenv("ATOMCODE_HOME"); filepath.IsAbs(h) {
dir = filepath.Clean(h)
}
path := filepath.Join(dir, "config.toml")
@@ -102,7 +106,7 @@ func atomcode(home string) *Agent {
return edit.SetTOMLTop(path, edit.KV{Path: "default_model", Value: v})
}
writeMagpie := func() error {
return edit.SetTOMLTablesMatching(path, atomcodeAccountNames, atomcodeTablePrefixes, atomcodeTables(path))
return edit.SetTOMLTablesMatching(path, atomcodeAccountNames, atomcodeTablePrefixes, atomcodeTablesAt(path, at.v1(), agentKeyAt("atomcode", at.gw())))
}
dropMagpie := func() error {
return edit.SetTOMLTablesMatching(path, atomcodeAccountNames, atomcodeTablePrefixes, nil)
@@ -149,7 +153,7 @@ func atomcode(home string) *Agent {
return "AtomCode's [" + atomcodeAccount + "] (config.toml) is gone, so it no longer reaches magpie"
}
return wiringOff("AtomCode", path, func(f string) (string, bool) { v, ok := a[f]; return v, ok },
"base_url", gatewayV1(), "api_key", gateway.TokenFor("atomcode"))
"base_url", at.v1(), "api_key", agentKeyAt("atomcode", at.gw()))
},
Fields: []Field{{
Key: "model", Label: "model",
@@ -289,12 +293,18 @@ func atomcode(home string) *Agent {
// No reasoning_effort is picked for the user — the vendor's default stands
// until they choose one.
func atomcodeTables(path string) []edit.Table {
return atomcodeTablesAt(path, gatewayV1(), gateway.TokenFor("atomcode"))
}
// atomcodeTablesAt is atomcodeTables for an AtomCode reaching the
// gateway's /v1 at v1 with key.
func atomcodeTablesAt(path, v1, key string) []edit.Table {
efforts := atomcodeExistingEfforts(path)
out := []edit.Table{{Name: atomcodeAccount, KVs: []edit.KV{
// openai-compatible is the documented preset for a custom endpoint
{Path: "provider", Value: "openai-compatible"},
{Path: "base_url", Value: gatewayV1()},
{Path: "api_key", Value: gateway.TokenFor("atomcode")},
{Path: "base_url", Value: v1},
{Path: "api_key", Value: key},
}}}
for _, m := range magpieModels("atomcode") {
ctx := m.Context
+29 -22
View File
@@ -46,20 +46,24 @@ import (
"strings"
"time"
"github.com/yetone/magpie/internal/appdir"
"github.com/yetone/magpie/internal/edit"
"github.com/yetone/magpie/internal/gateway"
)
// clineSlot is the provider magpie takes in Cline.
const clineSlot = "openai-compatible"
func cline(home string) *Agent {
dir := appdir.Getenv("CLINE_DIR")
func cline(home string) *Agent { return clineIn(here(home)) }
// clineIn is Cline at a place: this machine's home, or a WSL distro's (see
// wsl.go), where CLINE_DIR and CLINE_DATA_DIR aren't read and its provider
// names the gateway as the distro reaches it, with the key it takes from
// there.
func clineIn(at place) *Agent {
dir := at.getenv("CLINE_DIR")
if dir == "" {
dir = filepath.Join(home, ".cline")
dir = filepath.Join(at.home, ".cline")
}
data := appdir.Getenv("CLINE_DATA_DIR")
data := at.getenv("CLINE_DATA_DIR")
if data == "" {
data = filepath.Join(dir, "data")
}
@@ -71,13 +75,13 @@ func cline(home string) *Agent {
get := func(k string) string { v, _ := edit.GetJSON(path, k); return v }
inUse := func() string { return get("lastUsedProvider") }
onMagpie := func() bool {
return inUse() == clineSlot && get(slot+".settings.apiKey") == gateway.Token
return inUse() == clineSlot && ourKey(get(slot+".settings.apiKey"))
}
// stateOnMagpie: the extension's state is magpie's
stateOnMagpie := func() bool {
k, _ := edit.GetJSON(secrets, "openAiApiKey")
u, _ := edit.GetJSON(state, "openAiBaseUrl")
return k == gateway.Token && u == gatewayV1()
return ourKey(k) && u == at.v1()
}
// wireState points the extension's state, when there is one, at model
// on magpie's openai-compatible
@@ -97,11 +101,11 @@ func cline(home string) *Agent {
if err := edit.SetJSON(state,
edit.KV{Path: "actModeApiProvider", Value: "openai"}, edit.KV{Path: "planModeApiProvider", Value: "openai"},
edit.KV{Path: "actModeOpenAiModelId", Value: model}, edit.KV{Path: "planModeOpenAiModelId", Value: model},
edit.KV{Path: "openAiBaseUrl", Value: gatewayV1()},
edit.KV{Path: "openAiBaseUrl", Value: at.v1()},
edit.KV{Path: "openAiHeaders", Value: map[string]string{"User-Agent": "cline"}}); err != nil {
return err
}
return clineWrite(secrets, "{}", edit.KV{Path: "openAiApiKey", Value: gateway.Token})
return clineWrite(secrets, "{}", edit.KV{Path: "openAiApiKey", Value: at.gwKey()})
}
// restoreState puts back the extension's state from before magpie;
// magpie's keys go when nothing was stashed
@@ -165,15 +169,15 @@ func cline(home string) *Agent {
if !onMagpie() {
return nil
}
if err := syncJSON(path, slot+".settings.baseUrl", func() any { return gatewayV1() }); err != nil {
if err := syncJSON(path, slot+".settings.baseUrl", func() any { return at.v1() }); err != nil {
return err
}
if k, _ := edit.GetJSON(secrets, "openAiApiKey"); k == gateway.Token {
if err := syncJSON(state, "openAiBaseUrl", func() any { return gatewayV1() }); err != nil {
if k, _ := edit.GetJSON(secrets, "openAiApiKey"); ourKey(k) {
if err := syncJSON(state, "openAiBaseUrl", func() any { return at.v1() }); err != nil {
return err
}
}
return syncJSON(models, "providers."+clineSlot, func() any { return clineModels(get(slot + ".settings.model")) })
return syncJSON(models, "providers."+clineSlot, func() any { return clineModelsAt(get(slot+".settings.model"), at.v1()) })
},
Notice: func() string {
if runtime.GOOS == "windows" {
@@ -193,7 +197,7 @@ func cline(home string) *Agent {
return ""
}
return wiringOff("Cline", path, func(k string) (string, bool) { return edit.GetJSON(path, slot+".settings."+k) },
"baseUrl", gatewayV1())
"baseUrl", at.v1())
},
Fields: []Field{{
Key: "model", Label: "model",
@@ -229,14 +233,14 @@ func cline(home string) *Agent {
})
}
if err := clineWrite(models, `{"version":1,"providers":{}}`,
edit.KV{Path: "providers." + clineSlot, Value: clineModels(ref)}); err != nil {
edit.KV{Path: "providers." + clineSlot, Value: clineModelsAt(ref, at.v1())}); err != nil {
return err
}
if err := wireState(ref); err != nil {
return err
}
return clineWrite(path, providersEmpty,
edit.KV{Path: slot, Value: clineProvider(ref, effort)},
edit.KV{Path: slot, Value: clineProvider(ref, effort, at)},
edit.KV{Path: "lastUsedProvider", Value: clineSlot})
}
if onMagpie() {
@@ -284,10 +288,10 @@ func cutMagpie(v string) (string, bool) {
}
// clineProvider is magpie's openai-compatible entry, asking model with
// effort (none unset).
func clineProvider(model, effort string) map[string]any {
// effort (none unset), for the Cline at a place.
func clineProvider(model, effort string, at place) map[string]any {
s := map[string]any{
"provider": clineSlot, "apiKey": gateway.Token, "model": model, "baseUrl": gatewayV1(),
"provider": clineSlot, "apiKey": at.gwKey(), "model": model, "baseUrl": at.v1(),
"headers": map[string]string{"User-Agent": "cline"},
}
if effort != "" {
@@ -366,7 +370,10 @@ func clineRestore(path, snapshot string) error {
// clineModels is magpie's entry in models.json: every magpie model, model
// the one the provider starts on.
func clineModels(model string) map[string]any {
func clineModels(model string) map[string]any { return clineModelsAt(model, gatewayV1()) }
// clineModelsAt is clineModels for a Cline reaching the gateway's /v1 at v1.
func clineModelsAt(model, v1 string) map[string]any {
ms := map[string]any{}
for _, m := range magpieModels("cline") {
caps := []string{"streaming", "tools"}
@@ -386,7 +393,7 @@ func clineModels(model string) map[string]any {
ms[m.ID] = e
}
return map[string]any{
"provider": map[string]any{"name": "magpie", "baseUrl": gatewayV1(), "defaultModelId": model},
"provider": map[string]any{"name": "magpie", "baseUrl": v1, "defaultModelId": model},
"models": ms,
}
}
+13 -7
View File
@@ -23,16 +23,22 @@ import (
"slices"
"strings"
"github.com/yetone/magpie/internal/appdir"
"github.com/yetone/magpie/internal/edit"
)
func codebuddy(home string) *Agent {
dir := appdir.Getenv("CODEBUDDY_CONFIG_DIR")
func codebuddy(home string) *Agent { return codebuddyIn(here(home)) }
// codebuddyIn is CodeBuddy Code at a place: this machine's home, or a WSL
// distro's (see wsl.go), where CODEBUDDY_CONFIG_DIR isn't read and its
// models name the gateway as the distro reaches it, with the key it takes
// from there.
func codebuddyIn(at place) *Agent {
dir := at.getenv("CODEBUDDY_CONFIG_DIR")
if dir == "" {
dir = filepath.Join(home, ".codebuddy")
dir = filepath.Join(at.home, ".codebuddy")
}
path := filepath.Join(dir, "models.json")
write := func(on bool) error { return buddyWriteAt(path, "codebuddy", on, at) }
settings := filepath.Join(dir, "settings.json")
model := func() string { v, _ := edit.GetJSON(settings, "model"); return v }
// ours is whether id is one of magpie's models in models.json
@@ -48,7 +54,7 @@ func codebuddy(home string) *Agent {
if !workbuddyWired(path) {
return nil
}
return buddyWrite(path, "codebuddy", true)
return write(true)
},
Notice: func() string {
if Running(`(^|/)(codebuddy|cbc|codebuddy-code)( |$)`) {
@@ -67,13 +73,13 @@ func codebuddy(home string) *Agent {
},
Set: func(v string) error {
if ref, ok := strings.CutPrefix(v, magpieID+"/"); ok && isMagpie(ref) {
if err := buddyWrite(path, "codebuddy", true); err != nil {
if err := write(true); err != nil {
return err
}
return edit.SetJSON(settings, edit.KV{Path: "model", Value: ref})
}
// CodeBuddy's own: magpie's models come out of its list
if err := buddyWrite(path, "codebuddy", false); err != nil {
if err := write(false); err != nil {
return err
}
if v == "" {
+23 -12
View File
@@ -8,7 +8,6 @@ import (
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/edit"
"github.com/yetone/magpie/internal/gateway"
"github.com/yetone/magpie/internal/provider"
)
@@ -21,15 +20,27 @@ import (
// points GOOGLE_GEMINI_BASE_URL at it, uses the gateway token as the API
// key and names the catalog model in model.name.
func gemini(home string) *Agent {
dir := filepath.Join(home, ".gemini")
func gemini(home string) *Agent { return geminiIn(here(home)) }
// geminiIn is Gemini CLI at a place: this machine's home, or a WSL
// distro's (see wsl.go), whose .env names the gateway as the distro
// reaches it, with the key it takes from there.
func geminiIn(at place) *Agent {
dir := filepath.Join(at.home, ".gemini")
path := filepath.Join(dir, "settings.json")
envPath := filepath.Join(dir, ".env")
auth := jsonGet(path, "security.auth.selectedType")
model := jsonGet(path, "model.name")
base := func() string { v, _ := edit.GetEnvFile(envPath, "GOOGLE_GEMINI_BASE_URL"); return v }
envKey := func() string { v, _ := edit.GetEnvFile(envPath, "GEMINI_API_KEY"); return v }
routed := func() bool { return base() == gateway.URL() }
// routed: the .env names the gateway, or names it at an address it
// no longer has (a WSL distro's under NAT changes as WSL restarts) with
// magpie's key: either way what is there is magpie's, never stashed as
// the user's own
routed := func() bool {
b, k := base(), envKey()
return b == at.gw() || b != "" && (ourKey(k) || k == at.gwKey())
}
magpieKey := func() string {
for _, id := range []string{"google", "gemini"} {
if p, err := provider.Find(id); err == nil && p.Key != "" {
@@ -47,10 +58,10 @@ func gemini(home string) *Agent {
return err
}
var env []edit.KV
if u := unstash("gemini.base_url"); u != "" {
if u := unstash(at.key("gemini.base_url")); u != "" {
env = append(env, edit.KV{Path: "GOOGLE_GEMINI_BASE_URL", Value: u})
}
if k := unstash("gemini.api_key"); k != "" {
if k := unstash(at.key("gemini.api_key")); k != "" {
env = append(env, edit.KV{Path: "GEMINI_API_KEY", Value: k})
}
if len(env) > 0 {
@@ -58,14 +69,14 @@ func gemini(home string) *Agent {
return err
}
}
if a := unstash("gemini.auth"); a != "" {
if a := unstash(at.key("gemini.auth")); a != "" {
if err := edit.SetJSON(path, edit.KV{Path: "security.auth.selectedType", Value: a}); err != nil {
return err
}
} else if err := edit.DelJSON(path, "security.auth.selectedType"); err != nil {
return err
}
if m := unstash("gemini.model"); m != "" {
if m := unstash(at.key("gemini.model")); m != "" {
return edit.SetJSON(path, edit.KV{Path: "model.name", Value: m})
}
return delModelName(path)
@@ -73,7 +84,7 @@ func gemini(home string) *Agent {
setModel := func(v string) error {
if v == "" {
if routed() {
forget("gemini.base_url", "gemini.api_key", "gemini.auth", "gemini.model")
forget(at.key("gemini.base_url"), at.key("gemini.api_key"), at.key("gemini.auth"), at.key("gemini.model"))
if err := edit.DelEnvFile(envPath, "GOOGLE_GEMINI_BASE_URL", "GEMINI_API_KEY"); err != nil {
return err
}
@@ -85,9 +96,9 @@ func gemini(home string) *Agent {
}
if isMagpie(v) {
if !routed() {
stash(map[string]string{"gemini.base_url": base(), "gemini.api_key": envKey(), "gemini.auth": auth(), "gemini.model": model()})
stash(map[string]string{at.key("gemini.base_url"): base(), at.key("gemini.api_key"): envKey(), at.key("gemini.auth"): auth(), at.key("gemini.model"): model()})
}
if err := edit.SetEnvFile(envPath, edit.KV{Path: "GOOGLE_GEMINI_BASE_URL", Value: gateway.URL()}, edit.KV{Path: "GEMINI_API_KEY", Value: gateway.Token}); err != nil {
if err := edit.SetEnvFile(envPath, edit.KV{Path: "GOOGLE_GEMINI_BASE_URL", Value: at.gw()}, edit.KV{Path: "GEMINI_API_KEY", Value: at.gwKey()}); err != nil {
return err
}
return edit.SetJSON(path, edit.KV{Path: "security.auth.selectedType", Value: "gemini-api-key"}, edit.KV{Path: "model.name", Value: v})
@@ -186,7 +197,7 @@ func gemini(home string) *Agent {
return ""
}
if d := wiringOff("Gemini CLI", envPath, func(k string) (string, bool) { return edit.GetEnvFile(envPath, k) },
"GOOGLE_GEMINI_BASE_URL", gateway.URL(), "GEMINI_API_KEY", gateway.Token); d != "" {
"GOOGLE_GEMINI_BASE_URL", at.gw(), "GEMINI_API_KEY", at.gwKey()); d != "" {
return d
}
if a := auth(); a != "gemini-api-key" {
+14 -14
View File
@@ -14,7 +14,6 @@ import (
"gopkg.in/yaml.v3"
"github.com/yetone/magpie/internal/edit"
"github.com/yetone/magpie/internal/gateway"
)
// Goose (Block's goose, its CLI and desktop app alike) reaches magpie as a
@@ -69,8 +68,9 @@ var gooseKeys = map[string]string{
// (migrate_provider_config, config/migrations.rs), and a provider whose key
// is in the environment. A key in goose's keyring can't be seen from here;
// such a provider still counts once goose has used it, as each provider it
// is set up with gets an entry.
func gooseConfigured(cfg string) []string {
// is set up with gets an entry. getenv is the environment goose runs in:
// none of a WSL distro's is read.
func gooseConfigured(cfg string, getenv func(string) string) []string {
set := map[string]bool{}
if b, err := os.ReadFile(cfg); err == nil {
var c map[string]any
@@ -95,7 +95,7 @@ func gooseConfigured(cfg string) []string {
}
}
for p, k := range gooseKeys {
if os.Getenv(k) != "" {
if getenv(k) != "" {
set[p] = true
}
}
@@ -114,9 +114,9 @@ func gooseProviderPath(cfg string) string {
return filepath.Join(filepath.Dir(cfg), "custom_providers", gooseProviderID+".json")
}
// gooseProviderJSON is magpie as a Goose custom provider, its models the
// catalog as Goose is shown it.
func gooseProviderJSON() map[string]any {
// gooseProviderJSON is magpie as a custom provider of the Goose at a
// place, its models the catalog as Goose is shown it.
func gooseProviderJSON(at place) map[string]any {
models := []map[string]any{}
for _, m := range magpieModels("goose") {
e := map[string]any{"name": m.ID, "reasoning": gooseThinks.MatchString(m.ID)}
@@ -131,8 +131,8 @@ func gooseProviderJSON() map[string]any {
"display_name": "magpie",
"description": "Every model magpie has, through its local gateway",
"api_key_env": "",
"base_url": gatewayV1(),
"headers": map[string]any{"Authorization": "Bearer " + gateway.Token},
"base_url": at.v1(),
"headers": map[string]any{"Authorization": "Bearer " + at.gwKey()},
"requires_auth": false,
"skip_canonical_filtering": true,
"models": models,
@@ -145,8 +145,8 @@ func gooseProviderJSON() map[string]any {
}
// writeGooseProvider writes magpie's custom provider file for Goose.
func writeGooseProvider(path string) error {
b, err := json.MarshalIndent(gooseProviderJSON(), "", " ")
func writeGooseProvider(path string, at place) error {
b, err := json.MarshalIndent(gooseProviderJSON(at), "", " ")
if err != nil {
return err
}
@@ -158,15 +158,15 @@ func writeGooseProvider(path string) error {
// syncGooseProvider rewrites magpie's custom provider file for Goose with
// the catalog as it is now, where magpie wrote one and it differs.
func syncGooseProvider(path string) error {
func syncGooseProvider(path string, at place) error {
raw, err := edit.Read(path)
if err != nil || raw == nil {
return nil
}
if sameJSON(string(raw), gooseProviderJSON()) {
if sameJSON(string(raw), gooseProviderJSON(at)) {
return nil
}
return writeGooseProvider(path)
return writeGooseProvider(path, at)
}
// removeGooseProvider takes magpie's custom provider file away once Goose's
+28 -12
View File
@@ -21,7 +21,6 @@ import (
"github.com/pelletier/go-toml/v2"
"github.com/yetone/magpie/internal/catalog"
"github.com/yetone/magpie/internal/edit"
"github.com/yetone/magpie/internal/gateway"
"github.com/yetone/magpie/internal/provider"
)
@@ -78,8 +77,19 @@ type reasonixState struct {
AddedAccess bool `json:"added_access"`
}
func reasonix(home string) *Agent {
dir := reasonixHome(runtime.GOOS, home, os.Getenv)
func reasonix(home string) *Agent { return reasonixIn(here(home)) }
// reasonixIn is Reasonix at a place: this machine's home, or a WSL
// distro's (see wsl.go), where its CLI keeps Linux's ~/.reasonix
// (REASONIX_HOME isn't read) and its provider names the gateway as the
// distro reaches it, with the key it takes from there.
func reasonixIn(at place) *Agent {
goos, getenv := runtime.GOOS, os.Getenv
if at.spell != nil {
goos, getenv = "linux", func(string) string { return "" }
}
dir := reasonixHome(goos, at.home, getenv)
gwKey := func() string { return agentKeyAt("reasonix", at.gw()) }
if absolute, err := filepath.Abs(dir); err == nil {
dir = absolute
}
@@ -203,7 +213,7 @@ func reasonix(home string) *Agent {
return err
}
}
if v, exists := edit.GetEnvFile(env, reasonixKey); exists && v == gateway.TokenFor("reasonix") {
if v, exists := edit.GetEnvFile(env, reasonixKey); exists && v == gwKey() {
if err := edit.DelEnvFile(env, reasonixKey); err != nil {
return err
}
@@ -240,7 +250,7 @@ func reasonix(home string) *Agent {
currentState = &reasonixState{Version: 1, Default: cfg.DefaultModel, NewConfig: os.IsNotExist(cErr), NewEnv: os.IsNotExist(eErr)}
currentState.AddedAccess = cfg.Desktop.Access != nil && !slices.Contains(cfg.Desktop.Access, magpieID)
}
p := reasonixCatalog(models, selected, cfg.magpie().Effort)
p := reasonixCatalogAt(models, selected, cfg.magpie().Effort, at.v1())
if err := writeProvider(p); err != nil {
return err
}
@@ -251,8 +261,8 @@ func reasonix(home string) *Agent {
}
currentState.AddedAccess = true
}
if token, _ := edit.GetEnvFile(env, reasonixKey); token != gateway.TokenFor("reasonix") {
if err := edit.SetEnvFile(env, edit.KV{Path: reasonixKey, Value: gateway.TokenFor("reasonix")}); err != nil {
if token, _ := edit.GetEnvFile(env, reasonixKey); token != gwKey() {
if err := edit.SetEnvFile(env, edit.KV{Path: reasonixKey, Value: gwKey()}); err != nil {
return err
}
}
@@ -289,7 +299,7 @@ func reasonix(home string) *Agent {
a := &Agent{
ID: "reasonix", Name: "Reasonix Studio", Icon: "reasonix-color", Aliases: []string{"reasonix-studio"},
Bin: "reasonix", Dir: dir, Path: path, UA: []string{"reasonix"},
detect: func() bool { return reasonixDetected(home) },
detect: func() bool { return reasonixDetected(at.home) },
Notice: func() string {
return "Reasonix Studio 2.x and the native 1.39.x/2.x CLI read global settings at startup. Start a new process to load changes. Project/session models can override Executor and Plan."
},
@@ -318,10 +328,10 @@ func reasonix(home string) *Agent {
if err := checkOwner(cfg, state); err != nil {
return err.Error()
}
if p.Kind != "openai" || p.BaseURL != gatewayV1() {
if p.Kind != "openai" || p.BaseURL != at.v1() {
return "Reasonix's magpie base_url or kind changed"
}
if token, _ := edit.GetEnvFile(env, reasonixKey); token != gateway.TokenFor("reasonix") {
if token, _ := edit.GetEnvFile(env, reasonixKey); token != gwKey() {
return "Reasonix's gateway credential changed"
}
if !slices.Contains(p.Models, ref) || cfg.Agent.Planner != nil && usesMagpie(*cfg.Agent.Planner) && !slices.Contains(p.Models, strings.TrimPrefix(*cfg.Agent.Planner, magpieID+"/")) {
@@ -351,7 +361,7 @@ func reasonix(home string) *Agent {
if len(models) == 0 {
return fmt.Errorf("magpie has no visible Reasonix models")
}
next := reasonixCatalog(models, current.Default, current.Effort)
next := reasonixCatalogAt(models, current.Default, current.Effort, at.v1())
if reflect.DeepEqual(current, next) {
return nil
}
@@ -618,7 +628,13 @@ func (cfg reasonixConfig) hasModel(ref string) bool {
}
func reasonixCatalog(models []catalog.Model, selected, effort string) reasonixProvider {
p := reasonixProvider{Name: magpieID, Kind: "openai", BaseURL: gatewayV1(), APIKeyEnv: reasonixKey,
return reasonixCatalogAt(models, selected, effort, gatewayV1())
}
// reasonixCatalogAt is reasonixCatalog for a Reasonix reaching the
// gateway's /v1 at v1.
func reasonixCatalogAt(models []catalog.Model, selected, effort, v1 string) reasonixProvider {
p := reasonixProvider{Name: magpieID, Kind: "openai", BaseURL: v1, APIKeyEnv: reasonixKey,
Default: selected, Protocol: "openai", NoProxy: true, Overrides: map[string]reasonixModel{}}
for _, m := range models {
p.Models = append(p.Models, m.ID)
+16 -2
View File
@@ -125,7 +125,15 @@ func workbuddyWrite(path string, on bool) error { return buddyWrite(path, "workb
// WorkBuddy, or CodeBuddy Code (codebuddy.go), whose models go under the
// agent's own key. A new file is a bare list for WorkBuddy, as it writes
// one, and {"models":[…]} for CodeBuddy Code, as its docs give it.
func buddyWrite(path, agent string, on bool) error {
func buddyWrite(path, agent string, on bool) error { return buddyWriteAt(path, agent, on, place{}) }
// buddyWriteAt is buddyWrite for the agent at a place. One in a WSL distro
// (where.base set) is written the gateway as the distro reaches it, with the
// key it takes from there, at every write: its address is the distro's
// view of Windows, which moves, so it is never kept as a magpie of the
// user's on another machine.
func buddyWriteAt(path, agent string, on bool, where place) error {
away := where.base != nil
d, err := workbuddyRead(path)
if err != nil {
return err
@@ -136,6 +144,9 @@ func buddyWrite(path, agent string, on bool) error {
// the keys magpie's models are asked with: the gateway's, or that of a
// magpie on another machine the user pointed them at
keys := map[string]bool{gateway.TokenFor(agent): true}
if away {
keys[agentKeyAt(agent, where.gw())] = true
}
for _, raw := range d.models {
if e, ok := workbuddyMine(raw); ok && e.APIKey != "" {
keys[e.APIKey] = true
@@ -159,7 +170,7 @@ func buddyWrite(path, agent string, on bool) error {
if at < 0 {
at = len(kept)
}
if remote == "" && onAnotherMachine(e.URL) {
if !away && remote == "" && onAnotherMachine(e.URL) {
remote, remoteKey = e.URL, e.APIKey
}
ours = append(ours, e.ID)
@@ -195,6 +206,9 @@ func buddyWrite(path, agent string, on bool) error {
}
}
}
if away {
e["url"], e["apiKey"] = where.v1()+"/chat/completions", agentKeyAt(agent, where.gw())
}
if remote != "" {
e["url"] = remote
if remoteKey != "" {
+93 -3
View File
@@ -204,12 +204,17 @@ func (d distro) place(id string) place {
// of it, and how it lives at a place. Adding one here is all it takes.
type wslKind struct {
id, name string
dir, bin string // under $HOME, and on its PATH: either says it is there
// dir, under $HOME, and bin, on its PATH: either says it is there; ""
// for one that says nothing (a folder other tools keep too)
dir, bin string
in func(place) *Agent // the agent at a place, as on this machine
restart string // advice after a change, when mirrored; "" none
// version: the probe asks the distro's own bin its version (--version),
// for an agent whose config depends on it
version bool
// accept, when set, is whether what the probe found is the agent: a
// command another tool's release shares the name of, told by its version
accept func(d distro) bool
// asleep, when set, is a stopped distro's options for a field, which
// must read nothing of its files; nil keeps the live agent's
asleep func(key string) func(map[string]string) []Option
@@ -371,6 +376,86 @@ var wslKinds = []wslKind{
restart: "reads its settings as a session starts — open sessions keep the model they have; new ones use this."},
{id: "qoder-cn", name: "Qoder CN", dir: ".qoder-cn", bin: "qoderclicn", in: qoderCNIn,
restart: "reads its settings as a session starts — open sessions keep the model they have; new ones use this."},
// lgtm on Discord: magpie didn't find a CodeBuddy Code installed in WSL
{id: "codebuddy", name: "CodeBuddy Code", dir: ".codebuddy", bin: "codebuddy", in: codebuddyIn,
restart: "reads its model when a session starts — restart open codebuddy sessions, or run /clear in them, to use this."},
// Cline's CLI, and its VS Code extension in a Remote - WSL window, which
// keep the same ~/.cline
{id: "cline", name: "Cline", dir: ".cline", bin: "cline", in: clineIn,
restart: "reads its provider as a session starts — open sessions keep the model they have; new ones use this. Reload VS Code's window for its extension.",
asleep: wslOwnAsleep("cline", "model")},
{id: "atomcode", name: "AtomCode", dir: ".atomcode", bin: "atomcode", in: atomcodeIn,
restart: "reads its settings at start-up — restart open atomcode sessions to use this.",
asleep: func(key string) func(map[string]string) []Option {
if key != "model" {
return nil
}
// the models of its config.toml are the distro's files
return func(cur map[string]string) []Option {
return append(atomcodeOwnModels("", cur["model"]), viaMagpie("atomcode", magpieID+"/")...)
}
}},
// no dir: Antigravity keeps its folders in ~/.gemini too, so only the
// command says Gemini CLI is there
{id: "gemini", name: "Gemini CLI", bin: "gemini", in: geminiIn,
restart: "reads its settings at start-up — restart open gemini sessions to see this.",
asleep: func(key string) func(map[string]string) []Option {
if key != "provider" {
return nil
}
// which key its .env has is the distro's file
return func(cur map[string]string) []Option {
out := []Option{
{Value: "google", Label: "Google", Icon: "gemini-color", Note: "Google account · OAuth sign-in"},
{Value: "api-key", Label: "API key", Icon: "gemini-color"},
{Value: "vertex", Label: "Vertex AI", Icon: "googlecloud-color", Note: "Vertex AI · $GOOGLE_CLOUD_PROJECT"},
}
if v := cur["provider"]; v == magpieID || v == "custom" {
out = append(out, Option{Value: v})
}
return out
}
}},
// Block's goose, by its folder alone: a goose command may be pressly's
// database migration tool
{id: "goose", name: "Goose", dir: ".config/goose", in: func(at place) *Agent { return gooseIn(at, filepath.Join(at.home, ".config")) },
restart: "loads its providers at start-up — restart open goose sessions to use magpie's models.",
asleep: wslOwnAsleep("goose", "model")},
// Reasonix's native CLI (1.39 on), by its command and the version it
// gives: the historical npm client is reasonix too, and ~/.reasonix is
// both's
{id: "reasonix", name: "Reasonix", bin: "reasonix", in: reasonixIn, version: true,
accept: func(d distro) bool {
v := d.Versions["reasonix"]
return strings.HasPrefix(v, "2.") || strings.HasPrefix(v, "1.39.")
},
restart: "reads its settings at start-up — start a new reasonix process to use this.",
asleep: func(key string) func(map[string]string) []Option {
switch key {
case "model", "planner":
return func(cur map[string]string) []Option {
out := append(ownOptions("", cur[key]), viaMagpie("reasonix", magpieID+"/")...)
if key == "planner" {
out = append([]Option{{Value: "off", Label: "off", Note: "Disable the separate planner"}}, out...)
}
return out
}
case "effort":
// the levels of magpie's model, as its provider is written them
return func(cur map[string]string) []Option {
ref, ok := strings.CutPrefix(cur["model"], magpieID+"/")
if !ok {
return nil
}
levels := reasonixCatalogAt(magpieModels("reasonix"), ref, "", "").Overrides[ref].Efforts
if len(levels) == 0 {
return nil
}
return static(append([]string{"auto"}, levels...)...)
}
}
return nil
}},
}
// wslOwnAsleep is a stopped distro's options for an agent's model fields
@@ -389,7 +474,10 @@ func wslOwnAsleep(id string, keys ...string) func(string) func(map[string]string
// found reports whether the probe found the agent in d.
func (k wslKind) found(d distro) bool {
return d.Has["dir:"+k.dir] || k.bin != "" && d.Has["bin:"+k.bin]
if k.accept != nil && !k.accept(d) {
return false
}
return k.dir != "" && d.Has["dir:"+k.dir] || k.bin != "" && d.Has["bin:"+k.bin]
}
// memo is the key a field of the agent is kept under in distro.Values:
@@ -903,7 +991,9 @@ var wslProbeScript = func() string {
s := `wslbin() { for d in "$HOME/.bun/bin" "$HOME/.local/bin" "$HOME/.npm-global/bin" "$HOME/.volta/bin" "$HOME/.local/share/pnpm" "$HOME"/.nvm/versions/node/*/bin "$HOME/.local/share/mise/shims" "$HOME/.asdf/shims"; do [ -x "$d/$1" ] && { echo "$d/$1"; return 0; }; done; return 1; }; `
s += `echo "home:$HOME"; `
for _, k := range wslKinds {
s += `[ -d "$HOME/` + k.dir + `" ] && echo dir:` + k.dir + `; `
if k.dir != "" {
s += `[ -d "$HOME/` + k.dir + `" ] && echo dir:` + k.dir + `; `
}
if k.bin != "" {
s += `p=$(command -v ` + k.bin + ` 2>/dev/null || wslbin ` + k.bin + `) && echo "bin:` + k.bin + ` $p"`
if k.version {
+178 -14
View File
@@ -34,6 +34,29 @@ var wslMore = []struct {
{"muse", ".config/muse/settings.json", "/v1"},
{"qoder", ".qoder/settings.json", "/v1"},
{"qoder-cn", ".qoder-cn/settings.json", "/v1"},
// lgtm on Discord: a CodeBuddy Code in WSL wasn't found, nor were the
// other CLIs magpie set up only on this machine
{"codebuddy", ".codebuddy/models.json", "/v1/chat/completions"},
{"cline", ".cline/data/settings/providers.json", "/v1"},
{"atomcode", ".atomcode/config.toml", "/v1"},
{"goose", ".config/goose/custom_providers/magpie.json", "/v1"},
{"reasonix", ".reasonix/config.toml", "/v1"},
}
// wslFinds is what a probe of a distro with the agent of kind k prints,
// and the distro it makes: its folder, or for one found by its command
// alone, the command and the version it gives.
func wslFinds(k wslKind) (probe string, has map[string]bool, versions map[string]string) {
if k.dir != "" {
return "dir:" + k.dir + "\n", map[string]bool{"dir:" + k.dir: true}, nil
}
probe = "bin:" + k.bin + " /home/me/.local/bin/" + k.bin + "\n"
has = map[string]bool{"bin:" + k.bin: true}
if k.version {
probe += "ver:" + k.id + " " + k.bin + " v2.1.0\n"
versions = map[string]string{k.id: "2.1.0"}
}
return probe, has, versions
}
// moveHostDirs points the variables that move these agents' folders on
@@ -43,7 +66,8 @@ func moveHostDirs(t *testing.T) string {
t.Helper()
moved := t.TempDir()
for _, v := range []string{"OPENCODE_CONFIG_DIR", "MIMOCODE_HOME", "KIMI_CODE_HOME", "KIMI_SHARE_DIR", "PI_CODING_AGENT_DIR",
"HERMES_HOME", "GROK_HOME", "FACTORY_HOME_OVERRIDE", "MINIMAX_DATA_DIR", "QODER_CONFIG_DIR", "QODERCN_CONFIG_DIR", "DSH_HOME", "MISTER_MORPH_CONFIG"} {
"HERMES_HOME", "GROK_HOME", "FACTORY_HOME_OVERRIDE", "MINIMAX_DATA_DIR", "QODER_CONFIG_DIR", "QODERCN_CONFIG_DIR", "DSH_HOME", "MISTER_MORPH_CONFIG",
"CODEBUDDY_CONFIG_DIR", "CLINE_DIR", "CLINE_DATA_DIR", "ATOMCODE_HOME", "REASONIX_HOME", "APPDATA"} {
t.Setenv(v, filepath.Join(moved, v))
}
return moved
@@ -55,25 +79,27 @@ func moveHostDirs(t *testing.T) string {
func TestWSLProbeFindsMoreAgents(t *testing.T) {
for _, c := range wslMore {
k := wslKindOf(c.id)
if !strings.Contains(wslProbeScript, `[ -d "$HOME/`+k.dir+`" ] && echo dir:`+k.dir+`; `) {
if k.dir != "" && !strings.Contains(wslProbeScript, `[ -d "$HOME/`+k.dir+`" ] && echo dir:`+k.dir+`; `) {
t.Errorf("%s: probe lacks its folder %s", c.id, k.dir)
}
if !strings.HasPrefix(c.file, k.dir+"/") {
if k.dir != "" && !strings.HasPrefix(c.file, k.dir+"/") {
t.Errorf("%s: its file %s isn't under %s", c.id, c.file, k.dir)
}
if k.bin != "" && !strings.Contains(wslProbeScript, `command -v `+k.bin+` `) {
t.Errorf("%s: probe lacks its command %s", c.id, k.bin)
}
if d := parseProbe("U", "home:/home/me\ndir:"+k.dir+"\n"); d == nil || !k.found(*d) {
t.Errorf("%s: not found by its folder", c.id)
if p, _, _ := wslFinds(k); k.dir != "" || k.version {
if d := parseProbe("U", "home:/home/me\n"+p); d == nil || !k.found(*d) {
t.Errorf("%s: not found by %q", c.id, p)
}
}
if k.bin != "" {
if k.bin != "" && !k.version {
if d := parseProbe("U", "home:/home/me\nbin:"+k.bin+" /usr/bin/"+k.bin+"\n"); d == nil || !k.found(*d) {
t.Errorf("%s: not found by its command", c.id)
}
}
}
for _, bin := range []string{"fx", "grok"} {
for _, bin := range []string{"fx", "grok", "goose"} {
if strings.Contains(wslProbeScript, `command -v `+bin+` `) {
t.Errorf("probe takes any %s command for the agent", bin)
}
@@ -92,7 +118,8 @@ func TestWSLMoreAgentsDiscovered(t *testing.T) {
probe := "home:/home/me\nroute:default via 172.20.0.1 dev eth0\n"
var want []string
for _, c := range wslMore {
probe += "dir:" + wslKindOf(c.id).dir + "\n"
p, _, _ := wslFinds(wslKindOf(c.id))
probe += p
want = append(want, c.id+"@wsl:Ubuntu")
}
_, opened := fakeWSL(t, "Ubuntu\r\n", "Ubuntu\r\n", map[string]string{"Ubuntu": probe}, map[string]string{"Ubuntu": root})
@@ -128,8 +155,11 @@ func TestWSLMoreAgentsPick(t *testing.T) {
root := t.TempDir()
dhome := filepath.Join(root, "home", "me")
k := wslKindOf(c.id)
os.MkdirAll(filepath.Join(dhome, filepath.FromSlash(k.dir)), 0o755)
d := distro{Name: "Ubuntu", Root: root, Home: "/home/me", Has: map[string]bool{"dir:" + k.dir: true},
if k.dir != "" {
os.MkdirAll(filepath.Join(dhome, filepath.FromSlash(k.dir)), 0o755)
}
_, has, versions := wslFinds(k)
d := distro{Name: "Ubuntu", Root: root, Home: "/home/me", Has: has, Versions: versions,
Gateway: "172.20.0.1", Mirrored: mirrored, Running: true}
a := wslAgent(k, d)
if err := a.Field("model").Set("magpie/relay/glm-4.6"); err != nil {
@@ -150,8 +180,10 @@ func TestWSLMoreAgentsPick(t *testing.T) {
if v := a.Field("model").Get(); !usesMagpie(v) {
t.Fatalf("%s: model %q", c.id, v)
}
if msg := a.Check(); msg != "" {
t.Fatalf("%s mirrored %v: %s", c.id, mirrored, msg)
if a.Check != nil {
if msg := a.Check(); msg != "" {
t.Fatalf("%s mirrored %v: %s", c.id, mirrored, msg)
}
}
if a.Sync != nil {
if err := a.Sync(); err != nil || !strings.Contains(readFile(path), gw+c.suffix) {
@@ -164,7 +196,11 @@ func TestWSLMoreAgentsPick(t *testing.T) {
if mirrored && !strings.HasPrefix(a.Notice(), k.name+" in WSL Ubuntu ") {
t.Errorf("%s: %s", c.id, a.Notice())
}
for _, dir := range []string{filepath.Join(home, filepath.FromSlash(k.dir)), moved} {
own := k.dir
if own == "" {
own, _, _ = strings.Cut(c.file, "/")
}
for _, dir := range []string{filepath.Join(home, filepath.FromSlash(own)), moved} {
if es, _ := os.ReadDir(dir); len(es) > 0 {
t.Fatalf("%s: this machine's %s was touched", c.id, dir)
}
@@ -181,8 +217,9 @@ func TestWSLMoreAgentsStopped(t *testing.T) {
syncHome(t)
root := t.TempDir()
k := wslKindOf(c.id)
_, has, versions := wslFinds(k)
b, _ := json.Marshal(map[string]*distro{
"Stopped": {Name: "Stopped", Home: "/home/me", Root: root, Has: map[string]bool{"dir:" + k.dir: true},
"Stopped": {Name: "Stopped", Home: "/home/me", Root: root, Has: has, Versions: versions, Probe: wslProbeVersion,
Values: map[string]string{k.memo("model"): "own/last-seen"}},
})
os.MkdirAll(filepath.Dir(wslStatePath()), 0o755)
@@ -319,3 +356,130 @@ func TestWSLDshProfiles(t *testing.T) {
}
}
}
// Gemini CLI in a distro is found by its command alone (Antigravity keeps
// ~/.gemini too), and a magpie model points the distro's ~/.gemini/.env at
// the gateway as the distro reaches it, with the key it takes from there;
// a model of its own puts back what that replaced.
func TestWSLGemini(t *testing.T) {
home := syncHome(t)
k := wslKindOf("gemini")
if strings.Contains(wslProbeScript, `"$HOME/" ]`) {
t.Fatal("the probe asks after $HOME itself")
}
if d := parseProbe("U", "home:/home/me\ndir:.gemini\n"); d != nil && k.found(*d) {
t.Fatal("found by a ~/.gemini Antigravity keeps too")
}
if d := parseProbe("U", "home:/home/me\nbin:gemini /home/me/.npm-global/bin/gemini\n"); d == nil || !k.found(*d) {
t.Fatal("not found by its command")
}
root := t.TempDir()
dhome := filepath.Join(root, "home", "me")
env := filepath.Join(dhome, ".gemini", ".env")
os.MkdirAll(filepath.Dir(env), 0o755)
os.WriteFile(env, []byte("GEMINI_API_KEY=mine\n"), 0o600)
d := distro{Name: "Ubuntu", Root: root, Home: "/home/me", Has: map[string]bool{"bin:gemini": true},
Gateway: "172.20.0.1", Running: true}
a := wslAgent(k, d)
if !a.Detected() {
t.Fatal("not detected")
}
if err := a.Field("model").Set("relay/glm-4.6"); err != nil {
t.Fatal(err)
}
gw := "http://172.20.0.1:" + gateway.Port()
if v, _ := edit.GetEnvFile(env, "GOOGLE_GEMINI_BASE_URL"); v != gw {
t.Fatalf(".env:\n%s", readFile(env))
}
if v, _ := edit.GetJSON(filepath.Join(dhome, ".gemini", "settings.json"), "model.name"); v != "relay/glm-4.6" {
t.Fatalf("model %q", v)
}
if msg := a.Check(); msg != "" {
t.Fatal(msg)
}
if v := a.Field("provider").Get(); v != magpieID {
t.Fatalf("auth %q", v)
}
if err := a.Field("model").Set("gemini-2.5-pro"); err != nil {
t.Fatal(err)
}
if v, _ := edit.GetEnvFile(env, "GEMINI_API_KEY"); v != "mine" {
t.Fatalf("the key of its own isn't back:\n%s", readFile(env))
}
if _, set := edit.GetEnvFile(env, "GOOGLE_GEMINI_BASE_URL"); set {
t.Fatalf(".env:\n%s", readFile(env))
}
if es, _ := os.ReadDir(filepath.Join(home, ".gemini")); len(es) > 0 {
t.Fatal("this machine's ~/.gemini was touched")
}
}
// A distro's address under NAT changes when WSL restarts: the next sync
// names the gateway at the new one, and the old is gone from the file.
func TestWSLMoreAgentsFollowTheAddress(t *testing.T) {
for _, c := range wslMore {
syncHome(t)
moveHostDirs(t)
root := t.TempDir()
k := wslKindOf(c.id)
if k.dir != "" {
os.MkdirAll(filepath.Join(root, "home", "me", filepath.FromSlash(k.dir)), 0o755)
}
_, has, versions := wslFinds(k)
d := distro{Name: "Ubuntu", Root: root, Home: "/home/me", Has: has, Versions: versions, Gateway: "172.20.0.1", Running: true}
if err := wslAgent(k, d).Field("model").Set("magpie/relay/glm-4.6"); err != nil {
t.Fatalf("%s: %v", c.id, err)
}
d.Gateway = "172.29.0.1"
a := wslAgent(k, d)
if a.Sync == nil {
continue
}
if err := a.Sync(); err != nil {
t.Fatalf("%s: %v", c.id, err)
}
body := readFile(filepath.Join(root, "home", "me", filepath.FromSlash(c.file)))
if !strings.Contains(body, "http://172.29.0.1:"+gateway.Port()+c.suffix) || strings.Contains(body, "172.20.0.1") {
t.Errorf("%s: %s after the address moved:\n%s", c.id, c.file, body)
}
}
}
// Gemini CLI in a distro whose address moved is on magpie still, and
// picking its model again names the new address without taking the old
// one for the user's own: a model of its own then puts back their key.
func TestWSLGeminiAddressMoved(t *testing.T) {
syncHome(t)
k := wslKindOf("gemini")
root := t.TempDir()
env := filepath.Join(root, "home", "me", ".gemini", ".env")
writeFile(t, env, "GEMINI_API_KEY=mine\n")
d := distro{Name: "Ubuntu", Root: root, Home: "/home/me", Has: map[string]bool{"bin:gemini": true},
Gateway: "172.20.0.1", Running: true}
if err := wslAgent(k, d).Field("model").Set("relay/glm-4.6"); err != nil {
t.Fatal(err)
}
d.Gateway = "172.29.0.1"
a := wslAgent(k, d)
if v := a.Field("provider").Get(); v != magpieID {
t.Fatalf("auth %q", v)
}
if a.Check() == "" {
t.Fatal("the old address passes")
}
if err := a.Field("model").Set("relay/glm-4.6"); err != nil {
t.Fatal(err)
}
if v, _ := edit.GetEnvFile(env, "GOOGLE_GEMINI_BASE_URL"); v != "http://172.29.0.1:"+gateway.Port() {
t.Fatalf(".env:\n%s", readFile(env))
}
if msg := a.Check(); msg != "" {
t.Fatal(msg)
}
if err := a.Field("model").Set("gemini-2.5-pro"); err != nil {
t.Fatal(err)
}
if body := readFile(env); body != "GEMINI_API_KEY=mine\n" {
t.Fatalf(".env:\n%s", body)
}
}