Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 50 additions & 6 deletions internal/api/handlers/management/auth_files.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
Comment on lines +335 to +344

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore invalid disk priority values instead of coercing to 0

When the auth manager is unavailable, the fallback path now emits priority for any file that merely has a priority key, but gjson.Int() coerces non-numeric values (for example "priority":"high" or "priority":null) to 0. This makes the API report a concrete priority that was never configured and can change sort/order behavior in degraded mode, while the normal in-memory path only includes priority when integer parsing succeeds.

Useful? React with 👍 / 👎.

if nv := gjson.GetBytes(data, "note"); nv.Exists() {
if trimmed := strings.TrimSpace(nv.String()); trimmed != "" {
fileData["note"] = trimmed

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Emit note only for string-valued JSON fields

In the auth-manager fallback path, note is read with nv.String() for any existing JSON value and then trimmed, so malformed auth files like {"note":123} or {"note":true} get exposed as concrete note text instead of being ignored. This diverges from the normal manager path (synthesizeFileAuths/buildAuthFileEntry), which only accepts string notes, so degraded mode can return inconsistent and misleading note values. Restrict this branch to gjson.String before setting fileData["note"].

Useful? React with 👍 / 👎.

}
}
}

files = append(files, fileData)
Expand Down Expand Up @@ -415,6 +430,16 @@ 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 != "" {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Read list fields from metadata when attributes are missing

buildAuthFileEntry now exposes priority/note only from auth.Attributes, but registerAuthFromFile still initializes attributes with just path/source and leaves these values in Metadata; as a result, files added via UploadAuthFile can contain valid priority/note in JSON yet GET /auth-files omits them until some later re-synthesis repopulates attributes. This makes the new response fields unreliable in normal auth-manager mode for uploaded or freshly registered files.

Useful? React with 👍 / 👎.

if parsed, err := strconv.Atoi(p); err == nil {
entry["priority"] = parsed
}
}
// Expose note from Attributes (set by synthesizer from JSON "note" field).
if note := strings.TrimSpace(authAttribute(auth, "note")); note != "" {
entry["note"] = note
}
return entry
}

Expand Down Expand Up @@ -839,7 +864,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"})
Expand All @@ -851,6 +876,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"})
Expand Down Expand Up @@ -893,14 +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)
}

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
}
Expand Down
12 changes: 12 additions & 0 deletions internal/watcher/synthesizer/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down Expand Up @@ -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,
Expand Down
197 changes: 197 additions & 0 deletions internal/watcher/synthesizer/file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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: "[email protected]",
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": "[email protected]",
"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: "[email protected]",
Attributes: map[string]string{
"source": "test-source",
"path": "/path/to/auth",
},
}
metadata := map[string]any{
"project_id": "proj-a, proj-b",
"email": "[email protected]",
"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": "[email protected]",
"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)
}
}
}
Loading