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) + } + } +}