From 5b6342e6acd7399001e403b4dd88b9647094d035 Mon Sep 17 00:00:00 2001 From: RGBadmin Date: Sat, 14 Mar 2026 14:47:31 +0800 Subject: [PATCH 1/4] feat(api): expose priority and note fields in GET /auth-files list response The list endpoint previously omitted priority and note, which are stored inside each auth file's JSON content. This adds them to both the normal (auth-manager) and fallback (disk-read) code paths, and extends PATCH /auth-files/fields to support writing the note field. Co-Authored-By: Claude Opus 4.6 --- .../api/handlers/management/auth_files.go | 33 ++++++++++++++++++- internal/watcher/synthesizer/file.go | 8 +++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 2e471ae8..7b695f2c 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -332,6 +332,12 @@ func (h *Handler) listAuthFilesFromDisk(c *gin.Context) { emailValue := gjson.GetBytes(data, "email").String() fileData["type"] = typeValue fileData["email"] = emailValue + if pv := gjson.GetBytes(data, "priority"); pv.Exists() { + fileData["priority"] = int(pv.Int()) + } + if nv := gjson.GetBytes(data, "note"); nv.Exists() && strings.TrimSpace(nv.String()) != "" { + fileData["note"] = strings.TrimSpace(nv.String()) + } } files = append(files, fileData) @@ -415,6 +421,18 @@ func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth) gin.H { if claims := extractCodexIDTokenClaims(auth); claims != nil { entry["id_token"] = claims } + // Expose priority from Attributes (set by synthesizer from JSON "priority" field). + if p := strings.TrimSpace(authAttribute(auth, "priority")); p != "" { + if parsed, err := strconv.Atoi(p); err == nil { + entry["priority"] = parsed + } + } + // Expose note from Metadata. + if note, ok := auth.Metadata["note"].(string); ok { + if trimmed := strings.TrimSpace(note); trimmed != "" { + entry["note"] = trimmed + } + } return entry } @@ -839,7 +857,7 @@ func (h *Handler) PatchAuthFileStatus(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok", "disabled": *req.Disabled}) } -// PatchAuthFileFields updates editable fields (prefix, proxy_url, priority) of an auth file. +// PatchAuthFileFields updates editable fields (prefix, proxy_url, priority, note) of an auth file. func (h *Handler) PatchAuthFileFields(c *gin.Context) { if h.authManager == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"}) @@ -851,6 +869,7 @@ func (h *Handler) PatchAuthFileFields(c *gin.Context) { Prefix *string `json:"prefix"` ProxyURL *string `json:"proxy_url"` Priority *int `json:"priority"` + Note *string `json:"note"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) @@ -904,6 +923,18 @@ func (h *Handler) PatchAuthFileFields(c *gin.Context) { } changed = true } + if req.Note != nil { + if targetAuth.Metadata == nil { + targetAuth.Metadata = make(map[string]any) + } + trimmedNote := strings.TrimSpace(*req.Note) + if trimmedNote == "" { + delete(targetAuth.Metadata, "note") + } else { + targetAuth.Metadata["note"] = trimmedNote + } + changed = true + } if !changed { c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"}) diff --git a/internal/watcher/synthesizer/file.go b/internal/watcher/synthesizer/file.go index ab54aeaa..b063b45f 100644 --- a/internal/watcher/synthesizer/file.go +++ b/internal/watcher/synthesizer/file.go @@ -149,6 +149,14 @@ func synthesizeFileAuths(ctx *SynthesisContext, fullPath string, data []byte) [] } } } + // Read note from auth file. + if rawNote, ok := metadata["note"]; ok { + if note, isStr := rawNote.(string); isStr { + if trimmed := strings.TrimSpace(note); trimmed != "" { + a.Attributes["note"] = trimmed + } + } + } ApplyAuthExcludedModelsMeta(a, cfg, perAccountExcluded, "oauth") // For codex auth files, extract plan_type from the JWT id_token. if provider == "codex" { From f90120f846961253c7c19a61ab985a49a54cc6e9 Mon Sep 17 00:00:00 2001 From: RGBadmin Date: Sun, 15 Mar 2026 16:47:01 +0800 Subject: [PATCH 2/4] fix(api): propagate note to Gemini virtual auths and align priority parsing - Read note from Attributes (consistent with priority) in buildAuthFileEntry, fixing missing note on Gemini multi-project virtual auth cards. - Propagate note from primary to virtual auths in SynthesizeGeminiVirtualAuths, mirroring existing priority propagation. - Sync note/priority writes to both Metadata and Attributes in PatchAuthFileFields, with refactored nil-check to reduce duplication (review feedback). - Validate priority type in fallback disk-read path instead of coercing all values to 0 via gjson.Int(), aligning with the auth-manager code path. - Add regression tests for note synthesis, virtual-auth note propagation, and end-to-end multi-project Gemini note inheritance. Co-Authored-By: Claude Opus 4.6 --- .../api/handlers/management/auth_files.go | 59 ++++-- internal/watcher/synthesizer/file.go | 4 + internal/watcher/synthesizer/file_test.go | 197 ++++++++++++++++++ 3 files changed, 237 insertions(+), 23 deletions(-) diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 7b695f2c..d6b0e8af 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -333,10 +333,19 @@ func (h *Handler) listAuthFilesFromDisk(c *gin.Context) { fileData["type"] = typeValue fileData["email"] = emailValue if pv := gjson.GetBytes(data, "priority"); pv.Exists() { - fileData["priority"] = int(pv.Int()) + switch pv.Type { + case gjson.Number: + fileData["priority"] = int(pv.Int()) + case gjson.String: + if parsed, errAtoi := strconv.Atoi(strings.TrimSpace(pv.String())); errAtoi == nil { + fileData["priority"] = parsed + } + } } - if nv := gjson.GetBytes(data, "note"); nv.Exists() && strings.TrimSpace(nv.String()) != "" { - fileData["note"] = strings.TrimSpace(nv.String()) + if nv := gjson.GetBytes(data, "note"); nv.Exists() { + if trimmed := strings.TrimSpace(nv.String()); trimmed != "" { + fileData["note"] = trimmed + } } } @@ -427,11 +436,9 @@ func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth) gin.H { entry["priority"] = parsed } } - // Expose note from Metadata. - if note, ok := auth.Metadata["note"].(string); ok { - if trimmed := strings.TrimSpace(note); trimmed != "" { - entry["note"] = trimmed - } + // Expose note from Attributes (set by synthesizer from JSON "note" field). + if note := strings.TrimSpace(authAttribute(auth, "note")); note != "" { + entry["note"] = note } return entry } @@ -912,26 +919,32 @@ func (h *Handler) PatchAuthFileFields(c *gin.Context) { targetAuth.ProxyURL = *req.ProxyURL changed = true } - if req.Priority != nil { + if req.Priority != nil || req.Note != nil { if targetAuth.Metadata == nil { targetAuth.Metadata = make(map[string]any) } - if *req.Priority == 0 { - delete(targetAuth.Metadata, "priority") - } else { - targetAuth.Metadata["priority"] = *req.Priority + if targetAuth.Attributes == nil { + targetAuth.Attributes = make(map[string]string) } - changed = true - } - if req.Note != nil { - if targetAuth.Metadata == nil { - targetAuth.Metadata = make(map[string]any) + + if req.Priority != nil { + if *req.Priority == 0 { + delete(targetAuth.Metadata, "priority") + delete(targetAuth.Attributes, "priority") + } else { + targetAuth.Metadata["priority"] = *req.Priority + targetAuth.Attributes["priority"] = strconv.Itoa(*req.Priority) + } } - trimmedNote := strings.TrimSpace(*req.Note) - if trimmedNote == "" { - delete(targetAuth.Metadata, "note") - } else { - targetAuth.Metadata["note"] = trimmedNote + if req.Note != nil { + trimmedNote := strings.TrimSpace(*req.Note) + if trimmedNote == "" { + delete(targetAuth.Metadata, "note") + delete(targetAuth.Attributes, "note") + } else { + targetAuth.Metadata["note"] = trimmedNote + targetAuth.Attributes["note"] = trimmedNote + } } changed = true } diff --git a/internal/watcher/synthesizer/file.go b/internal/watcher/synthesizer/file.go index b063b45f..b76594c1 100644 --- a/internal/watcher/synthesizer/file.go +++ b/internal/watcher/synthesizer/file.go @@ -229,6 +229,10 @@ func SynthesizeGeminiVirtualAuths(primary *coreauth.Auth, metadata map[string]an if priorityVal, hasPriority := primary.Attributes["priority"]; hasPriority && priorityVal != "" { attrs["priority"] = priorityVal } + // Propagate note from primary auth to virtual auths + if noteVal, hasNote := primary.Attributes["note"]; hasNote && noteVal != "" { + attrs["note"] = noteVal + } metadataCopy := map[string]any{ "email": email, "project_id": projectID, diff --git a/internal/watcher/synthesizer/file_test.go b/internal/watcher/synthesizer/file_test.go index 105d9207..ec707436 100644 --- a/internal/watcher/synthesizer/file_test.go +++ b/internal/watcher/synthesizer/file_test.go @@ -744,3 +744,200 @@ func TestBuildGeminiVirtualID(t *testing.T) { }) } } + +func TestSynthesizeGeminiVirtualAuths_NotePropagated(t *testing.T) { + now := time.Now() + primary := &coreauth.Auth{ + ID: "primary-id", + Provider: "gemini-cli", + Label: "test@example.com", + Attributes: map[string]string{ + "source": "test-source", + "path": "/path/to/auth", + "priority": "5", + "note": "my test note", + }, + } + metadata := map[string]any{ + "project_id": "proj-a, proj-b", + "email": "test@example.com", + "type": "gemini", + } + + virtuals := SynthesizeGeminiVirtualAuths(primary, metadata, now) + + if len(virtuals) != 2 { + t.Fatalf("expected 2 virtuals, got %d", len(virtuals)) + } + + for i, v := range virtuals { + if got := v.Attributes["note"]; got != "my test note" { + t.Errorf("virtual %d: expected note %q, got %q", i, "my test note", got) + } + if got := v.Attributes["priority"]; got != "5" { + t.Errorf("virtual %d: expected priority %q, got %q", i, "5", got) + } + } +} + +func TestSynthesizeGeminiVirtualAuths_NoteAbsentWhenEmpty(t *testing.T) { + now := time.Now() + primary := &coreauth.Auth{ + ID: "primary-id", + Provider: "gemini-cli", + Label: "test@example.com", + Attributes: map[string]string{ + "source": "test-source", + "path": "/path/to/auth", + }, + } + metadata := map[string]any{ + "project_id": "proj-a, proj-b", + "email": "test@example.com", + "type": "gemini", + } + + virtuals := SynthesizeGeminiVirtualAuths(primary, metadata, now) + + if len(virtuals) != 2 { + t.Fatalf("expected 2 virtuals, got %d", len(virtuals)) + } + + for i, v := range virtuals { + if _, hasNote := v.Attributes["note"]; hasNote { + t.Errorf("virtual %d: expected no note attribute when primary has no note", i) + } + } +} + +func TestFileSynthesizer_Synthesize_NoteParsing(t *testing.T) { + tests := []struct { + name string + note any + want string + hasValue bool + }{ + { + name: "valid string note", + note: "hello world", + want: "hello world", + hasValue: true, + }, + { + name: "string note with whitespace", + note: " trimmed note ", + want: "trimmed note", + hasValue: true, + }, + { + name: "empty string note", + note: "", + hasValue: false, + }, + { + name: "whitespace only note", + note: " ", + hasValue: false, + }, + { + name: "non-string note ignored", + note: 12345, + hasValue: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir := t.TempDir() + authData := map[string]any{ + "type": "claude", + "note": tt.note, + } + data, _ := json.Marshal(authData) + errWriteFile := os.WriteFile(filepath.Join(tempDir, "auth.json"), data, 0644) + if errWriteFile != nil { + t.Fatalf("failed to write auth file: %v", errWriteFile) + } + + synth := NewFileSynthesizer() + ctx := &SynthesisContext{ + Config: &config.Config{}, + AuthDir: tempDir, + Now: time.Now(), + IDGenerator: NewStableIDGenerator(), + } + + auths, errSynthesize := synth.Synthesize(ctx) + if errSynthesize != nil { + t.Fatalf("unexpected error: %v", errSynthesize) + } + if len(auths) != 1 { + t.Fatalf("expected 1 auth, got %d", len(auths)) + } + + value, ok := auths[0].Attributes["note"] + if tt.hasValue { + if !ok { + t.Fatal("expected note attribute to be set") + } + if value != tt.want { + t.Fatalf("expected note %q, got %q", tt.want, value) + } + return + } + if ok { + t.Fatalf("expected note attribute to be absent, got %q", value) + } + }) + } +} + +func TestFileSynthesizer_Synthesize_MultiProjectGeminiWithNote(t *testing.T) { + tempDir := t.TempDir() + + authData := map[string]any{ + "type": "gemini", + "email": "multi@example.com", + "project_id": "project-a, project-b", + "priority": 5, + "note": "production keys", + } + data, _ := json.Marshal(authData) + err := os.WriteFile(filepath.Join(tempDir, "gemini-multi.json"), data, 0644) + if err != nil { + t.Fatalf("failed to write auth file: %v", err) + } + + synth := NewFileSynthesizer() + ctx := &SynthesisContext{ + Config: &config.Config{}, + AuthDir: tempDir, + Now: time.Now(), + IDGenerator: NewStableIDGenerator(), + } + + auths, err := synth.Synthesize(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should have 3 auths: 1 primary (disabled) + 2 virtuals + if len(auths) != 3 { + t.Fatalf("expected 3 auths (1 primary + 2 virtuals), got %d", len(auths)) + } + + primary := auths[0] + if gotNote := primary.Attributes["note"]; gotNote != "production keys" { + t.Errorf("expected primary note %q, got %q", "production keys", gotNote) + } + + // Verify virtuals inherit note + for i := 1; i < len(auths); i++ { + v := auths[i] + if gotNote := v.Attributes["note"]; gotNote != "production keys" { + t.Errorf("expected virtual %d note %q, got %q", i, "production keys", gotNote) + } + if gotPriority := v.Attributes["priority"]; gotPriority != "5" { + t.Errorf("expected virtual %d priority %q, got %q", i, "5", gotPriority) + } + } +} From 8d8f5970eea4de209a819706eb3bd445db88a1e8 Mon Sep 17 00:00:00 2001 From: RGBadmin Date: Sun, 15 Mar 2026 17:36:11 +0800 Subject: [PATCH 3/4] fix(api): fallback to Metadata for priority/note on uploaded auths buildAuthFileEntry now falls back to reading priority/note from auth.Metadata when Attributes lacks them. This covers auths registered via UploadAuthFile which bypass the synthesizer and only populate Metadata from the raw JSON. Co-Authored-By: Claude Opus 4.6 --- .../api/handlers/management/auth_files.go | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index d6b0e8af..176f8297 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -431,14 +431,35 @@ func (h *Handler) buildAuthFileEntry(auth *coreauth.Auth) gin.H { entry["id_token"] = claims } // Expose priority from Attributes (set by synthesizer from JSON "priority" field). + // Fall back to Metadata for auths registered via UploadAuthFile (no synthesizer). if p := strings.TrimSpace(authAttribute(auth, "priority")); p != "" { if parsed, err := strconv.Atoi(p); err == nil { entry["priority"] = parsed } + } else if auth.Metadata != nil { + if rawPriority, ok := auth.Metadata["priority"]; ok { + switch v := rawPriority.(type) { + case float64: + entry["priority"] = int(v) + case int: + entry["priority"] = v + case string: + if parsed, err := strconv.Atoi(strings.TrimSpace(v)); err == nil { + entry["priority"] = parsed + } + } + } } // Expose note from Attributes (set by synthesizer from JSON "note" field). + // Fall back to Metadata for auths registered via UploadAuthFile (no synthesizer). if note := strings.TrimSpace(authAttribute(auth, "note")); note != "" { entry["note"] = note + } else if auth.Metadata != nil { + if rawNote, ok := auth.Metadata["note"].(string); ok { + if trimmed := strings.TrimSpace(rawNote); trimmed != "" { + entry["note"] = trimmed + } + } } return entry } From c1241a98e2799f1cc722d0a78b592b599e86cab2 Mon Sep 17 00:00:00 2001 From: RGBadmin Date: Sun, 15 Mar 2026 23:00:17 +0800 Subject: [PATCH 4/4] fix(api): restrict fallback note to string-typed JSON values Only emit note in listAuthFilesFromDisk when the JSON value is actually a string (gjson.String), matching the synthesizer/buildAuthFileEntry behavior. Non-string values like numbers or booleans are now ignored instead of being coerced via gjson.String(). Co-Authored-By: Claude Opus 4.6 --- internal/api/handlers/management/auth_files.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 176f8297..4d1ec44c 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -342,7 +342,7 @@ func (h *Handler) listAuthFilesFromDisk(c *gin.Context) { } } } - if nv := gjson.GetBytes(data, "note"); nv.Exists() { + if nv := gjson.GetBytes(data, "note"); nv.Exists() && nv.Type == gjson.String { if trimmed := strings.TrimSpace(nv.String()); trimmed != "" { fileData["note"] = trimmed }