Files
gastown/internal/daemon/notification.go
sigfawn 3cf77b2e8b fix(daemon): improve error handling and security (#445)
* fix(beads): cache version check and add timeout to prevent cli lag

* fix(mail_queue): add nil check for queue config

Prevents potential nil pointer panic when queue config exists
in map but has nil value. Added || queueCfg == nil check to
the queue lookup condition in runMailClaim function.

Fixes potential panic that could occur if a queue entry exists
in config but with a nil value.

* fix(migrate_agents_test): fix icon expectations to match actual output

The printMigrationResult function uses icons with two leading spaces
("  ✓", "  ⊘", "  ✗") but the test expected icons without spaces.
This fixes the test expectations to match the actual output format.

* fix(hook): handle error from events.LogFeed

Previously the error from LogFeed was silently ignored with _.
Now we log the error to stderr at warning level but don't fail
the operation since the primary hook action succeeded.

* fix(tmux): security and error handling improvements

- Fix unchecked regexp error in IsClaudeRunning (CVE-like)
- Add input sanitization to SetPaneDiedHook to prevent shell injection
- Add session name validation to SetDynamicStatus
- Sanitize mail from/subject in SendNotificationBanner
- Return error on parse failure in GetEnvironment
- Track skipped lines in ListSessionIDs for debuggability

See: tmux.fix for full analysis

* fix(daemon): improve error handling and security

- Capture stderr in syncWorkspace for better debuggability
- Fail fast on git fetch failures to prevent stale code
- Add logging to previously silent bd list errors
- Change notification state file permissions to 0600
- Improve error messages with actual stderr content

This prevents agents from starting with stale code and provides
better visibility into daemon operations.
2026-01-13 22:13:54 -08:00

213 lines
5.2 KiB
Go

package daemon
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
// NotificationSlot tracks a pending notification for deduplication.
// Only the latest notification per slot matters - earlier ones are replaced.
type NotificationSlot struct {
Slot string `json:"slot"`
Session string `json:"session"`
Message string `json:"message"`
SentAt time.Time `json:"sent_at"`
Consumed bool `json:"consumed"`
ConsumedAt time.Time `json:"consumed_at,omitempty"`
}
// NotificationManager handles slot-based notification deduplication.
// It ensures that for a given (session, slot) pair, only one notification
// is pending at a time. Sending a new notification to the same slot
// replaces the previous one.
type NotificationManager struct {
stateDir string // Directory for slot state files
maxAge time.Duration // Max age before considering a slot stale
}
// NewNotificationManager creates a new notification manager.
// stateDir is where slot state files are stored (e.g., ~/gt/daemon/notifications/)
func NewNotificationManager(stateDir string, maxAge time.Duration) *NotificationManager {
return &NotificationManager{
stateDir: stateDir,
maxAge: maxAge,
}
}
// slotPath returns the path to the slot state file.
func (m *NotificationManager) slotPath(session, slot string) string {
// Sanitize session name (replace / with -)
safeSession := session
for i := range safeSession {
if safeSession[i] == '/' {
safeSession = safeSession[:i] + "-" + safeSession[i+1:]
}
}
return filepath.Join(m.stateDir, fmt.Sprintf("slot-%s-%s.json", safeSession, slot))
}
// GetSlot reads the current state of a notification slot.
func (m *NotificationManager) GetSlot(session, slot string) (*NotificationSlot, error) {
path := m.slotPath(session, slot)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil // No slot state
}
return nil, err
}
var ns NotificationSlot
if err := json.Unmarshal(data, &ns); err != nil {
return nil, err
}
return &ns, nil
}
// ShouldSend checks if a notification should be sent for this slot.
// Returns true if:
// - No pending notification exists for this slot
// - The pending notification is stale (older than maxAge)
// - The pending notification was consumed
func (m *NotificationManager) ShouldSend(session, slot string) (bool, error) {
ns, err := m.GetSlot(session, slot)
if err != nil {
return true, err // On error, allow sending
}
if ns == nil {
return true, nil // No pending notification
}
if ns.Consumed {
return true, nil // Previous was consumed
}
// Check if stale
if time.Since(ns.SentAt) > m.maxAge {
return true, nil // Stale, allow new send
}
return false, nil // Recent pending notification exists
}
// RecordSend records that a notification was sent for a slot.
func (m *NotificationManager) RecordSend(session, slot, message string) error {
// Ensure directory exists
if err := os.MkdirAll(m.stateDir, 0755); err != nil {
return err
}
ns := &NotificationSlot{
Slot: slot,
Session: session,
Message: message,
SentAt: time.Now(),
Consumed: false,
}
data, err := json.Marshal(ns)
if err != nil {
return err
}
return os.WriteFile(m.slotPath(session, slot), data, 0600)
}
// MarkConsumed marks a slot's notification as consumed (agent responded).
func (m *NotificationManager) MarkConsumed(session, slot string) error {
ns, err := m.GetSlot(session, slot)
if err != nil {
return err
}
if ns == nil {
return nil // Nothing to mark
}
ns.Consumed = true
ns.ConsumedAt = time.Now()
data, err := json.Marshal(ns)
if err != nil {
return err
}
return os.WriteFile(m.slotPath(session, slot), data, 0600)
}
// MarkSessionActive marks all slots for a session as consumed.
// Call this when the session shows activity (keepalive update).
func (m *NotificationManager) MarkSessionActive(session string) error {
// List all slot files for this session
pattern := filepath.Join(m.stateDir, fmt.Sprintf("slot-%s-*.json", session))
matches, err := filepath.Glob(pattern)
if err != nil {
return err
}
for _, path := range matches {
data, err := os.ReadFile(path)
if err != nil {
continue
}
var ns NotificationSlot
if err := json.Unmarshal(data, &ns); err != nil {
continue
}
if !ns.Consumed {
ns.Consumed = true
ns.ConsumedAt = time.Now()
if data, err := json.Marshal(&ns); err == nil {
_ = os.WriteFile(path, data, 0644) // non-fatal: state file update
}
}
}
return nil
}
// ClearSlot removes the state file for a slot.
func (m *NotificationManager) ClearSlot(session, slot string) error {
path := m.slotPath(session, slot)
err := os.Remove(path)
if os.IsNotExist(err) {
return nil
}
return err
}
// ClearStaleSlots removes slot files older than maxAge.
func (m *NotificationManager) ClearStaleSlots() error {
pattern := filepath.Join(m.stateDir, "slot-*.json")
matches, err := filepath.Glob(pattern)
if err != nil {
return err
}
for _, path := range matches {
info, err := os.Stat(path)
if err != nil {
continue
}
if time.Since(info.ModTime()) > m.maxAge {
_ = os.Remove(path) // best-effort cleanup
}
}
return nil
}
// Common notification slots
const (
SlotHeartbeat = "heartbeat"
SlotStatus = "status"
)