agent: OpenHanako 1.0's encrypted provider-catalog.json is never written over; magpie's address and models are kept in its plugin file (owner: "~/.hanako/provider-catalog.json: top level is not a JSON object")

OpenHanako 1.0.0-beta seals provider-catalog.json under a key its desktop app keeps in the keychain ("HANA-SECRET-1.AES-256-GCM.<nonce>.<body>", shared/credential-envelope.ts); its own compatibility note says a reader without the key must refuse a sealed file. With OpenHanako closed, magpie's Sync couldn't read the key out of it, took magpie's entry for changed, and edit.SetJSON failed on the file. Now, when the catalog is sealed:
- Sync and a model pick keep magpie's definition (address, api, models) current in provider-plugins/magpie/providers/magpie.json, where OpenHanako has moved it (everything but the key and headers, which stay in the catalog: wun's overlay), and the key, unreadable, counts as unknown, not changed, in Sync and Check; Check names magpie.json for the address.
- Adding magpie with no plugin file, or taking it out, needs the key in the catalog: with OpenHanako closed it says "open OpenHanako and try again" and changes nothing (taking out refuses before the agent's model is put back). While it runs, its API does it all, as before.

TestHanakoSealed (a catalog sealed the way OpenHanako seals it, a plugin on an old port with an old model) fails on the old code with the reported "provider-catalog.json: top level is not a JSON object" and passes; a copy of the owner's ~/.hanako (sealed by 1.0.0-beta) syncs with no error, its catalog's bytes unchanged and the plugin's address and models current, Check then empty. go test -race ./internal/agent, vet (darwin, linux, windows) and gofmt pass.
This commit is contained in:
yetone committed 2026-10-06 23:06:47 +08:00
1 parent 7bdb0b34e8
commit e2f1c6bacf
2 files changed
+177 -5

No files matched your search

+63 -5
View File
@@ -10,6 +10,13 @@ package agent
// agents/<id>/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.
+114
View File
@@ -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")
}