fix(agent): address critical issues from PR #1485 review

1. Prevent double-close panic on Stop() by using sync.Once in Scheduler,
   Brain, and Sentinel; remove duplicate Stop() call in main.go
2. Add trade quantity (100k) and leverage (125x) sanity caps to prevent
   LLM hallucinations or input errors from reaching the exchange
3. Mask secrets in onboarding setup state — only store "****" markers in
   SystemConfig instead of plaintext API keys/secrets

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shinchan-zhai
2026-04-22 00:01:46 +08:00
parent 3ca95b294d
commit 5d6ec35bb4
6 changed files with 40 additions and 12 deletions
+2 -1
View File
@@ -17,6 +17,7 @@ type Brain struct {
logger *slog.Logger
http *http.Client
stopCh chan struct{}
stopOnce sync.Once
recentSignals sync.Map // debounce
}
@@ -29,7 +30,7 @@ func NewBrain(agent *Agent, logger *slog.Logger) *Brain {
}
}
func (b *Brain) Stop() { close(b.stopCh) }
func (b *Brain) Stop() { b.stopOnce.Do(func() { close(b.stopCh) }) }
// cleanStaleSignals removes debounce entries older than 30 minutes.
func (b *Brain) cleanStaleSignals() {
+15 -4
View File
@@ -64,13 +64,24 @@ func (a *Agent) saveSetupState(userID int64, s *SetupState) {
a.store.SetSystemConfig(fmt.Sprintf("setup_step_%d", userID), s.Step)
setConfig(a.store, userID, "exchange", s.Exchange)
setConfig(a.store, userID, "exchange_id", s.ExchangeID)
setConfig(a.store, userID, "api_key", s.APIKey)
setConfig(a.store, userID, "api_secret", s.APISecret)
setConfig(a.store, userID, "passphrase", s.Passphrase)
// Store only a masked marker for secrets — full values stay in memory only.
// This prevents plaintext credentials from lingering in the config store
// if the setup flow is interrupted before clearSetupState runs.
if s.APIKey != "" {
setConfig(a.store, userID, "api_key", "****")
}
if s.APISecret != "" {
setConfig(a.store, userID, "api_secret", "****")
}
if s.Passphrase != "" {
setConfig(a.store, userID, "passphrase", "****")
}
setConfig(a.store, userID, "ai_provider", s.AIProvider)
setConfig(a.store, userID, "ai_model", s.AIModel)
setConfig(a.store, userID, "ai_model_id", s.AIModelID)
setConfig(a.store, userID, "ai_key", s.AIKey)
if s.AIKey != "" {
setConfig(a.store, userID, "ai_key", "****")
}
setConfig(a.store, userID, "ai_base_url", s.AIBaseURL)
}
+3 -1
View File
@@ -6,6 +6,7 @@ import (
"log/slog"
"nofx/safe"
"strings"
"sync"
"time"
)
@@ -13,6 +14,7 @@ type Scheduler struct {
agent *Agent
logger *slog.Logger
stopCh chan struct{}
stopOnce sync.Once
}
func NewScheduler(a *Agent, l *slog.Logger) *Scheduler {
@@ -51,7 +53,7 @@ func (s *Scheduler) Start(ctx context.Context) {
})
}
func (s *Scheduler) Stop() { close(s.stopCh) }
func (s *Scheduler) Stop() { s.stopOnce.Do(func() { close(s.stopCh) }) }
func (s *Scheduler) dailyReport() {
if s.agent.traderManager == nil { return }
+2 -1
View File
@@ -41,6 +41,7 @@ type Sentinel struct {
http *http.Client
logger *slog.Logger
stopCh chan struct{}
stopOnce sync.Once
}
type pricePt struct {
@@ -76,7 +77,7 @@ func (s *Sentinel) Start() {
})
}
func (s *Sentinel) Stop() { close(s.stopCh) }
func (s *Sentinel) Stop() { s.stopOnce.Do(func() { close(s.stopCh) }) }
func (s *Sentinel) SymbolCount() int { s.mu.RLock(); defer s.mu.RUnlock(); return len(s.symbols) }
func (s *Sentinel) AddSymbol(sym string) { s.mu.Lock(); defer s.mu.Unlock(); for _, x := range s.symbols { if x == sym { return } }; s.symbols = append(s.symbols, sym) }
func (s *Sentinel) RemoveSymbol(sym string) { s.mu.Lock(); defer s.mu.Unlock(); for i, x := range s.symbols { if x == sym { s.symbols = append(s.symbols[:i], s.symbols[i+1:]...); return } } }
+14
View File
@@ -194,17 +194,31 @@ func (a *Agent) executeTrade(ctx context.Context, trade *TradeAction) error {
return fmt.Errorf("no running trader supports trade execution")
}
// Sanity caps to prevent LLM hallucinations or input errors from causing damage.
const maxQuantity = 100000.0
const maxLeverage = 125
if trade.Leverage > maxLeverage {
return fmt.Errorf("leverage %dx exceeds maximum allowed (%dx)", trade.Leverage, maxLeverage)
}
switch trade.Action {
case "open_long":
if trade.Quantity <= 0 {
return fmt.Errorf("quantity must be > 0")
}
if trade.Quantity > maxQuantity {
return fmt.Errorf("quantity %.4f exceeds maximum allowed (%.0f)", trade.Quantity, maxQuantity)
}
_, err := underlyingTrader.OpenLong(trade.Symbol, trade.Quantity, trade.Leverage)
return err
case "open_short":
if trade.Quantity <= 0 {
return fmt.Errorf("quantity must be > 0")
}
if trade.Quantity > maxQuantity {
return fmt.Errorf("quantity %.4f exceeds maximum allowed (%.0f)", trade.Quantity, maxQuantity)
}
_, err := underlyingTrader.OpenShort(trade.Symbol, trade.Quantity, trade.Leverage)
return err
case "close_long":
+1 -2
View File
@@ -169,8 +169,7 @@ func main() {
}
logger.Info("✅ HTTP server stopped")
nofxiAgent.Stop()
logger.Info("✅ NOFXi agent stopped")
// nofxiAgent.Stop() is handled by defer above
// Stop all traders
traderManager.StopAll()