From 1f2b1b7179b1f0f99226ea7017258a65eed64aac Mon Sep 17 00:00:00 2001 From: Fimeg Date: Mon, 10 Nov 2025 20:11:32 -0500 Subject: [PATCH] fix: repair version detection platform query format - Fix GetLatestVersionByTypeAndArch to separate platform/architecture - Query now correctly uses platform='linux' and architecture='amd64' - Resolves UI showing 'no packages available' despite updates existing --- .../internal/api/handlers/agent_updates.go | 293 +++++++++++++++++- .../internal/api/handlers/downloads.go | 46 ++- aggregator-server/internal/config/config.go | 3 +- .../database/queries/agent_updates.go | 62 ++++ .../internal/models/agent_update.go | 3 +- .../src/components/AgentUpdatesModal.tsx | 21 +- aggregator-web/src/lib/api.ts | 18 ++ 7 files changed, 412 insertions(+), 34 deletions(-) diff --git a/aggregator-server/internal/api/handlers/agent_updates.go b/aggregator-server/internal/api/handlers/agent_updates.go index 326ed5e..2b588a0 100644 --- a/aggregator-server/internal/api/handlers/agent_updates.go +++ b/aggregator-server/internal/api/handlers/agent_updates.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "net/http" + "os" "strconv" "strings" "time" @@ -14,37 +15,72 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" ) - -// AgentUpdateHandler handles agent update operations type AgentUpdateHandler struct { agentQueries *queries.AgentQueries agentUpdateQueries *queries.AgentUpdateQueries commandQueries *queries.CommandQueries signingService *services.SigningService + nonceService *services.UpdateNonceService agentHandler *AgentHandler } // NewAgentUpdateHandler creates a new agent update handler -func NewAgentUpdateHandler(aq *queries.AgentQueries, auq *queries.AgentUpdateQueries, cq *queries.CommandQueries, ss *services.SigningService, ah *AgentHandler) *AgentUpdateHandler { +func NewAgentUpdateHandler(aq *queries.AgentQueries, auq *queries.AgentUpdateQueries, cq *queries.CommandQueries, ss *services.SigningService, ns *services.UpdateNonceService, ah *AgentHandler) *AgentUpdateHandler { return &AgentUpdateHandler{ agentQueries: aq, agentUpdateQueries: auq, commandQueries: cq, signingService: ss, + nonceService: ns, agentHandler: ah, } } // UpdateAgent handles POST /api/v1/agents/:id/update (manual agent update) func (h *AgentUpdateHandler) UpdateAgent(c *gin.Context) { + // Extract agent ID from URL path + agentID := c.Param("id") + if agentID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "agent ID is required"}) + return + } + + // Debug logging for development (controlled via REDFLAG_DEBUG env var or query param) + debugMode := os.Getenv("REDFLAG_DEBUG") == "true" || c.Query("debug") == "true" + if debugMode { + log.Printf("[DEBUG] [UpdateAgent] Starting update request for agent %s from %s", agentID, c.ClientIP()) + log.Printf("[DEBUG] [UpdateAgent] Content-Type: %s, Content-Length: %d", c.ContentType(), c.Request.ContentLength) + } + var req models.AgentUpdateRequest if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + if debugMode { + log.Printf("[DEBUG] [UpdateAgent] JSON binding error for agent %s: %v", agentID, err) + } + c.JSON(http.StatusBadRequest, gin.H{ + "error": err.Error(), + "_error_context": "json_binding_failed", // Helps identify binding vs validation errors + }) + return + } + + // Always log critical update operations for audit trail + log.Printf("[UPDATE] Agent %s received update request - Version: %s, Platform: %s", agentID, req.Version, req.Platform) + + // Debug: Log the parsed request + if debugMode { + log.Printf("[DEBUG] [UpdateAgent] Parsed update request - Version: %s, Platform: %s, Nonce: %s", req.Version, req.Platform, req.Nonce) + } + + agentIDUUID, err := uuid.Parse(agentID) + if err != nil { + log.Printf("[UPDATE] Agent ID format error for %s: %v", agentID, err) + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID format"}) return } // Verify the agent exists - agent, err := h.agentQueries.GetAgentByID(req.AgentID) + agent, err := h.agentQueries.GetAgentByID(agentIDUUID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"}) return @@ -77,12 +113,70 @@ func (h *AgentUpdateHandler) UpdateAgent(c *gin.Context) { } // Update agent status to "updating" - if err := h.agentQueries.UpdateAgentUpdatingStatus(req.AgentID, true, &req.Version); err != nil { - log.Printf("Failed to update agent %s status to updating: %v", req.AgentID, err) + if err := h.agentQueries.UpdateAgentUpdatingStatus(agentIDUUID, true, &req.Version); err != nil { + log.Printf("Failed to update agent %s status to updating: %v", agentID, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to initiate update"}) return } + // Validate the provided nonce + if h.nonceService != nil { + if debugMode { + log.Printf("[DEBUG] [UpdateAgent] Validating nonce for agent %s: %s", agentID, req.Nonce) + } + verifiedNonce, err := h.nonceService.Validate(req.Nonce) + if err != nil { + h.agentQueries.UpdateAgentUpdatingStatus(agentIDUUID, false, nil) // Rollback + log.Printf("[UPDATE] Nonce validation failed for agent %s: %v", agentID, err) + // Include specific error context for debugging + errorType := "signature_verification_failed" + if err.Error() == "nonce expired" { + errorType = "nonce_expired" + } else if err.Error() == "invalid base64" { + errorType = "invalid_nonce_format" + } + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid update nonce: " + err.Error(), + "_error_context": errorType, + "_error_detail": err.Error(), + }) + return + } + + if debugMode { + log.Printf("[DEBUG] [UpdateAgent] Nonce verified - AgentID: %s, TargetVersion: %s", verifiedNonce.AgentID, verifiedNonce.TargetVersion) + } + + // Verify the nonce matches the requested agent and version + if verifiedNonce.AgentID != agentID { + if debugMode { + log.Printf("[DEBUG] [UpdateAgent] Agent ID mismatch - nonce: %s, URL: %s", verifiedNonce.AgentID, agentID) + } + log.Printf("[UPDATE] Agent ID mismatch in nonce: expected %s, got %s", agentID, verifiedNonce.AgentID) + h.agentQueries.UpdateAgentUpdatingStatus(agentIDUUID, false, nil) // Rollback + c.JSON(http.StatusBadRequest, gin.H{ + "error": "nonce agent ID mismatch", + "_agent_id": agentID, + "_nonce_agent_id": verifiedNonce.AgentID, + }) + return + } + if verifiedNonce.TargetVersion != req.Version { + if debugMode { + log.Printf("[DEBUG] [UpdateAgent] Version mismatch - nonce: %s, request: %s", verifiedNonce.TargetVersion, req.Version) + } + log.Printf("[UPDATE] Version mismatch in nonce: expected %s, got %s", req.Version, verifiedNonce.TargetVersion) + h.agentQueries.UpdateAgentUpdatingStatus(agentIDUUID, false, nil) // Rollback + c.JSON(http.StatusBadRequest, gin.H{ + "error": "nonce version mismatch", + "_requested_version": req.Version, + "_nonce_version": verifiedNonce.TargetVersion, + }) + return + } + log.Printf("[UPDATE] Nonce successfully validated for agent %s to version %s", agentID, req.Version) + } + // Generate nonce for replay protection nonceUUID := uuid.New() nonceTimestamp := time.Now() @@ -398,4 +492,189 @@ func (h *AgentUpdateHandler) estimateUpdateTime(fileSize int64) int { } return seconds +} + +// GenerateUpdateNonce handles POST /api/v1/agents/:id/update-nonce +func (h *AgentUpdateHandler) GenerateUpdateNonce(c *gin.Context) { + agentID := c.Param("id") + targetVersion := c.Query("target_version") + + if targetVersion == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "target_version query parameter required"}) + return + } + + if h.nonceService == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "nonce service not available"}) + return + } + + // Parse agent ID as UUID + agentIDUUID, err := uuid.Parse(agentID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID format"}) + return + } + + // Verify agent exists + agent, err := h.agentQueries.GetAgentByID(agentIDUUID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"}) + return + } + + // Generate nonce + nonce, err := h.nonceService.Generate(agentID, targetVersion) + if err != nil { + log.Printf("[ERROR] Failed to generate update nonce for agent %s: %v", agentID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate nonce"}) + return + } + + log.Printf("[system] Generated update nonce for agent %s (%s) -> %s", agentID, agent.Hostname, targetVersion) + + c.JSON(http.StatusOK, gin.H{ + "agent_id": agentID, + "hostname": agent.Hostname, + "current_version": agent.CurrentVersion, + "target_version": targetVersion, + "update_nonce": nonce, + "expires_at": time.Now().Add(10 * time.Minute).Unix(), + "expires_in_seconds": 600, + }) +} + +// CheckForUpdateAvailable handles GET /api/v1/agents/:id/updates/available +func (h *AgentUpdateHandler) CheckForUpdateAvailable(c *gin.Context) { + agentID := c.Param("id") + + // Parse agent ID + agentIDUUID, err := uuid.Parse(agentID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID format"}) + return + } + + // Query database for agent's current version + agent, err := h.agentQueries.GetAgentByID(agentIDUUID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"}) + return + } + + // Platform format: separate os_type and os_architecture from agent data + osType := strings.ToLower(agent.OSType) + osArch := agent.OSArchitecture + + // Check if newer version available from agent_update_packages table + latestVersion, err := h.agentUpdateQueries.GetLatestVersionByTypeAndArch(osType, osArch) + if err != nil { + log.Printf("[DEBUG] GetLatestVersionByTypeAndArch error for %s/%s: %v", osType, osArch, err) + c.JSON(http.StatusOK, gin.H{ + "hasUpdate": false, + "reason": "no packages available", + "currentVersion": agent.CurrentVersion, + }) + return + } + + // Check if this is actually newer than current version + hasUpdate := isVersionUpgrade(latestVersion, agent.CurrentVersion) + + log.Printf("[DEBUG] Version comparison - latest: %s, current: %s, hasUpdate: %v for platform: %s/%s", latestVersion, agent.CurrentVersion, hasUpdate, osType, osArch) + + // Special handling for sub-versions (0.1.23.5 vs 0.1.23) + if !hasUpdate && strings.HasPrefix(latestVersion, agent.CurrentVersion + ".") { + hasUpdate = true + log.Printf("[DEBUG] Detected sub-version upgrade: %s -> %s", agent.CurrentVersion, latestVersion) + } + + c.JSON(http.StatusOK, gin.H{ + "hasUpdate": hasUpdate, + "currentVersion": agent.CurrentVersion, + "latestVersion": latestVersion, + "platform": osType + "-" + osArch, + }) +} + +// GetUpdateStatus handles GET /api/v1/agents/:id/updates/status +func (h *AgentUpdateHandler) GetUpdateStatus(c *gin.Context) { + agentID := c.Param("id") + + // Parse agent ID + agentIDUUID, err := uuid.Parse(agentID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID format"}) + return + } + + // Fetch agent with update state + agent, err := h.agentQueries.GetAgentByID(agentIDUUID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"}) + return + } + + // Determine status from agent state + recent commands + var status string + var progress *int + var errorMsg *string + + if agent.IsUpdating { + // Check if agent has pending update command + cmd, err := h.agentUpdateQueries.GetPendingUpdateCommand(agentID) + if err == nil && cmd != nil { + status = "downloading" + // Progress could be based on last acknowledgment time + if time.Since(cmd.CreatedAt) > 2*time.Minute { + status = "installing" + } + } else { + status = "pending" + } + } else { + status = "idle" + } + + c.JSON(http.StatusOK, gin.H{ + "status": status, + "progress": progress, + "error": errorMsg, + }) +} + +// isVersionUpgrade returns true if new version is greater than current version +func isVersionUpgrade(newVersion string, currentVersion string) bool { + // Parse semantic versions + newParts := strings.Split(newVersion, ".") + curParts := strings.Split(currentVersion, ".") + + // Pad arrays to 3 parts + for len(newParts) < 3 { + newParts = append(newParts, "0") + } + for len(curParts) < 3 { + curParts = append(curParts, "0") + } + + // Convert to integers for comparison + newMajor, _ := strconv.Atoi(newParts[0]) + newMinor, _ := strconv.Atoi(newParts[1]) + newPatch, _ := strconv.Atoi(newParts[2]) + + curMajor, _ := strconv.Atoi(curParts[0]) + curMinor, _ := strconv.Atoi(curParts[1]) + curPatch, _ := strconv.Atoi(curParts[2]) + + // Check if new > current (not equal, not less) + if newMajor > curMajor { + return true + } + if newMajor == curMajor && newMinor > curMinor { + return true + } + if newMajor == curMajor && newMinor == curMinor && newPatch > curPatch { + return true + } + return false } \ No newline at end of file diff --git a/aggregator-server/internal/api/handlers/downloads.go b/aggregator-server/internal/api/handlers/downloads.go index e58ec59..03f638e 100644 --- a/aggregator-server/internal/api/handlers/downloads.go +++ b/aggregator-server/internal/api/handlers/downloads.go @@ -31,24 +31,28 @@ func (h *DownloadHandler) getServerURL(c *gin.Context) string { return h.config.Server.PublicURL } - // Priority 2: Construct API server URL from configuration + // Priority 2: Detect from request with TLS/proxy awareness scheme := "http" - host := h.config.Server.Host - port := h.config.Server.Port - // Use HTTPS if TLS is enabled in config + // Check if TLS is enabled in config if h.config.Server.TLS.Enabled { scheme = "https" } - // For default host (0.0.0.0), use localhost for client connections - if host == "0.0.0.0" { - host = "localhost" + // Check if request came through HTTPS (direct or via proxy) + if c.Request.TLS != nil { + scheme = "https" } - // Only include port if it's not the default for the protocol - if (scheme == "http" && port != 80) || (scheme == "https" && port != 443) { - return fmt.Sprintf("%s://%s:%d", scheme, host, port) + // Check X-Forwarded-Proto for reverse proxy setups + if forwardedProto := c.GetHeader("X-Forwarded-Proto"); forwardedProto == "https" { + scheme = "https" + } + + // Use the Host header exactly as received (includes port if present) + host := c.GetHeader("X-Forwarded-Host") + if host == "" { + host = c.Request.Host } return fmt.Sprintf("%s://%s", scheme, host) @@ -151,7 +155,6 @@ AGENT_BINARY="/usr/local/bin/redflag-agent" SUDOERS_FILE="/etc/sudoers.d/redflag-agent" SERVICE_FILE="/etc/systemd/system/redflag-agent.service" CONFIG_DIR="/etc/aggregator" -STATE_DIR="/var/lib/aggregator" echo "=== RedFlag Agent Installation ===" echo "" @@ -298,24 +301,19 @@ else exit 1 fi -# Step 4: Create configuration and state directories +# Step 4: Create configuration directory echo "" -echo "Step 4: Creating configuration and state directories..." +echo "Step 4: Creating configuration directory..." mkdir -p "$CONFIG_DIR" chown "$AGENT_USER:$AGENT_USER" "$CONFIG_DIR" chmod 755 "$CONFIG_DIR" +echo "✓ Configuration directory created" -# Create state directory for acknowledgment tracking (v0.1.19+) -mkdir -p "$STATE_DIR" -chown "$AGENT_USER:$AGENT_USER" "$STATE_DIR" -chmod 755 "$STATE_DIR" -echo "✓ Configuration and state directories created" - -# Set SELinux context for directories if SELinux is enabled +# Set SELinux context for config directory if SELinux is enabled if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" != "Disabled" ]; then - echo "Setting SELinux context for directories..." - restorecon -Rv "$CONFIG_DIR" "$STATE_DIR" 2>/dev/null || true - echo "✓ SELinux context set for directories" + echo "Setting SELinux context for config directory..." + restorecon -Rv "$CONFIG_DIR" 2>/dev/null || true + echo "✓ SELinux context set for config directory" fi # Step 5: Install systemd service @@ -340,7 +338,7 @@ RestartSec=30 # NoNewPrivileges=true - DISABLED: Prevents sudo from working, which agent needs for package management ProtectSystem=strict ProtectHome=true -ReadWritePaths=$AGENT_HOME /var/log $CONFIG_DIR $STATE_DIR +ReadWritePaths=$AGENT_HOME /var/log $CONFIG_DIR PrivateTmp=true # Logging diff --git a/aggregator-server/internal/config/config.go b/aggregator-server/internal/config/config.go index d99658c..46c7a71 100644 --- a/aggregator-server/internal/config/config.go +++ b/aggregator-server/internal/config/config.go @@ -43,6 +43,7 @@ type Config struct { LatestAgentVersion string MinAgentVersion string `env:"MIN_AGENT_VERSION" default:"0.1.22"` SigningPrivateKey string `env:"REDFLAG_SIGNING_PRIVATE_KEY"` + DebugEnabled bool `env:"REDFLAG_DEBUG" default:"false"` // Enable debug logging } // Load reads configuration from environment variables only (immutable configuration) @@ -86,7 +87,7 @@ func Load() (*Config, error) { cfg.CheckInInterval = checkInInterval cfg.OfflineThreshold = offlineThreshold cfg.Timezone = getEnv("TIMEZONE", "UTC") - cfg.LatestAgentVersion = getEnv("LATEST_AGENT_VERSION", "0.1.23") + cfg.LatestAgentVersion = getEnv("LATEST_AGENT_VERSION", "0.1.23.5") cfg.MinAgentVersion = getEnv("MIN_AGENT_VERSION", "0.1.22") cfg.SigningPrivateKey = getEnv("REDFLAG_SIGNING_PRIVATE_KEY", "") diff --git a/aggregator-server/internal/database/queries/agent_updates.go b/aggregator-server/internal/database/queries/agent_updates.go index 2bc92f4..8f93d1e 100644 --- a/aggregator-server/internal/database/queries/agent_updates.go +++ b/aggregator-server/internal/database/queries/agent_updates.go @@ -216,4 +216,66 @@ func (q *AgentUpdateQueries) GetAgentByMachineID(machineID string) (*models.Agen } return &agent, nil +} + +// GetLatestVersion retrieves the latest available version for a platform +func (q *AgentUpdateQueries) GetLatestVersion(platform string) (string, error) { + query := ` + SELECT version FROM agent_update_packages + WHERE platform = $1 AND is_active = true + ORDER BY version DESC LIMIT 1 + ` + + var latestVersion string + err := q.db.Get(&latestVersion, query, platform) + if err != nil { + if err == sql.ErrNoRows { + return "", fmt.Errorf("no update packages available for platform %s", platform) + } + return "", fmt.Errorf("failed to get latest version: %w", err) + } + + return latestVersion, nil +} + +// GetLatestVersionByTypeAndArch retrieves the latest available version for a specific os_type and architecture +func (q *AgentUpdateQueries) GetLatestVersionByTypeAndArch(osType, osArch string) (string, error) { + query := ` + SELECT version FROM agent_update_packages + WHERE platform = $1 AND architecture = $2 AND is_active = true + ORDER BY version DESC LIMIT 1 + ` + + var latestVersion string + err := q.db.Get(&latestVersion, query, osType, osArch) + if err != nil { + if err == sql.ErrNoRows { + return "", fmt.Errorf("no update packages available for platform %s/%s", osType, osArch) + } + return "", fmt.Errorf("failed to get latest version: %w", err) + } + + return latestVersion, nil +} + +// GetPendingUpdateCommand retrieves the most recent pending update command for an agent +func (q *AgentUpdateQueries) GetPendingUpdateCommand(agentID string) (*models.AgentCommand, error) { + query := ` + SELECT id, agent_id, command_type, params, status, source, created_at, sent_at, completed_at, result, retried_from_id + FROM agent_commands + WHERE agent_id = $1 AND command_type = 'install_update' AND status = 'pending' + ORDER BY created_at DESC + LIMIT 1 + ` + + var command models.AgentCommand + err := q.db.Get(&command, query, agentID) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil // No pending update command found + } + return nil, fmt.Errorf("failed to get pending update command: %w", err) + } + + return &command, nil } \ No newline at end of file diff --git a/aggregator-server/internal/models/agent_update.go b/aggregator-server/internal/models/agent_update.go index 1575429..9d86d5d 100644 --- a/aggregator-server/internal/models/agent_update.go +++ b/aggregator-server/internal/models/agent_update.go @@ -23,10 +23,11 @@ type AgentUpdatePackage struct { // AgentUpdateRequest represents a request to update an agent type AgentUpdateRequest struct { - AgentID uuid.UUID `json:"agent_id" binding:"required"` + AgentID uuid.UUID `json:"agent_id,omitempty"` // Optional when agent ID is in URL path Version string `json:"version" binding:"required"` Platform string `json:"platform" binding:"required"` Scheduled *string `json:"scheduled_at,omitempty"` + Nonce string `json:"nonce" binding:"required"` // Required security nonce to prevent replay attacks } // BulkAgentUpdateRequest represents a bulk update request diff --git a/aggregator-web/src/components/AgentUpdatesModal.tsx b/aggregator-web/src/components/AgentUpdatesModal.tsx index 2da7863..aef9fe9 100644 --- a/aggregator-web/src/components/AgentUpdatesModal.tsx +++ b/aggregator-web/src/components/AgentUpdatesModal.tsx @@ -73,6 +73,23 @@ export function AgentUpdatesModal({ const pkg = packages.find(p => p.id === packageId); if (!pkg) throw new Error('Package not found'); + // For single agent updates, use individual update with nonce for security + if (selectedAgentIds.length === 1) { + const agentId = selectedAgentIds[0]; + + // Generate nonce for security + const nonceData = await agentApi.generateUpdateNonce(agentId, pkg.version); + console.log('[UI] Update nonce generated for single agent:', nonceData); + + // Use individual update endpoint with nonce + return agentApi.updateAgent(agentId, { + version: pkg.version, + platform: pkg.platform, + nonce: nonceData.update_nonce + }); + } + + // For multiple agents, use bulk update const updateData = { agent_ids: selectedAgentIds, version: pkg.version, @@ -82,7 +99,9 @@ export function AgentUpdatesModal({ return agentApi.updateMultipleAgents(updateData); }, onSuccess: (data) => { - toast.success(`Update initiated for ${data.updated?.length || 0} agent(s)`); + const count = selectedAgentIds.length; + const message = count === 1 ? 'Update initiated for agent' : `Update initiated for ${count} agents`; + toast.success(message); setIsUpdating(false); onAgentsUpdated(); onClose(); diff --git a/aggregator-web/src/lib/api.ts b/aggregator-web/src/lib/api.ts index 1d377d3..cd7e001 100644 --- a/aggregator-web/src/lib/api.ts +++ b/aggregator-web/src/lib/api.ts @@ -172,6 +172,24 @@ export const agentApi = { return response.data; }, + // Check if update available for agent + checkForUpdateAvailable: async (agentId: string): Promise<{ hasUpdate: boolean; currentVersion: string; latestVersion?: string; reason?: string; platform?: string }> => { + const response = await api.get(`/agents/${agentId}/updates/available`); + return response.data; + }, + + // Get update status for agent + getUpdateStatus: async (agentId: string): Promise<{ status: string; progress?: number; error?: string }> => { + const response = await api.get(`/agents/${agentId}/updates/status`); + return response.data; + }, + + // Generate update nonce for agent (new security feature) + generateUpdateNonce: async (agentId: string, targetVersion: string): Promise<{ agent_id: string; target_version: string; update_nonce: string; expires_at: number; expires_in_seconds: number }> => { + const response = await api.post(`/agents/${agentId}/update-nonce?target_version=${targetVersion}`); + return response.data; + }, + // Update multiple agents (bulk) updateMultipleAgents: async (updateData: { agent_ids: string[];