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 <noreply@anthropic.com>
This commit is contained in:
RGBadmin
2026-03-15 16:47:01 +08:00
parent 5b6342e6ac
commit f90120f846
3 changed files with 237 additions and 23 deletions

View File

@@ -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
}

View File

@@ -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,

View File

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