diff --git a/internal/agent/hanako.go b/internal/agent/hanako.go index a9479551..9644422d 100644 --- a/internal/agent/hanako.go +++ b/internal/agent/hanako.go @@ -10,6 +10,13 @@ package agent // agents//config.yaml that agent's settings; models.chat is {id, provider} // server-info.json {port, token, …}, while it runs // +// Since 1.0 OpenHanako seals the catalog under a key the desktop app keeps +// in the system keychain: the file is then "HANA-SECRET-1.AES-256-GCM.…", +// not JSON. magpie can't read it nor write it, and never writes over it. A +// provider's definition is in its plugin file, out of the catalog, so that +// is still magpie's to keep up to date; its key stays sealed, and adding or +// removing magpie needs OpenHanako running, to do it through its API. +// // magpie is one provider there, magpie, spoken to as chat completions at the // gateway's /v1 with the catalog as its models; choosing one of them makes it // the primary agent's chat model. While OpenHanako runs, both go through its @@ -96,7 +103,7 @@ func hanako(home string) *Agent { Spelled: prefixed, Sync: func() error { cur, ok := hanakoCurrent(dir) - if !ok || hanakoSame(cur, hanakoProvider()) { + if !ok || hanakoSame(cur, hanakoProvider(), hanakoSealed(path)) { return nil } return hanakoSave(dir, hanakoProvider()) @@ -106,10 +113,14 @@ func hanako(home string) *Agent { return "" } cur, _ := hanakoCurrent(dir) - return wiringOff("OpenHanako", path, func(k string) (string, bool) { + file, kvs := path, []string{"base_url", gatewayV1(), "api_key", gateway.TokenFor("hanako")} + if hanakoSealed(path) { // its key is sealed: unknown, not gone; the address is in the plugin + file, kvs = filepath.Join(hanakoPlugin(dir), "providers", magpieID+".json"), kvs[:2] + } + return wiringOff("OpenHanako", file, func(k string) (string, bool) { v, ok := cur[k].(string) return v, ok - }, "base_url", gatewayV1(), "api_key", gateway.TokenFor("hanako")) + }, kvs...) }, Fields: []Field{{ Key: "model", Label: "model", @@ -123,6 +134,10 @@ func hanako(home string) *Agent { Set: func(v string) error { agent := hanakoAgent(dir) if v == "" { + // nothing changes rather than half of it + if _, there := hanakoCurrent(dir); there && hanakoSealed(path) && hanakoLive(dir) == nil { + return errSealed("take magpie out of") + } if onMagpie() { if err := hanakoChat(dir, agent, unstash(key())); err != nil { return err @@ -245,12 +260,27 @@ func hanakoCurrent(dir string) (map[string]any, bool) { return cur, found } +// hanakoSealed reports whether OpenHanako has sealed its catalog under its +// keychain's key, so that only it can read or write it. +func hanakoSealed(path string) bool { + b, err := os.ReadFile(path) + if err != nil { + return false + } + b = bytes.TrimPrefix(bytes.TrimSpace(b), []byte("\ufeff")) + return bytes.HasPrefix(bytes.TrimSpace(b), []byte("HANA-SECRET-")) +} + // hanakoSame reports whether the provider says all that want does; keys -// the user added (headers, say) don't count. -func hanakoSame(cur map[string]any, want hanakoProviderEntry) bool { +// the user added (headers, say) don't count, nor, in a sealed catalog, its +// key, which magpie can't read. +func hanakoSame(cur map[string]any, want hanakoProviderEntry, sealed bool) bool { b, _ := json.Marshal(want) var w map[string]any json.Unmarshal(b, &w) + if sealed { + delete(w, "api_key") + } for k, v := range w { c := cur[k] if l, _ := v.([]any); k == "models" && len(l) == 0 { @@ -274,6 +304,9 @@ func hanakoSave(dir string, p hanakoProviderEntry) error { return s.put("/api/config", map[string]any{"providers": map[string]any{magpieID: p}}) } path := filepath.Join(dir, "provider-catalog.json") + if hanakoSealed(path) { + return hanakoSavePlugin(dir, p) + } raw, err := edit.Read(path) if err != nil { return err @@ -309,6 +342,9 @@ func hanakoRemove(dir string) error { return s.put("/api/config", map[string]any{"providers": map[string]any{magpieID: nil}}) } path := filepath.Join(dir, "provider-catalog.json") + if hanakoSealed(path) { + return errSealed("take magpie out of") + } if err := edit.DelJSON(path, "providers."+magpieID); err != nil { return err } @@ -321,6 +357,28 @@ func hanakoRemove(dir string) error { return os.RemoveAll(hanakoPlugin(dir)) } +// errSealed says why magpie can't do what it was asked to with OpenHanako +// closed, and what will. +func errSealed(what string) error { + return fmt.Errorf("OpenHanako keeps its providers encrypted, so magpie can only %s it while it runs: open OpenHanako and try again", what) +} + +// hanakoSavePlugin keeps magpie's definition up to date in its plugin file, +// beside a sealed catalog: all but the key, which is the catalog's. With no +// plugin file magpie isn't there to keep up to date, and only OpenHanako +// can add it, its key going into the sealed catalog. +func hanakoSavePlugin(dir string, p hanakoProviderEntry) error { + file := filepath.Join(hanakoPlugin(dir), "providers", magpieID+".json") + if _, err := os.Stat(file); err != nil { + return errSealed("add magpie to") + } + return edit.SetJSON(file, + edit.KV{Path: "displayName", Value: p.DisplayName}, + edit.KV{Path: "defaultBaseUrl", Value: p.BaseURL}, + edit.KV{Path: "defaultApi", Value: p.API}, + edit.KV{Path: "models", Value: p.Models}) +} + func hanakoExists(path string) bool { _, err := os.Stat(path); return err == nil } // hanakoDeleted is the catalog's meta.deletedProviders. diff --git a/internal/agent/hanako_test.go b/internal/agent/hanako_test.go index 7a723a13..74da3375 100644 --- a/internal/agent/hanako_test.go +++ b/internal/agent/hanako_test.go @@ -1,6 +1,10 @@ package agent import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "encoding/base64" "encoding/json" "io" "net/http" @@ -499,3 +503,113 @@ func TestHanakoOwnProviderNamedAsMagpies(t *testing.T) { t.Fatalf("still connected after Disconnect: %v", a.Values()) } } + +// hanakoSeal seals s as OpenHanako 1.0 seals its catalog +// (shared/credential-envelope.ts): AES-256-GCM under its keychain's key, +// the header as additional data, nonce and body+tag in base64. +func hanakoSeal(t *testing.T, s string) string { + t.Helper() + const header = "HANA-SECRET-1.AES-256-GCM" + block, _ := aes.NewCipher(bytes.Repeat([]byte{7}, 32)) + gcm, _ := cipher.NewGCM(block) + nonce := bytes.Repeat([]byte{1}, 12) + body := gcm.Seal(nil, nonce, []byte(s), []byte(header)) + return header + "." + base64.StdEncoding.EncodeToString(nonce) + "." + base64.StdEncoding.EncodeToString(body) + "\n" +} + +// Since 1.0 OpenHanako seals its catalog: magpie never writes over it, keeps +// its own definition current in its plugin file, and says that adding or +// taking magpie out needs OpenHanako open. +func TestHanakoSealed(t *testing.T) { + home, dir := hanakoTestHome(t) + catalogPath := filepath.Join(dir, "provider-catalog.json") + sealed := hanakoSeal(t, `{"catalogVersion":2,"providers":{"openai":{"api_key":"sk-x"},"magpie":{"api_key":"magpie-hanako"}}}`) + hanakoWrite(t, catalogPath, sealed, 0o600) + plugin := filepath.Join(dir, "provider-plugins", "magpie", "providers", "magpie.json") + // as OpenHanako 1.0 moved it there, from an older magpie on another port + hanakoWrite(t, plugin, `{"id":"magpie","displayName":"magpie","authType":"api-key","defaultBaseUrl":"http://127.0.0.1:1/v1",`+ + `"defaultApi":"openai-completions","models":[{"id":"old"}]}`, 0o600) + hanakoWrite(t, filepath.Join(dir, "provider-plugins", "magpie", "manifest.json"), `{"id":"magpie","type":"provider-plugin","schemaVersion":1,"provider":"magpie"}`, 0o600) + cfg := filepath.Join(dir, "agents", "hana", "config.yaml") + cfgYAML := "models:\n chat:\n id: relay/glm-4.6\n provider: magpie\n" + hanakoWrite(t, cfg, cfgYAML, 0o644) + unchanged := func(what string) { + t.Helper() + if b, _ := os.ReadFile(catalogPath); string(b) != sealed { + t.Fatalf("%s: the sealed catalog was written:\n%s", what, b) + } + } + + a := hanako(home) + if c := a.Check(); !strings.Contains(c, "base_url (magpie.json)") || strings.Contains(c, "api_key") { + t.Fatalf("check: %q", c) + } + if err := a.Sync(); err != nil { + t.Fatal(err) + } + unchanged("sync") + p := hanakoJSON(t, plugin) + ms, _ := json.Marshal(p["models"]) + want := map[string]any{} + json.Unmarshal([]byte(hanakoWant(t)), &want) + wantMs, _ := json.Marshal(want["models"]) + if p["defaultBaseUrl"] != gatewayV1() || p["defaultApi"] != "openai-completions" || p["id"] != "magpie" || p["authType"] != "api-key" || !sameJSON(string(ms), json.RawMessage(wantMs)) { + t.Fatalf("plugin: %v", p) + } + if st, _ := os.Stat(plugin); runtime.GOOS != "windows" && st.Mode().Perm() != 0o600 { + t.Fatalf("mode %v", st.Mode()) + } + // the key it can't read is unknown, not gone: nothing to say, nothing to write + if c := a.Check(); c != "" { + t.Fatalf("check after sync: %q", c) + } + before, _ := os.ReadFile(plugin) + if err := a.Sync(); err != nil { + t.Fatal(err) + } + if after, _ := os.ReadFile(plugin); string(after) != string(before) { + t.Fatalf("sync rewrote a current plugin:\n%s", after) + } + + // taking magpie out needs OpenHanako: nothing changes meanwhile + f := a.Field("model") + if err := f.Set(""); err == nil || !strings.Contains(err.Error(), "open OpenHanako") { + t.Fatalf("reset: %v", err) + } + unchanged("reset") + if b, _ := os.ReadFile(cfg); string(b) != cfgYAML { + t.Fatalf("reset changed the config:\n%s", b) + } + if _, err := os.Stat(plugin); err != nil { + t.Fatal("reset took the plugin") + } + + // nor can magpie be added with no plugin to keep it in + os.RemoveAll(filepath.Join(dir, "provider-plugins")) + hanakoWrite(t, cfg, "models:\n chat:\n id: gpt-5\n provider: openai\n", 0o644) + if err := f.Set("magpie/relay/glm-4.6"); err == nil || !strings.Contains(err.Error(), "open OpenHanako") { + t.Fatalf("add: %v", err) + } + unchanged("add") + if chat, _ := hanakoChatOf(t, cfg); chat["provider"] != "openai" { + t.Fatalf("add changed the model: %v", chat) + } + + // while it runs, its API does it all + calls, mu := fakeHanako(t, dir, "tok", func(hanakoCall) (int, string) { return 200, `{"ok":true}` }) + if err := f.Set("magpie/relay/glm-4.6"); err != nil { + t.Fatal(err) + } + mu.Lock() + var paths []string + for _, c := range *calls { + if c.Method == "PUT" { + paths = append(paths, c.Path) + } + } + mu.Unlock() + if strings.Join(paths, " ") != "/api/config /api/agents/hana/config" { + t.Fatalf("live puts: %v", paths) + } + unchanged("live") +}