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:
@@ -333,10 +333,19 @@ func (h *Handler) listAuthFilesFromDisk(c *gin.Context) {
|
|||||||
fileData["type"] = typeValue
|
fileData["type"] = typeValue
|
||||||
fileData["email"] = emailValue
|
fileData["email"] = emailValue
|
||||||
if pv := gjson.GetBytes(data, "priority"); pv.Exists() {
|
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()) != "" {
|
if nv := gjson.GetBytes(data, "note"); nv.Exists() {
|
||||||
fileData["note"] = strings.TrimSpace(nv.String())
|
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
|
entry["priority"] = parsed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Expose note from Metadata.
|
// Expose note from Attributes (set by synthesizer from JSON "note" field).
|
||||||
if note, ok := auth.Metadata["note"].(string); ok {
|
if note := strings.TrimSpace(authAttribute(auth, "note")); note != "" {
|
||||||
if trimmed := strings.TrimSpace(note); trimmed != "" {
|
entry["note"] = note
|
||||||
entry["note"] = trimmed
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
@@ -912,26 +919,32 @@ func (h *Handler) PatchAuthFileFields(c *gin.Context) {
|
|||||||
targetAuth.ProxyURL = *req.ProxyURL
|
targetAuth.ProxyURL = *req.ProxyURL
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
if req.Priority != nil {
|
if req.Priority != nil || req.Note != nil {
|
||||||
if targetAuth.Metadata == nil {
|
if targetAuth.Metadata == nil {
|
||||||
targetAuth.Metadata = make(map[string]any)
|
targetAuth.Metadata = make(map[string]any)
|
||||||
}
|
}
|
||||||
if *req.Priority == 0 {
|
if targetAuth.Attributes == nil {
|
||||||
delete(targetAuth.Metadata, "priority")
|
targetAuth.Attributes = make(map[string]string)
|
||||||
} else {
|
|
||||||
targetAuth.Metadata["priority"] = *req.Priority
|
|
||||||
}
|
}
|
||||||
changed = true
|
|
||||||
}
|
if req.Priority != nil {
|
||||||
if req.Note != nil {
|
if *req.Priority == 0 {
|
||||||
if targetAuth.Metadata == nil {
|
delete(targetAuth.Metadata, "priority")
|
||||||
targetAuth.Metadata = make(map[string]any)
|
delete(targetAuth.Attributes, "priority")
|
||||||
|
} else {
|
||||||
|
targetAuth.Metadata["priority"] = *req.Priority
|
||||||
|
targetAuth.Attributes["priority"] = strconv.Itoa(*req.Priority)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
trimmedNote := strings.TrimSpace(*req.Note)
|
if req.Note != nil {
|
||||||
if trimmedNote == "" {
|
trimmedNote := strings.TrimSpace(*req.Note)
|
||||||
delete(targetAuth.Metadata, "note")
|
if trimmedNote == "" {
|
||||||
} else {
|
delete(targetAuth.Metadata, "note")
|
||||||
targetAuth.Metadata["note"] = trimmedNote
|
delete(targetAuth.Attributes, "note")
|
||||||
|
} else {
|
||||||
|
targetAuth.Metadata["note"] = trimmedNote
|
||||||
|
targetAuth.Attributes["note"] = trimmedNote
|
||||||
|
}
|
||||||
}
|
}
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,6 +229,10 @@ func SynthesizeGeminiVirtualAuths(primary *coreauth.Auth, metadata map[string]an
|
|||||||
if priorityVal, hasPriority := primary.Attributes["priority"]; hasPriority && priorityVal != "" {
|
if priorityVal, hasPriority := primary.Attributes["priority"]; hasPriority && priorityVal != "" {
|
||||||
attrs["priority"] = 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{
|
metadataCopy := map[string]any{
|
||||||
"email": email,
|
"email": email,
|
||||||
"project_id": projectID,
|
"project_id": projectID,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user