diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index 2e471ae8ca..4d1ec44cf2 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -332,6 +332,21 @@ 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() { + 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() && nv.Type == gjson.String { + if trimmed := strings.TrimSpace(nv.String()); trimmed != "" { + fileData["note"] = trimmed + } + } } files = append(files, fileData) @@ -415,6 +430,37 @@ 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). + // 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 } @@ -839,7 +885,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 +897,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"}) @@ -893,14 +940,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) + } + + 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) + } + } + 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 ab54aeaaa3..b76594c164 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" { @@ -221,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 105d920747..ec707436ad 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) + } + } +}