fix: toFloat handles json.Number, CORS credentials+wildcard bug, strategy config error handling

- agent/agent.go: toFloat() now handles json.Number and int32 types to prevent silent data loss
- api/server.go: CORS fix — echo specific origin instead of '*' when credentials are enabled (browsers reject Allow-Credentials with wildcard)
- api/strategy.go: all 4 json.Unmarshal calls now check errors and return 'config_error' field instead of silently serving zero-value configs
- store/decision.go: explicitly mark best-effort unmarshal with _ = assignment for clarity
This commit is contained in:
shinchan-zhai
2026-03-23 12:10:55 +08:00
parent 2dbbc82506
commit 708ffdb7bd
4 changed files with 47 additions and 18 deletions

View File

@@ -7,6 +7,7 @@ package agent
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"net/http" "net/http"
@@ -548,7 +549,9 @@ func toFloat(v interface{}) float64 {
case float32: return float64(x) case float32: return float64(x)
case int: return float64(x) case int: return float64(x)
case int64: return float64(x) case int64: return float64(x)
case int32: return float64(x)
case string: f, _ := strconv.ParseFloat(x, 64); return f case string: f, _ := strconv.ParseFloat(x, 64); return f
case json.Number: f, _ := x.Float64(); return f
} }
return 0 return 0
} }

View File

@@ -98,7 +98,14 @@ func corsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
origin := c.Request.Header.Get("Origin") origin := c.Request.Header.Get("Origin")
if allowAll { if allowAll {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*") if origin != "" {
// When credentials are needed, we must echo the specific origin
// instead of "*" (browsers reject Allow-Credentials with wildcard).
c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
c.Writer.Header().Set("Vary", "Origin")
} else {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
}
} else if origin != "" && allowed[origin] { } else if origin != "" && allowed[origin] {
c.Writer.Header().Set("Access-Control-Allow-Origin", origin) c.Writer.Header().Set("Access-Control-Allow-Origin", origin)
c.Writer.Header().Set("Vary", "Origin") c.Writer.Header().Set("Vary", "Origin")

View File

@@ -56,8 +56,11 @@ func (s *Server) handlePublicStrategies(c *gin.Context) {
// Only include config if config_visible is true // Only include config if config_visible is true
if st.ConfigVisible { if st.ConfigVisible {
var config store.StrategyConfig var config store.StrategyConfig
json.Unmarshal([]byte(st.Config), &config) if err := json.Unmarshal([]byte(st.Config), &config); err != nil {
item["config"] = config item["config_error"] = "invalid config format"
} else {
item["config"] = config
}
} }
result = append(result, item) result = append(result, item)
@@ -86,9 +89,9 @@ func (s *Server) handleGetStrategies(c *gin.Context) {
result := make([]gin.H, 0, len(strategies)) result := make([]gin.H, 0, len(strategies))
for _, st := range strategies { for _, st := range strategies {
var config store.StrategyConfig var config store.StrategyConfig
json.Unmarshal([]byte(st.Config), &config) configErr := json.Unmarshal([]byte(st.Config), &config)
result = append(result, gin.H{ item := gin.H{
"id": st.ID, "id": st.ID,
"name": st.Name, "name": st.Name,
"description": st.Description, "description": st.Description,
@@ -96,10 +99,15 @@ func (s *Server) handleGetStrategies(c *gin.Context) {
"is_default": st.IsDefault, "is_default": st.IsDefault,
"is_public": st.IsPublic, "is_public": st.IsPublic,
"config_visible": st.ConfigVisible, "config_visible": st.ConfigVisible,
"config": config,
"created_at": st.CreatedAt, "created_at": st.CreatedAt,
"updated_at": st.UpdatedAt, "updated_at": st.UpdatedAt,
}) }
if configErr != nil {
item["config_error"] = "invalid config format"
} else {
item["config"] = config
}
result = append(result, item)
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -124,18 +132,23 @@ func (s *Server) handleGetStrategy(c *gin.Context) {
} }
var config store.StrategyConfig var config store.StrategyConfig
json.Unmarshal([]byte(strategy.Config), &config) configErr := json.Unmarshal([]byte(strategy.Config), &config)
c.JSON(http.StatusOK, gin.H{ result := gin.H{
"id": strategy.ID, "id": strategy.ID,
"name": strategy.Name, "name": strategy.Name,
"description": strategy.Description, "description": strategy.Description,
"is_active": strategy.IsActive, "is_active": strategy.IsActive,
"is_default": strategy.IsDefault, "is_default": strategy.IsDefault,
"config": config,
"created_at": strategy.CreatedAt, "created_at": strategy.CreatedAt,
"updated_at": strategy.UpdatedAt, "updated_at": strategy.UpdatedAt,
}) }
if configErr != nil {
result["config_error"] = "invalid config format"
} else {
result["config"] = config
}
c.JSON(http.StatusOK, result)
} }
// handleCreateStrategy Create strategy. // handleCreateStrategy Create strategy.
@@ -383,18 +396,23 @@ func (s *Server) handleGetActiveStrategy(c *gin.Context) {
} }
var config store.StrategyConfig var config store.StrategyConfig
json.Unmarshal([]byte(strategy.Config), &config) configErr := json.Unmarshal([]byte(strategy.Config), &config)
c.JSON(http.StatusOK, gin.H{ result := gin.H{
"id": strategy.ID, "id": strategy.ID,
"name": strategy.Name, "name": strategy.Name,
"description": strategy.Description, "description": strategy.Description,
"is_active": strategy.IsActive, "is_active": strategy.IsActive,
"is_default": strategy.IsDefault, "is_default": strategy.IsDefault,
"config": config,
"created_at": strategy.CreatedAt, "created_at": strategy.CreatedAt,
"updated_at": strategy.UpdatedAt, "updated_at": strategy.UpdatedAt,
}) }
if configErr != nil {
result["config_error"] = "invalid config format"
} else {
result["config"] = config
}
c.JSON(http.StatusOK, result)
} }
// handleGetDefaultStrategyConfig Get default strategy configuration template // handleGetDefaultStrategyConfig Get default strategy configuration template

View File

@@ -138,9 +138,10 @@ func (db *DecisionRecordDB) toRecord() *DecisionRecord {
ErrorMessage: db.ErrorMessage, ErrorMessage: db.ErrorMessage,
AIRequestDurationMs: db.AIRequestDurationMs, AIRequestDurationMs: db.AIRequestDurationMs,
} }
json.Unmarshal([]byte(db.CandidateCoins), &record.CandidateCoins) // Best-effort unmarshal — empty/malformed JSON fields are left as zero values.
json.Unmarshal([]byte(db.ExecutionLog), &record.ExecutionLog) _ = json.Unmarshal([]byte(db.CandidateCoins), &record.CandidateCoins)
json.Unmarshal([]byte(db.Decisions), &record.Decisions) _ = json.Unmarshal([]byte(db.ExecutionLog), &record.ExecutionLog)
_ = json.Unmarshal([]byte(db.Decisions), &record.Decisions)
return record return record
} }