diff --git a/README.md b/README.md index 3e881509..3063121d 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/internal/agent/agents.go b/internal/agent/agents.go index 1c473122..75e06139 100644 --- a/internal/agent/agents.go +++ b/internal/agent/agents.go @@ -70,6 +70,7 @@ func All() []*Agent { omo(home), goose(home, cfg), cursor(home), + zed(home, cfg), copilot(home), crush(home, cfg), dsh(home), diff --git a/internal/agent/main_test.go b/internal/agent/main_test.go index bc855b8f..e03ef178 100644 --- a/internal/agent/main_test.go +++ b/internal/agent/main_test.go @@ -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) diff --git a/internal/agent/zed.go b/internal/agent/zed.go new file mode 100644 index 00000000..6ec725d1 --- /dev/null +++ b/internal/agent/zed.go @@ -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} +} diff --git a/internal/agent/zed_credential_other.go b/internal/agent/zed_credential_other.go new file mode 100644 index 00000000..52d50128 --- /dev/null +++ b/internal/agent/zed_credential_other.go @@ -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() + } + } +} diff --git a/internal/agent/zed_credential_other_test.go b/internal/agent/zed_credential_other_test.go new file mode 100644 index 00000000..0e9c7d52 --- /dev/null +++ b/internal/agent/zed_credential_other_test.go @@ -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) + } +} diff --git a/internal/agent/zed_credential_secret_test.go b/internal/agent/zed_credential_secret_test.go new file mode 100644 index 00000000..8d232dcd --- /dev/null +++ b/internal/agent/zed_credential_secret_test.go @@ -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 +} diff --git a/internal/agent/zed_credential_windows.go b/internal/agent/zed_credential_windows.go new file mode 100644 index 00000000..eca37836 --- /dev/null +++ b/internal/agent/zed_credential_windows.go @@ -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 +} diff --git a/internal/agent/zed_test.go b/internal/agent/zed_test.go new file mode 100644 index 00000000..ac102509 --- /dev/null +++ b/internal/agent/zed_test.go @@ -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) + } +} diff --git a/internal/gui/assets/app.js b/internal/gui/assets/app.js index 70ffabeb..204fc060 100644 --- a/internal/gui/assets/app.js +++ b/internal/gui/assets/app.js @@ -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(); diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index e61c723c..5fd8c994 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -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 的后台服务已重启", diff --git a/main.go b/main.go index 7cba772e..d13ea8bf 100644 --- a/main.go +++ b/main.go @@ -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 (