agent: configure Zed models and gateway credentials automatically (#584)
* agent: configure Zed models and gateway credentials automatically Add Zed to the CLI and Agents page with an OpenAI-compatible magpie provider, a synced model catalog and default model selection. Preserve existing JSONC settings and restore the previous provider and default model on reset. Store the gateway credential in Secret Service, macOS Keychain or Windows Credential Manager so Zed needs no manual API key entry. Cover config preservation, catalog sync, model limits, Flatpak paths and credential failures. Verified with the full nogui test suite, go vet, macOS and Windows cross compilation, and a local Zed conversation through the gateway. Amp-Thread-ID: https://ampcode.com/threads/T-01a0fd03-00d7-75fd-956f-c505e07ba60d Co-authored-by: Amp <amp@ampcode.com> * agent: store Zed credentials directly through Secret Service D-Bus Replace the Linux and FreeBSD secret-tool subprocess with the existing godbus client. Open a session, create the default collection when needed, unlock it and replace the matching Zed credential. Handle collection, unlock and item prompts, dismissal and timeout. Verify the protocol on isolated D-Bus instances with no helper executables on PATH, including immediate completion signals and failed writes. The full nogui suite, targeted vet and race checks, macOS and Windows cross compilation pass; local credential readback confirms replacement without duplication. Amp-Thread-ID: https://ampcode.com/threads/T-01a0fd03-00d7-75fd-956f-c505e07ba60d Co-authored-by: Amp <amp@ampcode.com> * docs: remove the separate Zed configuration paragraph Amp-Thread-ID: https://ampcode.com/threads/T-01a0fd03-00d7-75fd-956f-c505e07ba60d Co-authored-by: Amp <amp@ampcode.com> * agent: fix Zed reset, keychain access and translated notices Avoid restoring magpie’s own provider after a model change in Zed, and cover reselection followed by reset. Remove the unsupported Flatpak config path. Authorize the installed Zed app and security command to access the macOS gateway credential. Translate Zed’s notice and apply translation when selecting or reapplying an agent model. Verified with the full nogui test suite, go vet, Zed race tests, cross compilation and browser checks. macOS keychain behavior still needs runtime verification on a Mac. Amp-Thread-ID: https://ampcode.com/threads/T-01a0fd70-98a1-737f-93bd-77e181f904c1 Co-authored-by: XYenon <register@xyenon.bid> * agent: preserve Zed model picks when resetting magpie Restore the stashed default model only while Zed still selects magpie. Preserve a model chosen in Zed itself and discard the stale model stash, while restoring the previous provider. Find Zed Preview in Applications folders and Spotlight without requiring a CLI, and use any in the isolated D-Bus tests. Verified the reset regression fails before the fix and passes afterward. The full nogui suite, go vet, Zed race tests and macOS/Windows test cross compilation pass. Amp-Thread-ID: https://ampcode.com/threads/T-01a0fd70-98a1-737f-93bd-77e181f904c1 Co-authored-by: XYenon <register@xyenon.bid> * agent: retain Zed provider backup when reselecting magpie When Zed’s provider already points at the gateway, update only the model stash and preserve the original provider backup. Reset can then restore the user’s provider after a native model pick followed by another magpie pick. Add a regression covering the full sequence and checking the restored provider, model catalog, custom headers and latest native model. Confirm it fails before the fix and passes afterward; all Zed tests, the full nogui suite and go vet pass. Amp-Thread-ID: https://ampcode.com/threads/T-01a0fd70-98a1-737f-93bd-77e181f904c1 Co-authored-by: XYenon <register@xyenon.bid> --------- Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
1 parent
12cc2089e4
commit
6e6338f8d6
12 files changed
+948
-3
No files matched your search
@@ -81,6 +81,7 @@ and there is a terminal version (`magpie tui`) and a plain CLI.
|
||||
| OmO (omo-ai) | `~/.omo/agent/settings.json` (+ `models.json`; `$OMO_CODING_AGENT_DIR`, `$SENPI_CODING_AGENT_DIR`) | model |
|
||||
| Goose | `~/.config/goose/config.yaml` | model |
|
||||
| Cursor CLI | `~/.cursor/cli-config.json` | model |
|
||||
| Zed | `~/.config/zed/settings.json` (`$XDG_CONFIG_HOME` on Linux, `%APPDATA%\Zed` on Windows) | model (a `magpie` OpenAI-compatible provider; its catalog in Zed's picker) |
|
||||
| Copilot CLI | `~/.copilot/settings.json` | model |
|
||||
| Crush | `~/.config/crush/crush.json` | large, small |
|
||||
| DeepSeek Harness (dsh) | `~/.dsh/profiles/*/cordis.patch.yml` (`$DSH_HOME`; a custom provider, Magpie), or `~/.dsh/config.yaml` before dsh 0.1.5 | model, effort |
|
||||
|
||||
@@ -70,6 +70,7 @@ func All() []*Agent {
|
||||
omo(home),
|
||||
goose(home, cfg),
|
||||
cursor(home),
|
||||
zed(home, cfg),
|
||||
copilot(home),
|
||||
crush(home, cfg),
|
||||
dsh(home),
|
||||
|
||||
@@ -32,6 +32,8 @@ func TestMain(m *testing.M) {
|
||||
// whether Codex's ChatGPT account is out of its allowance is asked of
|
||||
// OpenAI; never from here
|
||||
codexUsedUp = func() bool { return false }
|
||||
// Sandboxed config writes must never write into the real OS keychain.
|
||||
zedCredential = func(string) error { return nil }
|
||||
code := m.Run()
|
||||
os.RemoveAll(home)
|
||||
os.Exit(code)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/yetone/magpie/internal/edit"
|
||||
)
|
||||
|
||||
const zedProvider = "language_models.openai_compatible.magpie"
|
||||
const zedModel = "agent.default_model"
|
||||
|
||||
// Zed stores API keys in the OS credential store, keyed by the API URL.
|
||||
var zedCredential = saveZedCredential
|
||||
|
||||
// Zed uses ~/.config on macOS, XDG on Linux and Roaming AppData on Windows.
|
||||
func zed(home, cfg string) *Agent {
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
cfg = filepath.Join(home, ".config")
|
||||
case "windows":
|
||||
cfg = os.Getenv("APPDATA")
|
||||
if cfg == "" {
|
||||
cfg = filepath.Join(home, "AppData", "Roaming")
|
||||
}
|
||||
return zedAt(filepath.Join(cfg, "Zed"))
|
||||
}
|
||||
return zedAt(filepath.Join(cfg, "zed"))
|
||||
}
|
||||
|
||||
func zedAt(dir string) *Agent {
|
||||
path := filepath.Join(dir, "settings.json")
|
||||
get := func(k string) string { v, _ := edit.GetJSON(path, k); return v }
|
||||
model := pairGet(func(k string) (string, bool) { return edit.GetJSON(path, k) }, zedModel+".provider", zedModel+".model")
|
||||
key := "zed:" + path + ":"
|
||||
return atomic(&Agent{
|
||||
ID: "zed", Name: "Zed", Icon: "zed", Bin: "zed", Dir: dir, Path: path,
|
||||
UA: []string{"zed"},
|
||||
Notice: func() string {
|
||||
if usesMagpie(model()) && Running(`(^|/)(zed|zeditor|zed-editor)( |$)`) {
|
||||
return "Restart Zed if it still asks for an API key: magpie has configured its gateway credential in the system credential store."
|
||||
}
|
||||
return ""
|
||||
},
|
||||
Sync: func() error {
|
||||
if get(zedProvider+".api_url") != gatewayV1() {
|
||||
return nil
|
||||
}
|
||||
return syncJSON(path, zedProvider+".available_models", func() any {
|
||||
return zedProviderJSON()["available_models"]
|
||||
})
|
||||
},
|
||||
Check: func() string {
|
||||
if !usesMagpie(model()) {
|
||||
return ""
|
||||
}
|
||||
return wiringOff("Zed", path, func(k string) (string, bool) { return edit.GetJSON(path, zedProvider+"."+k) }, "api_url", gatewayV1())
|
||||
},
|
||||
Fields: []Field{{
|
||||
Key: "model", Label: "model", Get: model,
|
||||
Set: func(v string) error {
|
||||
if ref, ok := strings.CutPrefix(v, magpieID+"/"); ok && isMagpie(ref) {
|
||||
if err := zedCredential(gatewayV1()); err != nil {
|
||||
return fmt.Errorf("configure Zed gateway credential: %w", err)
|
||||
}
|
||||
if !usesMagpie(model()) {
|
||||
previous := map[string]string{key + "model": get(zedModel)}
|
||||
if get(zedProvider+".api_url") != gatewayV1() {
|
||||
previous[key+"provider"] = get(zedProvider)
|
||||
}
|
||||
stash(previous)
|
||||
}
|
||||
return edit.SetJSON(path,
|
||||
edit.KV{Path: zedProvider, Value: zedProviderJSON()},
|
||||
edit.KV{Path: zedModel, Value: map[string]string{"provider": magpieID, "model": ref}})
|
||||
}
|
||||
// Validate a native selection before removing magpie's wiring.
|
||||
p, m, ok := strings.Cut(v, "/")
|
||||
if v != "" && (!ok || p == "" || m == "") {
|
||||
return fmt.Errorf("expected provider/model, got %q", v)
|
||||
}
|
||||
if get(zedProvider+".api_url") == gatewayV1() || usesMagpie(model()) {
|
||||
for _, entry := range []struct{ name, field string }{{"provider", zedProvider}, {"model", zedModel}} {
|
||||
if entry.name == "model" && !usesMagpie(model()) {
|
||||
forget(key + entry.name)
|
||||
continue
|
||||
}
|
||||
if was := unstash(key + entry.name); was != "" {
|
||||
if err := edit.SetJSON(path, edit.KV{Path: entry.field, Value: json.RawMessage(was)}); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if entry.name == "provider" || usesMagpie(model()) {
|
||||
if err := edit.DelJSON(path, entry.field); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if v == "" {
|
||||
return edit.DelJSON(path, zedModel)
|
||||
}
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return edit.SetJSON(path, edit.KV{Path: zedModel, Value: map[string]string{"provider": p, "model": m}})
|
||||
},
|
||||
Options: func(cur map[string]string) []Option {
|
||||
var own []Option
|
||||
if v := cur["model"]; v != "" && !usesMagpie(v) {
|
||||
own = append(own, Option{Value: v, Icon: modelIcon("", v)})
|
||||
}
|
||||
return append(group("Zed", own), viaMagpie("zed", magpieID+"/")...)
|
||||
},
|
||||
}},
|
||||
}, path, stashPath())
|
||||
}
|
||||
|
||||
func zedProviderJSON() map[string]any {
|
||||
models := []any{}
|
||||
for _, m := range magpieModels("zed") {
|
||||
context := m.Context
|
||||
if context == 0 {
|
||||
context = 128000 // Zed requires a context window for every custom model.
|
||||
}
|
||||
entry := map[string]any{
|
||||
"name": m.ID, "display_name": m.Name, "max_tokens": context,
|
||||
"capabilities": map[string]any{
|
||||
"tools": true, "images": m.Images,
|
||||
"parallel_tool_calls": false, "prompt_cache_key": false,
|
||||
"chat_completions": true,
|
||||
},
|
||||
}
|
||||
if output := maxTokens(m); output > 0 {
|
||||
entry["max_output_tokens"] = min(output, context)
|
||||
}
|
||||
models = append(models, entry)
|
||||
}
|
||||
return map[string]any{"api_url": gatewayV1(), "available_models": models}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//go:build !windows
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
|
||||
"github.com/yetone/magpie/internal/gateway"
|
||||
"github.com/yetone/magpie/internal/proc"
|
||||
)
|
||||
|
||||
func saveZedCredential(url string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
var name string
|
||||
var args []string
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
name = "security"
|
||||
app, err := zedAppPath(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// GPUI uses an Internet Password whose server is the full API URL.
|
||||
// Keep security trusted as well so it can update the key on later picks.
|
||||
args = []string{"add-internet-password", "-U", "-T", app, "-T", "/usr/bin/security", "-s", url, "-a", "Bearer", "-w", gateway.TokenFor("zed")}
|
||||
case "linux", "freebsd":
|
||||
return saveZedSecret(ctx, url)
|
||||
default:
|
||||
return fmt.Errorf("unsupported system credential store on %s", runtime.GOOS)
|
||||
}
|
||||
cmd := proc.CommandContext(ctx, name, args...)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("%s: %w: %s", name, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Trust the installed Zed bundle, including a CLI symlink to a custom location.
|
||||
func zedAppPath(ctx context.Context) (string, error) {
|
||||
home, _ := os.UserHomeDir()
|
||||
paths := []string{
|
||||
filepath.Join(home, "Applications", "Zed.app"), filepath.Join(home, "Applications", "Zed Preview.app"),
|
||||
"/Applications/Zed.app", "/Applications/Zed Preview.app",
|
||||
}
|
||||
if bin, err := exec.LookPath("zed"); err == nil {
|
||||
if bin, err = filepath.EvalSymlinks(bin); err == nil {
|
||||
for dir := filepath.Dir(bin); dir != filepath.Dir(dir); dir = filepath.Dir(dir) {
|
||||
if strings.HasSuffix(dir, ".app") {
|
||||
paths = append([]string{dir}, paths...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, path := range paths {
|
||||
if info, err := os.Stat(path); err == nil && info.IsDir() {
|
||||
return path, nil
|
||||
}
|
||||
}
|
||||
// Spotlight finds bundles moved outside the usual Applications folders.
|
||||
out, err := proc.CommandContext(ctx, "mdfind", "kMDItemCFBundleIdentifier == 'dev.zed.Zed' || kMDItemCFBundleIdentifier == 'dev.zed.Zed-Preview'").Output()
|
||||
if err == nil {
|
||||
for _, path := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if info, err := os.Stat(path); err == nil && info.IsDir() && strings.HasSuffix(path, ".app") {
|
||||
return path, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("cannot find Zed.app to authorize access to its gateway credential")
|
||||
}
|
||||
|
||||
const secretService = "org.freedesktop.Secret.Service"
|
||||
|
||||
// The field order is the Secret Service D-Bus signature (oayays).
|
||||
type zedSecret struct {
|
||||
Session dbus.ObjectPath
|
||||
Parameters []byte
|
||||
Value []byte
|
||||
ContentType string
|
||||
}
|
||||
|
||||
func saveZedSecret(ctx context.Context, url string) error {
|
||||
conn, err := dbus.ConnectSessionBus()
|
||||
if err != nil {
|
||||
return fmt.Errorf("Secret Service session bus: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
service := conn.Object("org.freedesktop.secrets", "/org/freedesktop/secrets")
|
||||
var output dbus.Variant
|
||||
var session dbus.ObjectPath
|
||||
if err := service.CallWithContext(ctx, secretService+".OpenSession", 0, "plain", dbus.MakeVariant("")).Store(&output, &session); err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Object("org.freedesktop.secrets", session).CallWithContext(ctx, "org.freedesktop.Secret.Session.Close", 0)
|
||||
var collection, prompt dbus.ObjectPath
|
||||
if err := service.CallWithContext(ctx, secretService+".ReadAlias", 0, "default").Store(&collection); err != nil {
|
||||
return err
|
||||
}
|
||||
if collection == "/" {
|
||||
properties := map[string]dbus.Variant{"org.freedesktop.Secret.Collection.Label": dbus.MakeVariant("Default")}
|
||||
if err := service.CallWithContext(ctx, secretService+".CreateCollection", 0, properties, "default").Store(&collection, &prompt); err != nil {
|
||||
return err
|
||||
}
|
||||
if prompt != "/" {
|
||||
result, err := zedSecretPrompt(ctx, conn, prompt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := result.Store(&collection); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
var unlocked []dbus.ObjectPath
|
||||
if err := service.CallWithContext(ctx, secretService+".Unlock", 0, []dbus.ObjectPath{collection}).Store(&unlocked, &prompt); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := zedSecretPrompt(ctx, conn, prompt); err != nil {
|
||||
return err
|
||||
}
|
||||
// GPUI finds credentials by URL, username and this historical label.
|
||||
properties := map[string]dbus.Variant{
|
||||
"org.freedesktop.Secret.Item.Label": dbus.MakeVariant("zed-github-account"),
|
||||
"org.freedesktop.Secret.Item.Attributes": dbus.MakeVariant(map[string]string{
|
||||
"url": url, "username": "Bearer",
|
||||
}),
|
||||
}
|
||||
secret := zedSecret{Session: session, Parameters: []byte{}, Value: []byte(gateway.TokenFor("zed")), ContentType: "text/plain; charset=utf8"}
|
||||
var item dbus.ObjectPath
|
||||
if err := conn.Object("org.freedesktop.secrets", collection).CallWithContext(ctx, "org.freedesktop.Secret.Collection.CreateItem", 0, properties, secret, true).Store(&item, &prompt); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = zedSecretPrompt(ctx, conn, prompt)
|
||||
return err
|
||||
}
|
||||
|
||||
func zedSecretPrompt(ctx context.Context, conn *dbus.Conn, path dbus.ObjectPath) (dbus.Variant, error) {
|
||||
if path == "/" {
|
||||
return dbus.Variant{}, nil
|
||||
}
|
||||
const iface = "org.freedesktop.Secret.Prompt"
|
||||
match := []dbus.MatchOption{dbus.WithMatchSender("org.freedesktop.secrets"), dbus.WithMatchObjectPath(path), dbus.WithMatchInterface(iface), dbus.WithMatchMember("Completed")}
|
||||
if err := conn.AddMatchSignalContext(ctx, match...); err != nil {
|
||||
return dbus.Variant{}, err
|
||||
}
|
||||
defer conn.RemoveMatchSignalContext(ctx, match...)
|
||||
signals := make(chan *dbus.Signal, 1)
|
||||
conn.Signal(signals)
|
||||
defer conn.RemoveSignal(signals)
|
||||
object := conn.Object("org.freedesktop.secrets", path)
|
||||
if err := object.CallWithContext(ctx, iface+".Prompt", 0, "").Err; err != nil {
|
||||
return dbus.Variant{}, err
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case signal, ok := <-signals:
|
||||
if !ok {
|
||||
return dbus.Variant{}, fmt.Errorf("Secret Service connection closed")
|
||||
}
|
||||
if signal.Path != path || signal.Name != iface+".Completed" {
|
||||
continue
|
||||
}
|
||||
var dismissed bool
|
||||
var result dbus.Variant
|
||||
if err := dbus.Store(signal.Body, &dismissed, &result); err != nil {
|
||||
return dbus.Variant{}, err
|
||||
}
|
||||
if dismissed {
|
||||
return dbus.Variant{}, fmt.Errorf("Secret Service prompt dismissed")
|
||||
}
|
||||
return result, nil
|
||||
case <-ctx.Done():
|
||||
// Dismiss with a fresh deadline after the operation timed out.
|
||||
cleanup, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
object.CallWithContext(cleanup, iface+".Dismiss", 0)
|
||||
return dbus.Variant{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//go:build !windows
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestZedCredentialCommand(t *testing.T) {
|
||||
if runtime.GOOS != "darwin" {
|
||||
t.Skip("macOS security command")
|
||||
}
|
||||
bin := t.TempDir()
|
||||
app := filepath.Join(t.TempDir(), "Zed.app")
|
||||
zedBin := filepath.Join(app, "Contents", "MacOS", "zed")
|
||||
writeFile(t, zedBin, "#!/bin/sh\n")
|
||||
if err := os.Chmod(zedBin, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(zedBin, filepath.Join(bin, "zed")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := filepath.EvalSymlinks(app)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", bin)
|
||||
args := filepath.Join(bin, "args")
|
||||
t.Setenv("ZED_TEST_ARGS", args)
|
||||
name := "security"
|
||||
script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$ZED_TEST_ARGS\"\n"
|
||||
if err := os.WriteFile(filepath.Join(bin, name), []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
url := "http://127.0.0.1:7654/v1"
|
||||
if err := saveZedCredential(url); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "add-internet-password\n-U\n-T\n" + app + "\n-T\n/usr/bin/security\n-s\n" + url + "\n-a\nBearer\n-w\nmagpie-zed\n"
|
||||
if got := readFile(args); got != want {
|
||||
t.Fatalf("credential arguments: %q, want %q", got, want)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\necho locked >&2\nexit 1\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := saveZedCredential(url); err == nil || !strings.Contains(err.Error(), "locked") {
|
||||
t.Fatalf("credential store error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZedAppPath(t *testing.T) {
|
||||
bin := t.TempDir()
|
||||
t.Setenv("PATH", bin)
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
app := filepath.Join(home, "Applications", "Zed.app")
|
||||
if err := os.MkdirAll(app, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := zedAppPath(context.Background()); err != nil || got != app {
|
||||
t.Fatalf("home Applications: %q, %v", got, err)
|
||||
}
|
||||
preview := filepath.Join(home, "Applications", "Zed Preview.app")
|
||||
if err := os.Rename(app, preview); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := zedAppPath(context.Background()); err != nil || got != preview {
|
||||
t.Fatalf("Preview without a CLI: %q, %v", got, err)
|
||||
}
|
||||
custom := filepath.Join(t.TempDir(), "Custom Zed.app")
|
||||
zedBin := filepath.Join(custom, "Contents", "MacOS", "zed")
|
||||
writeFile(t, zedBin, "#!/bin/sh\n")
|
||||
if err := os.Chmod(zedBin, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(zedBin, filepath.Join(bin, "zed")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
custom, err := filepath.EvalSymlinks(custom)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := zedAppPath(context.Background()); err != nil || got != custom {
|
||||
t.Fatalf("CLI's bundle: %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
//go:build !windows
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
)
|
||||
|
||||
func TestZedSecretService(t *testing.T) {
|
||||
if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" {
|
||||
t.Skip("Secret Service platform")
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
missing bool
|
||||
prompt string
|
||||
dismissed, timeout, storeError bool
|
||||
}{
|
||||
{name: "existing"},
|
||||
{name: "new collection", missing: true},
|
||||
{name: "collection prompt", missing: true, prompt: "collection"},
|
||||
{name: "unlock prompt", prompt: "unlock"},
|
||||
{name: "item prompt", prompt: "item"},
|
||||
{name: "dismissed", prompt: "unlock", dismissed: true},
|
||||
{name: "timeout", prompt: "unlock", timeout: true},
|
||||
{name: "write failure", storeError: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
conn := zedTestBus(t)
|
||||
// No helper executable is available to the credential writer.
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
const service = "org.freedesktop.Secret.Service"
|
||||
const root dbus.ObjectPath = "/org/freedesktop/secrets"
|
||||
const collection dbus.ObjectPath = "/collection/custom"
|
||||
const session dbus.ObjectPath = "/session/42"
|
||||
const prompt dbus.ObjectPath = "/prompt/7"
|
||||
const url = "http://127.0.0.1:7654/v1"
|
||||
calls := make(chan string, 16)
|
||||
export := func(path dbus.ObjectPath, iface string, methods map[string]any) {
|
||||
if err := conn.ExportMethodTable(methods, path, iface); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
export(root, service, map[string]any{
|
||||
"OpenSession": func(algorithm string, input dbus.Variant) (dbus.Variant, dbus.ObjectPath, *dbus.Error) {
|
||||
if algorithm != "plain" || input.Value() != "" {
|
||||
t.Errorf("session negotiation: %s %v", algorithm, input)
|
||||
}
|
||||
return dbus.MakeVariant(""), session, nil
|
||||
},
|
||||
"ReadAlias": func(alias string) (dbus.ObjectPath, *dbus.Error) {
|
||||
if alias != "default" {
|
||||
t.Errorf("alias: %s", alias)
|
||||
}
|
||||
if tc.missing {
|
||||
return "/", nil
|
||||
}
|
||||
return collection, nil
|
||||
},
|
||||
"CreateCollection": func(properties map[string]dbus.Variant, alias string) (dbus.ObjectPath, dbus.ObjectPath, *dbus.Error) {
|
||||
calls <- "collection"
|
||||
if alias != "default" || properties["org.freedesktop.Secret.Collection.Label"].Value() != "Default" {
|
||||
t.Errorf("new collection: %s %v", alias, properties)
|
||||
}
|
||||
if tc.prompt == "collection" {
|
||||
return "/", prompt, nil
|
||||
}
|
||||
return collection, "/", nil
|
||||
},
|
||||
"Unlock": func(paths []dbus.ObjectPath) ([]dbus.ObjectPath, dbus.ObjectPath, *dbus.Error) {
|
||||
if !reflect.DeepEqual(paths, []dbus.ObjectPath{collection}) {
|
||||
t.Errorf("unlock paths: %v", paths)
|
||||
}
|
||||
if tc.prompt == "unlock" {
|
||||
return []dbus.ObjectPath{}, prompt, nil
|
||||
}
|
||||
return paths, "/", nil
|
||||
},
|
||||
})
|
||||
export(session, "org.freedesktop.Secret.Session", map[string]any{
|
||||
"Close": func() *dbus.Error { calls <- "close"; return nil },
|
||||
})
|
||||
export(collection, "org.freedesktop.Secret.Collection", map[string]any{
|
||||
"CreateItem": func(properties map[string]dbus.Variant, secret struct {
|
||||
Session dbus.ObjectPath
|
||||
Parameters []byte
|
||||
Value []byte
|
||||
ContentType string
|
||||
}, replace bool) (dbus.ObjectPath, dbus.ObjectPath, *dbus.Error) {
|
||||
calls <- "item"
|
||||
want := map[string]string{"url": url, "username": "Bearer"}
|
||||
if properties["org.freedesktop.Secret.Item.Label"].Value() != "zed-github-account" ||
|
||||
!reflect.DeepEqual(properties["org.freedesktop.Secret.Item.Attributes"].Value(), want) || !replace {
|
||||
t.Errorf("item properties: %v, replace %v", properties, replace)
|
||||
}
|
||||
if secret.Session != session || len(secret.Parameters) != 0 || string(secret.Value) != "magpie-zed" || secret.ContentType != "text/plain; charset=utf8" {
|
||||
t.Errorf("incorrect Secret wire value: %+v", secret)
|
||||
}
|
||||
if tc.storeError {
|
||||
return "/", "/", dbus.MakeFailedError(errors.New("store refused"))
|
||||
}
|
||||
if tc.prompt == "item" {
|
||||
return "/", prompt, nil
|
||||
}
|
||||
return "/item/9", "/", nil
|
||||
},
|
||||
})
|
||||
export(prompt, "org.freedesktop.Secret.Prompt", map[string]any{
|
||||
"Prompt": func(window string) *dbus.Error {
|
||||
calls <- "prompt"
|
||||
if window != "" {
|
||||
t.Errorf("window: %s", window)
|
||||
}
|
||||
if tc.timeout {
|
||||
return nil
|
||||
}
|
||||
result := dbus.MakeVariant([]dbus.ObjectPath{collection})
|
||||
if tc.prompt == "collection" {
|
||||
result = dbus.MakeVariant(collection)
|
||||
} else if tc.prompt == "item" {
|
||||
result = dbus.MakeVariant(dbus.ObjectPath("/item/9"))
|
||||
}
|
||||
// Emit before returning: subscribing after Prompt loses this signal.
|
||||
if err := conn.Emit(prompt, "org.freedesktop.Secret.Prompt.Completed", tc.dismissed, result); err != nil {
|
||||
return dbus.MakeFailedError(err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
"Dismiss": func() *dbus.Error { calls <- "dismiss"; return nil },
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if tc.timeout {
|
||||
cancel()
|
||||
ctx, cancel = context.WithTimeout(context.Background(), 250*time.Millisecond)
|
||||
defer cancel()
|
||||
}
|
||||
var err error
|
||||
if tc.timeout {
|
||||
err = saveZedSecret(ctx, url)
|
||||
} else {
|
||||
err = saveZedCredential(url)
|
||||
}
|
||||
switch {
|
||||
case tc.timeout:
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("timeout: %v", err)
|
||||
}
|
||||
case tc.dismissed:
|
||||
if err == nil || !strings.Contains(err.Error(), "dismissed") {
|
||||
t.Fatalf("dismissed: %v", err)
|
||||
}
|
||||
case tc.storeError:
|
||||
if err == nil || !strings.Contains(err.Error(), "store refused") {
|
||||
t.Fatalf("write failure: %v", err)
|
||||
}
|
||||
default:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for len(calls) > 0 {
|
||||
seen[<-calls] = true
|
||||
}
|
||||
if seen["collection"] != tc.missing || seen["prompt"] != (tc.prompt != "") || seen["dismiss"] != tc.timeout || seen["item"] != (!tc.dismissed && !tc.timeout) || (!tc.timeout && !seen["close"]) {
|
||||
t.Fatalf("unexpected operations: %v", seen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func zedTestBus(t *testing.T) *dbus.Conn {
|
||||
t.Helper()
|
||||
bin, err := exec.LookPath("dbus-daemon")
|
||||
if err != nil {
|
||||
t.Skip("dbus-daemon is required for isolated Secret Service tests")
|
||||
}
|
||||
cmd := exec.Command(bin, "--session", "--nofork", "--print-address=1")
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { cmd.Process.Kill(); cmd.Wait() })
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
if !scanner.Scan() {
|
||||
t.Fatalf("no private bus address: %v", scanner.Err())
|
||||
}
|
||||
t.Setenv("DBUS_SESSION_BUS_ADDRESS", scanner.Text())
|
||||
conn, err := dbus.ConnectSessionBus()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
if reply, err := conn.RequestName("org.freedesktop.secrets", dbus.NameFlagDoNotQueue); err != nil || reply != dbus.RequestNameReplyPrimaryOwner {
|
||||
t.Fatalf("claim Secret Service: %v %v", reply, err)
|
||||
}
|
||||
return conn
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
"github.com/yetone/magpie/internal/gateway"
|
||||
)
|
||||
|
||||
// zedWindowsCredential matches CREDENTIALW. Zed's blob is UTF-8, not UTF-16.
|
||||
type zedWindowsCredential struct {
|
||||
Flags uint32
|
||||
Type uint32
|
||||
TargetName *uint16
|
||||
Comment *uint16
|
||||
LastWritten windows.Filetime
|
||||
CredentialBlobSize uint32
|
||||
CredentialBlob *byte
|
||||
Persist uint32
|
||||
AttributeCount uint32
|
||||
Attributes uintptr
|
||||
TargetAlias *uint16
|
||||
UserName *uint16
|
||||
}
|
||||
|
||||
func saveZedCredential(url string) error {
|
||||
target, err := windows.UTF16PtrFromString("zed:url=" + url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user, _ := windows.UTF16PtrFromString("Bearer")
|
||||
key := []byte(gateway.TokenFor("zed"))
|
||||
credential := zedWindowsCredential{
|
||||
Type: 1, TargetName: target, UserName: user, // CRED_TYPE_GENERIC
|
||||
CredentialBlobSize: uint32(len(key)), CredentialBlob: &key[0],
|
||||
Persist: 2, // CRED_PERSIST_LOCAL_MACHINE
|
||||
}
|
||||
write := windows.NewLazySystemDLL("advapi32.dll").NewProc("CredWriteW")
|
||||
ok, _, callErr := write.Call(uintptr(unsafe.Pointer(&credential)), 0)
|
||||
runtime.KeepAlive(credential)
|
||||
runtime.KeepAlive(key)
|
||||
if ok == 0 {
|
||||
return fmt.Errorf("CredWriteW: %w", callErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/yetone/magpie/internal/catalog"
|
||||
"github.com/yetone/magpie/internal/edit"
|
||||
"github.com/yetone/magpie/internal/provider"
|
||||
)
|
||||
|
||||
func TestZed(t *testing.T) {
|
||||
home := syncHome(t)
|
||||
credential := zedCredential
|
||||
t.Cleanup(func() { zedCredential = credential })
|
||||
credentialURL := ""
|
||||
zedCredential = func(url string) error { credentialURL = url; return nil }
|
||||
a := zedAt(filepath.Join(home, ".config", "zed"))
|
||||
original := `{
|
||||
// My editor settings stay intact.
|
||||
"theme": "One Dark",
|
||||
"agent": {"default_model": {"provider": "zed.dev", "model": "claude-sonnet", "temperature": 0.7}, "profiles": {"ask": {"name": "Ask"}}},
|
||||
"language_models": {"openai": {"api_url": "https://my-openai/v1"}, "openai_compatible": {"other": {"api_url": "https://other/v1", "available_models": []}}}
|
||||
}`
|
||||
writeFile(t, a.Path, original)
|
||||
f := a.Field("model")
|
||||
if f.Get() != "zed.dev/claude-sonnet" || !a.Detected() {
|
||||
t.Fatalf("original: %q", f.Get())
|
||||
}
|
||||
v, err := a.Spell("model", "relay/glm-4.6")
|
||||
if err != nil || v != "magpie/relay/glm-4.6" {
|
||||
t.Fatalf("spelling: %q %v", v, err)
|
||||
}
|
||||
if err := a.Apply("model", v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.Get() != v || a.Check() != "" || credentialURL != gatewayV1() {
|
||||
t.Fatalf("applied: %q %q", f.Get(), a.Check())
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
zedProvider + ".api_url": gatewayV1(),
|
||||
zedProvider + ".available_models.0.name": "relay/glm-4.6",
|
||||
zedProvider + ".available_models.0.max_tokens": "204800",
|
||||
zedProvider + ".available_models.0.capabilities.tools": "true",
|
||||
zedProvider + ".available_models.0.capabilities.parallel_tool_calls": "false",
|
||||
zedProvider + ".available_models.0.capabilities.prompt_cache_key": "false",
|
||||
"language_models.openai.api_url": "https://my-openai/v1",
|
||||
"language_models.openai_compatible.other.api_url": "https://other/v1",
|
||||
"agent.profiles.ask.name": "Ask",
|
||||
} {
|
||||
if got, _ := edit.GetJSON(a.Path, key); got != want {
|
||||
t.Errorf("%s = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(readFile(a.Path), "// My editor settings stay intact.") {
|
||||
t.Fatal("lost comments")
|
||||
}
|
||||
before := readFile(a.Path)
|
||||
if err := f.Set("invalid"); err == nil || readFile(a.Path) != before {
|
||||
t.Fatal("invalid selection changed settings")
|
||||
}
|
||||
if err := provider.Save(provider.Provider{ID: "relay", Name: "Relay", Key: "k", Chat: "http://127.0.0.1:1/v1", Models: []string{"glm-4.6", "unknown"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.Sync(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, _ := edit.GetJSON(a.Path, zedProvider+".available_models.#"); n != "2" {
|
||||
t.Fatalf("catalog did not sync: %s", readFile(a.Path))
|
||||
}
|
||||
if err := f.Set("magpie/relay/unknown"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Set(""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := f.Get(); got != "zed.dev/claude-sonnet" {
|
||||
t.Fatalf("reset: %q", got)
|
||||
}
|
||||
if temp, _ := edit.GetJSON(a.Path, zedModel+".temperature"); temp != "0.7" {
|
||||
t.Fatal("did not restore whole default model")
|
||||
}
|
||||
if _, ok := edit.GetJSON(a.Path, zedProvider); ok {
|
||||
t.Fatal("magpie provider left after reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZedCredentialFailureLeavesSettings(t *testing.T) {
|
||||
home := syncHome(t)
|
||||
a := zedAt(filepath.Join(home, "zed"))
|
||||
writeFile(t, a.Path, `{"agent":{"default_model":{"provider":"openai","model":"own"}}}`)
|
||||
before := readFile(a.Path)
|
||||
credential := zedCredential
|
||||
t.Cleanup(func() { zedCredential = credential })
|
||||
zedCredential = func(string) error { return errors.New("keychain locked") }
|
||||
if err := a.Field("model").Set("magpie/relay/glm-4.6"); err == nil || !strings.Contains(err.Error(), "keychain locked") {
|
||||
t.Fatalf("missing credential failure: %v", err)
|
||||
}
|
||||
if readFile(a.Path) != before {
|
||||
t.Fatal("changed settings despite credential failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZedFreshAndOwn(t *testing.T) {
|
||||
home := syncHome(t)
|
||||
a := zedAt(filepath.Join(home, "zed"))
|
||||
if err := a.Sync(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(a.Path); !os.IsNotExist(err) {
|
||||
t.Fatal("sync created settings without a pick")
|
||||
}
|
||||
f := a.Field("model")
|
||||
if err := f.Set("magpie/relay/glm-4.6"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Set(""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.Get() != "" {
|
||||
t.Fatalf("reset fresh: %q", f.Get())
|
||||
}
|
||||
if err := f.Set("magpie/relay/glm-4.6"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Set("openai/gpt-custom"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.Get() != "openai/gpt-custom" {
|
||||
t.Fatalf("native model: %q", f.Get())
|
||||
}
|
||||
if _, ok := edit.GetJSON(a.Path, zedProvider); ok {
|
||||
t.Fatal("magpie provider left on native selection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestZedRestoresProviderAndModelLimits(t *testing.T) {
|
||||
home := syncHome(t)
|
||||
writeFile(t, catalog.CachePath(), `{"zai":{"models":{"glm-4.6":{"id":"glm-4.6","name":"GLM","modalities":{"input":["text","image"]},"limit":{"context":204800,"output":300000}}}}}`)
|
||||
catalog.Reset()
|
||||
a := zedAt(filepath.Join(home, "zed"))
|
||||
writeFile(t, a.Path, `{"agent":{"default_model":{"provider":"openai","model":"own"}},"language_models":{"openai_compatible":{"magpie":{"api_url":"https://my-proxy/v1","available_models":[]}}}}`)
|
||||
before := readFile(a.Path)
|
||||
if err := a.Sync(); err != nil || readFile(a.Path) != before {
|
||||
t.Fatalf("sync changed a user's provider: %v", err)
|
||||
}
|
||||
if err := a.Field("model").Set("magpie/relay/glm-4.6"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, want := range map[string]string{"max_tokens": "204800", "max_output_tokens": "204800", "capabilities.images": "true"} {
|
||||
if got, _ := edit.GetJSON(a.Path, zedProvider+".available_models.0."+key); got != want {
|
||||
t.Errorf("%s = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
if err := a.Field("model").Set(""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := edit.GetJSON(a.Path, zedProvider+".api_url"); got != "https://my-proxy/v1" || a.Field("model").Get() != "openai/own" {
|
||||
t.Fatalf("did not restore original provider and model: %s", readFile(a.Path))
|
||||
}
|
||||
}
|
||||
|
||||
func TestZedReselectAfterNativePicker(t *testing.T) {
|
||||
home := syncHome(t)
|
||||
a := zedAt(filepath.Join(home, "zed"))
|
||||
f := a.Field("model")
|
||||
if err := f.Set("magpie/relay/glm-4.6"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Zed's picker changes only the model, leaving our provider installed.
|
||||
if err := edit.SetJSON(a.Path, edit.KV{Path: zedModel, Value: map[string]string{"provider": "openai", "model": "gpt-custom"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Set("magpie/relay/glm-4.6"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Set(""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := edit.GetJSON(a.Path, zedProvider); ok {
|
||||
t.Fatal("reset restored magpie's own provider")
|
||||
}
|
||||
if got := f.Get(); got != "openai/gpt-custom" {
|
||||
t.Fatalf("reset model: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZedReselectPreservesProvider(t *testing.T) {
|
||||
home := syncHome(t)
|
||||
a := zedAt(filepath.Join(home, "zed"))
|
||||
original := `{"api_url":"https://my-proxy/v1","available_models":[{"name":"custom","max_tokens":8192}],"headers":{"X-Custom":"keep"}}`
|
||||
writeFile(t, a.Path, `{"agent":{"default_model":{"provider":"openai","model":"own"}},"language_models":{"openai_compatible":{"magpie":`+original+`}}}`)
|
||||
f := a.Field("model")
|
||||
if err := f.Set("magpie/relay/glm-4.6"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
native := `{"provider":"zed.dev","model":"claude-sonnet","temperature":0.3}`
|
||||
if err := edit.SetJSON(a.Path, edit.KV{Path: zedModel, Value: json.RawMessage(native)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Set("magpie/relay/glm-4.6"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Set(""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := edit.GetJSON(a.Path, zedProvider); got != original {
|
||||
t.Fatalf("reselection lost the user's provider: %s", got)
|
||||
}
|
||||
if got, _ := edit.GetJSON(a.Path, zedModel); got != native {
|
||||
t.Fatalf("reset did not restore the latest native model: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZedResetAfterNativePicker(t *testing.T) {
|
||||
home := syncHome(t)
|
||||
a := zedAt(filepath.Join(home, "zed"))
|
||||
writeFile(t, a.Path, `{"agent":{"default_model":{"provider":"openai","model":"own"}},"language_models":{"openai_compatible":{"magpie":{"api_url":"https://my-proxy/v1","available_models":[]}}}}`)
|
||||
f := a.Field("model")
|
||||
if err := f.Set("magpie/relay/glm-4.6"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Zed's picker changes the model without removing magpie's provider.
|
||||
native := `{"provider":"zed.dev","model":"claude-sonnet","temperature":0.3}`
|
||||
if err := edit.SetJSON(a.Path, edit.KV{Path: zedModel, Value: json.RawMessage(native)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Set(""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := edit.GetJSON(a.Path, zedModel); got != native {
|
||||
t.Fatalf("reset overwrote the native model: %s", got)
|
||||
}
|
||||
if got, _ := edit.GetJSON(a.Path, zedProvider+".api_url"); got != "https://my-proxy/v1" {
|
||||
t.Fatalf("reset did not restore the user's provider: %q", got)
|
||||
}
|
||||
if was := stashLoad()["zed:"+a.Path+":model"]; was != "" {
|
||||
t.Fatalf("reset left stale model stash: %s", was)
|
||||
}
|
||||
}
|
||||
|
||||
func TestZedXDGPath(t *testing.T) {
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("Linux config path")
|
||||
}
|
||||
home := t.TempDir()
|
||||
cfg := filepath.Join(home, "config")
|
||||
if a := zed(home, cfg); a.Path != filepath.Join(cfg, "zed", "settings.json") {
|
||||
t.Fatalf("XDG: %s", a.Path)
|
||||
}
|
||||
}
|
||||
@@ -727,7 +727,7 @@ async function reapplyAgent(a, btn) {
|
||||
renderAgents();
|
||||
document.querySelector(`.agent[data-id="${CSS.escape(a.id)}"] .field`)?.classList.add("flash");
|
||||
const msg = t("{agent} goes through magpie again", { agent: a.name });
|
||||
if (state.notice) status(`${msg}. ${state.notice}`, "warn", 9000);
|
||||
if (state.notice) status(`${msg}. ${t(state.notice)}`, "warn", 9000);
|
||||
else status(msg, "ok");
|
||||
} catch (e) {
|
||||
btn?.classList.remove("busy");
|
||||
@@ -2587,7 +2587,7 @@ async function commit(value) {
|
||||
renderAgents();
|
||||
flash();
|
||||
const shown = opt?.label || value;
|
||||
if (state.notice) status(`${agent.name} → ${shown}. ${state.notice}`, "warn", 9000);
|
||||
if (state.notice) status(`${agent.name} → ${shown}. ${t(state.notice)}`, "warn", 9000);
|
||||
else if (opt?.direct) status(`${agent.name} ${t(field.label)} → ${shown} · ${t("straight to {vendor}, not through magpie", { vendor: opt.direct })}`, "ok", 6000);
|
||||
else status(`${agent.name} ${t(field.label)} → ${shown}`, "ok");
|
||||
if (providers) loadProviders();
|
||||
|
||||
@@ -706,6 +706,7 @@ const I18N = {
|
||||
"{agent} is now signed in as {user}": "{agent} 已切换为 {user}",
|
||||
"Codex's background service is still signed in as {user}": "Codex 的后台服务仍登录着 {user}",
|
||||
"Restart it to use the new account. Running Codex sessions will be interrupted.": "重启它才会用上新账号。正在运行的 Codex 会话会被中断。",
|
||||
"Restart Zed if it still asks for an API key: magpie has configured its gateway credential in the system credential store.": "如果 Zed 仍要求输入 API 密钥,请重启 Zed:magpie 已在系统凭据存储中配置了网关凭据。",
|
||||
"Later": "稍后",
|
||||
"Restart": "重启",
|
||||
"Codex's background service restarted": "Codex 的后台服务已重启",
|
||||
|
||||
@@ -99,7 +99,7 @@ const usage = `magpie — one place to pick every agent's model
|
||||
magpie update [check] install the newest release (check: only say if there is one)
|
||||
magpie update auto [on|off] [30m|1h|6h|24h] whether the app looks for updates by itself, and how often (6h)
|
||||
|
||||
agents: claude (cc), codex, gemini, opencode (oc), mimocode, pi, goose, cursor, copilot, crush
|
||||
agents: claude (cc), codex, gemini, opencode (oc), mimocode, pi, goose, cursor, zed, copilot, crush
|
||||
`
|
||||
|
||||
var (
|
||||
|
||||
Reference in new issue
Block a user