feat: machine binding and version enforcement

migration 017 adds machine_id to agents table
middleware validates X-Machine-ID header on authed routes
agent client sends machine ID with requests
MIN_AGENT_VERSION config defaults 0.1.22
version utils added for comparison

blocks config copying attacks via hardware fingerprint
old agents get 426 upgrade required
breaking: <0.1.22 agents rejected
This commit is contained in:
Fimeg
2025-11-02 09:30:04 -05:00
parent 99480f3fe3
commit ec3ba88459
48 changed files with 3811 additions and 122 deletions

View File

@@ -1,18 +1,9 @@
import React, { useState } from 'react';
import React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
MonitorPlay,
RefreshCw,
Settings,
Activity,
Clock,
CheckCircle,
XCircle,
Play,
Square,
Database,
Shield,
Search,
HardDrive,
Cpu,
Container,
@@ -78,7 +69,7 @@ export function AgentScanners({ agentId }: AgentScannersProps) {
return await agentApi.disableSubsystem(agentId, subsystem);
}
},
onSuccess: (data, variables) => {
onSuccess: (_, variables) => {
toast.success(`${subsystemConfig[variables.subsystem]?.name || variables.subsystem} ${variables.enabled ? 'enabled' : 'disabled'}`);
queryClient.invalidateQueries({ queryKey: ['subsystems', agentId] });
},
@@ -92,7 +83,7 @@ export function AgentScanners({ agentId }: AgentScannersProps) {
mutationFn: async ({ subsystem, intervalMinutes }: { subsystem: string; intervalMinutes: number }) => {
return await agentApi.setSubsystemInterval(agentId, subsystem, intervalMinutes);
},
onSuccess: (data, variables) => {
onSuccess: (_, variables) => {
toast.success(`Interval updated to ${variables.intervalMinutes} minutes`);
queryClient.invalidateQueries({ queryKey: ['subsystems', agentId] });
},
@@ -106,7 +97,7 @@ export function AgentScanners({ agentId }: AgentScannersProps) {
mutationFn: async ({ subsystem, autoRun }: { subsystem: string; autoRun: boolean }) => {
return await agentApi.setSubsystemAutoRun(agentId, subsystem, autoRun);
},
onSuccess: (data, variables) => {
onSuccess: (_, variables) => {
toast.success(`Auto-run ${variables.autoRun ? 'enabled' : 'disabled'}`);
queryClient.invalidateQueries({ queryKey: ['subsystems', agentId] });
},
@@ -120,7 +111,7 @@ export function AgentScanners({ agentId }: AgentScannersProps) {
mutationFn: async (subsystem: string) => {
return await agentApi.triggerSubsystem(agentId, subsystem);
},
onSuccess: (data, subsystem) => {
onSuccess: (_, subsystem) => {
toast.success(`${subsystemConfig[subsystem]?.name || subsystem} scan triggered`);
queryClient.invalidateQueries({ queryKey: ['subsystems', agentId] });
},
@@ -155,12 +146,7 @@ export function AgentScanners({ agentId }: AgentScannersProps) {
{ value: 1440, label: '24 hours' },
];
const getFrequencyLabel = (frequency: number) => {
if (frequency < 60) return `${frequency}m`;
if (frequency < 1440) return `${frequency / 60}h`;
return `${frequency / 1440}d`;
};
const enabledCount = subsystems.filter(s => s.enabled).length;
const autoRunCount = subsystems.filter(s => s.auto_run && s.enabled).length;

View File

@@ -1,17 +1,8 @@
import React, { useState } from 'react';
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
HardDrive,
RefreshCw,
Database,
Search,
Activity,
Monitor,
AlertTriangle,
CheckCircle,
Info,
TrendingUp,
Server,
MemoryStick,
} from 'lucide-react';
import { formatBytes, formatRelativeTime } from '@/lib/utils';
@@ -116,15 +107,7 @@ export function AgentStorage({ agentId }: AgentStorageProps) {
}));
};
const getDiskTypeIcon = (diskType: string) => {
switch (diskType.toLowerCase()) {
case 'nvme': return <Database className="h-4 w-4 text-purple-500" />;
case 'ssd': return <Server className="h-4 w-4 text-blue-500" />;
case 'hdd': return <HardDrive className="h-4 w-4 text-gray-500" />;
default: return <Monitor className="h-4 w-4 text-gray-400" />;
}
};
if (!agentData) {
return (
<div className="space-y-6">

View File

@@ -4,6 +4,7 @@ import {
Search,
Package,
Download,
Upload,
CheckCircle,
RefreshCw,
Terminal,
@@ -18,6 +19,7 @@ import { updateApi, agentApi } from '@/lib/api';
import toast from 'react-hot-toast';
import { cn } from '@/lib/utils';
import type { UpdatePackage } from '@/types';
import { AgentUpdatesModal } from './AgentUpdatesModal';
interface AgentUpdatesEnhancedProps {
agentId: string;
@@ -52,7 +54,7 @@ export function AgentUpdatesEnhanced({ agentId }: AgentUpdatesEnhancedProps) {
const [selectedSeverity, setSelectedSeverity] = useState('all');
const [showLogsModal, setShowLogsModal] = useState(false);
const [logsData, setLogsData] = useState<LogResponse | null>(null);
const [isLoadingLogs, setIsLoadingLogs] = useState(false);
const [showUpdateModal, setShowUpdateModal] = useState(false);
const [expandedUpdates, setExpandedUpdates] = useState<Set<string>>(new Set());
const [selectedUpdates, setSelectedUpdates] = useState<string[]>([]);
@@ -300,6 +302,15 @@ export function AgentUpdatesEnhanced({ agentId }: AgentUpdatesEnhancedProps) {
)}
</button>
)}
{/* Update Agent Button */}
<button
onClick={() => setShowUpdateModal(true)}
className="text-sm text-primary-600 hover:text-primary-800 flex items-center space-x-1 border border-primary-300 px-2 py-1 rounded"
>
<Upload className="h-4 w-4" />
<span>Update Agent</span>
</button>
</div>
{/* Search and Filters */}
@@ -531,6 +542,17 @@ export function AgentUpdatesEnhanced({ agentId }: AgentUpdatesEnhancedProps) {
</div>
</div>
)}
{/* Agent Update Modal */}
<AgentUpdatesModal
isOpen={showUpdateModal}
onClose={() => setShowUpdateModal(false)}
selectedAgentIds={[agentId]}
onAgentsUpdated={() => {
setShowUpdateModal(false);
queryClient.invalidateQueries({ queryKey: ['agents'] });
}}
/>
</div>
);
}

View File

@@ -0,0 +1,290 @@
import { useState } from 'react';
import {
X,
Download,
CheckCircle,
AlertCircle,
RefreshCw,
Info,
Users,
Package,
Hash,
} from 'lucide-react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { agentApi, updateApi } from '@/lib/api';
import toast from 'react-hot-toast';
import { cn } from '@/lib/utils';
import { Agent } from '@/types';
interface AgentUpdatesModalProps {
isOpen: boolean;
onClose: () => void;
selectedAgentIds: string[];
onAgentsUpdated: () => void;
}
export function AgentUpdatesModal({
isOpen,
onClose,
selectedAgentIds,
onAgentsUpdated,
}: AgentUpdatesModalProps) {
const [selectedVersion, setSelectedVersion] = useState('');
const [selectedPlatform, setSelectedPlatform] = useState('');
const [isUpdating, setIsUpdating] = useState(false);
// Fetch selected agents details
const { data: agents = [] } = useQuery<Agent[]>({
queryKey: ['agents-details', selectedAgentIds],
queryFn: async (): Promise<Agent[]> => {
const promises = selectedAgentIds.map(id => agentApi.getAgent(id));
const results = await Promise.all(promises);
return results;
},
enabled: isOpen && selectedAgentIds.length > 0,
});
// Fetch available update packages
const { data: packagesResponse, isLoading: packagesLoading } = useQuery({
queryKey: ['update-packages'],
queryFn: () => updateApi.getPackages(),
enabled: isOpen,
});
const packages = packagesResponse?.packages || [];
// Group packages by version
const versions = [...new Set(packages.map(pkg => pkg.version))].sort((a, b) => b.localeCompare(a));
const platforms = [...new Set(packages.map(pkg => pkg.platform))].sort();
// Filter packages based on selection
const availablePackages = packages.filter(
pkg => (!selectedVersion || pkg.version === selectedVersion) &&
(!selectedPlatform || pkg.platform === selectedPlatform)
);
// Get unique platform for selected agents (simplified - assumes all agents same platform)
const agentPlatform = agents[0]?.os_type || 'linux';
const agentArchitecture = agents[0]?.os_architecture || 'amd64';
// Update agents mutation
const updateAgentsMutation = useMutation({
mutationFn: async (packageId: string) => {
const pkg = packages.find(p => p.id === packageId);
if (!pkg) throw new Error('Package not found');
const updateData = {
agent_ids: selectedAgentIds,
version: pkg.version,
platform: pkg.platform,
};
return agentApi.updateMultipleAgents(updateData);
},
onSuccess: (data) => {
toast.success(`Update initiated for ${data.updated?.length || 0} agent(s)`);
setIsUpdating(false);
onAgentsUpdated();
onClose();
},
onError: (error: any) => {
toast.error(`Failed to update agents: ${error.message}`);
setIsUpdating(false);
},
});
const handleUpdateAgents = async (packageId: string) => {
setIsUpdating(true);
updateAgentsMutation.mutate(packageId);
};
const canUpdate = selectedAgentIds.length > 0 && availablePackages.length > 0 && !isUpdating;
const hasUpdatingAgents = agents.some(agent => agent.is_updating);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={onClose} />
<div className="relative w-full max-w-4xl transform overflow-hidden rounded-lg bg-white p-6 text-left shadow-xl transition-all">
{/* Header */}
<div className="flex items-center justify-between pb-4 border-b">
<div className="flex items-center space-x-3">
<Download className="h-6 w-6 text-primary-600" />
<div>
<h3 className="text-lg font-semibold text-gray-900">Agent Updates</h3>
<p className="text-sm text-gray-500">
Update {selectedAgentIds.length} agent{selectedAgentIds.length !== 1 ? 's' : ''}
</p>
</div>
</div>
<button
onClick={onClose}
className="rounded-md p-2 text-gray-400 hover:text-gray-500"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Content */}
<div className="py-4">
{/* Selected Agents */}
<div className="mb-6">
<h4 className="text-sm font-medium text-gray-900 mb-3 flex items-center">
<Users className="h-4 w-4 mr-2" />
Selected Agents
</h4>
<div className="max-h-32 overflow-y-auto space-y-2">
{agents.map((agent) => (
<div key={agent.id} className="flex items-center justify-between p-2 bg-gray-50 rounded">
<div className="flex items-center space-x-3">
<CheckCircle className={cn(
"h-4 w-4",
agent.status === 'online' ? "text-green-500" : "text-gray-400"
)} />
<div>
<div className="text-sm font-medium text-gray-900">{agent.hostname}</div>
<div className="text-xs text-gray-500">
{agent.os_type}/{agent.os_architecture} Current: {agent.current_version || 'Unknown'}
</div>
</div>
</div>
{agent.is_updating && (
<div className="flex items-center text-amber-600 text-xs">
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
Updating to {agent.updating_to_version}
</div>
)}
</div>
))}
</div>
{hasUpdatingAgents && (
<div className="mt-2 text-xs text-amber-600 flex items-center">
<AlertCircle className="h-3 w-3 mr-1" />
Some agents are currently updating
</div>
)}
</div>
{/* Package Selection */}
<div className="mb-6">
<h4 className="text-sm font-medium text-gray-900 mb-3 flex items-center">
<Package className="h-4 w-4 mr-2" />
Update Package Selection
</h4>
{/* Filters */}
<div className="grid grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Version</label>
<select
value={selectedVersion}
onChange={(e) => setSelectedVersion(e.target.value)}
className="w-full rounded-md border-gray-300 shadow-sm text-sm"
>
<option value="">All Versions</option>
{versions.map(version => (
<option key={version} value={version}>{version}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Platform</label>
<select
value={selectedPlatform}
onChange={(e) => setSelectedPlatform(e.target.value)}
className="w-full rounded-md border-gray-300 shadow-sm text-sm"
>
<option value="">All Platforms</option>
{platforms.map(platform => (
<option key={platform} value={platform}>{platform}</option>
))}
</select>
</div>
</div>
{/* Available Packages */}
<div className="space-y-2 max-h-48 overflow-y-auto">
{packagesLoading ? (
<div className="text-center py-4 text-sm text-gray-500">
Loading packages...
</div>
) : availablePackages.length === 0 ? (
<div className="text-center py-4 text-sm text-gray-500">
No packages available for the selected criteria
</div>
) : (
availablePackages.map((pkg) => (
<div
key={pkg.id}
className={cn(
"flex items-center justify-between p-3 border rounded-lg cursor-pointer transition-colors",
"hover:bg-gray-50 border-gray-200"
)}
onClick={() => handleUpdateAgents(pkg.id)}
>
<div className="flex items-center space-x-3">
<Package className="h-4 w-4 text-primary-600" />
<div>
<div className="text-sm font-medium text-gray-900">
Version {pkg.version}
</div>
<div className="text-xs text-gray-500">
{pkg.platform} {(pkg.file_size / 1024 / 1024).toFixed(1)} MB
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<div className="text-xs text-gray-400">
<Hash className="h-3 w-3 inline mr-1" />
{pkg.checksum.slice(0, 8)}...
</div>
<button
disabled={!canUpdate}
className={cn(
"px-3 py-1 text-xs rounded-md font-medium transition-colors",
canUpdate
? "bg-primary-600 text-white hover:bg-primary-700"
: "bg-gray-100 text-gray-400 cursor-not-allowed"
)}
>
{isUpdating ? (
<RefreshCw className="h-3 w-3 animate-spin" />
) : (
'Update'
)}
</button>
</div>
</div>
))
)}
</div>
</div>
{/* Platform Compatibility Info */}
<div className="text-xs text-gray-500 flex items-start">
<Info className="h-3 w-3 mr-1 mt-0.5 flex-shrink-0" />
<span>
Detected platform: <strong>{agentPlatform}/{agentArchitecture}</strong>.
Only compatible packages will be shown.
</span>
</div>
</div>
{/* Footer */}
<div className="flex justify-end space-x-3 pt-4 border-t">
<button
onClick={onClose}
disabled={isUpdating}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50"
>
Cancel
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState } from 'react';
import {
CheckCircle,
XCircle,
@@ -7,14 +7,12 @@ import {
Search,
Terminal,
RefreshCw,
Filter,
ChevronDown,
ChevronRight,
User,
Clock,
Activity,
Copy,
Hash,
HardDrive,
Cpu,
Container,
@@ -25,7 +23,6 @@ import { useRetryCommand } from '@/hooks/useCommands';
import { cn } from '@/lib/utils';
import toast from 'react-hot-toast';
import { Highlight, themes } from 'prism-react-renderer';
import { useEffect as useEffectHook } from 'react';
interface HistoryEntry {
id: string;
@@ -41,6 +38,8 @@ interface HistoryEntry {
exit_code?: number;
duration_seconds?: number;
created_at: string;
metadata?: Record<string, string>;
params?: Record<string, any>;
hostname?: string;
}
@@ -76,9 +75,9 @@ const createPackageOperationSummary = (entry: HistoryEntry): string => {
// Extract duration if available
let durationInfo = '';
if (entry.logged_at) {
if (entry.created_at) {
try {
const loggedTime = new Date(entry.logged_at).toLocaleTimeString('en-US', {
const loggedTime = new Date(entry.created_at).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
});
@@ -444,9 +443,27 @@ const ChatTimeline: React.FC<ChatTimelineProps> = ({ agentId, className, isScope
}
}
// Fallback subject
// Fallback subject - provide better action labels
if (!subject) {
subject = entry.package_name || 'system operation';
// Map action to more readable labels
const actionLabels: Record<string, string> = {
'scan updates': 'Package Updates',
'scan storage': 'Disk Usage',
'scan system': 'System Metrics',
'scan docker': 'Docker Images',
'update agent': 'Agent Update',
'dry run update': 'Update Dry Run',
'confirm dependencies': 'Dependency Check',
'install update': 'Update Installation',
'collect specs': 'System Specifications',
'enable heartbeat': 'Heartbeat Enable',
'disable heartbeat': 'Heartbeat Disable',
'reboot': 'System Reboot',
'process command': 'Command Processing'
};
// Prioritize metadata subsystem label for better descriptions
subject = entry.metadata?.subsystem_label || entry.package_name || actionLabels[action] || action;
}
// Build narrative sentence - system thought style
@@ -495,6 +512,16 @@ const ChatTimeline: React.FC<ChatTimelineProps> = ({ agentId, className, isScope
} else {
sentence = `Docker Image Scanner results`;
}
} else if (action === 'update agent') {
if (isInProgress) {
sentence = `Agent Update initiated to version ${subject}`;
} else if (statusType === 'success') {
sentence = `Agent updated to version ${subject}`;
} else if (statusType === 'failed') {
sentence = `Agent update failed for version ${subject}`;
} else {
sentence = `Agent update to version ${subject}`;
}
} else if (action === 'dry run update') {
if (isInProgress) {
sentence = `Dry run initiated for ${subject}`;

View File

@@ -2,6 +2,7 @@ import axios, { AxiosResponse } from 'axios';
import {
Agent,
UpdatePackage,
AgentUpdatePackage,
DashboardStats,
AgentListResponse,
UpdateListResponse,
@@ -160,6 +161,27 @@ export const agentApi = {
const response = await api.post(`/agents/${agentId}/subsystems/${subsystem}/interval`, { interval_minutes: intervalMinutes });
return response.data;
},
// Update single agent
updateAgent: async (agentId: string, updateData: {
version: string;
platform: string;
scheduled?: string;
}): Promise<{ message: string; update_id: string; download_url: string; signature: string; checksum: string; file_size: number; estimated_time: number }> => {
const response = await api.post(`/agents/${agentId}/update`, updateData);
return response.data;
},
// Update multiple agents (bulk)
updateMultipleAgents: async (updateData: {
agent_ids: string[];
version: string;
platform: string;
scheduled?: string;
}): Promise<{ message: string; updated: Array<{ agent_id: string; hostname: string; update_id: string; status: string }>; failed: string[]; total_agents: number; package_info: any }> => {
const response = await api.post('/agents/bulk-update', updateData);
return response.data;
},
};
export const updateApi = {
@@ -185,6 +207,11 @@ export const updateApi = {
await api.post(`/updates/${id}/approve`, { scheduled_at: scheduledAt });
},
// Approve multiple updates
approveMultiple: async (updateIds: string[]): Promise<void> => {
await api.post('/updates/approve', { update_ids: updateIds });
},
// Reject/cancel update
rejectUpdate: async (id: string): Promise<void> => {
await api.post(`/updates/${id}/reject`);
@@ -250,6 +277,28 @@ export const updateApi = {
const response = await api.delete(`/commands/failed${params.toString() ? '?' + params.toString() : ''}`);
return response.data;
},
// Get available update packages
getPackages: async (params?: {
version?: string;
platform?: string;
limit?: number;
offset?: number;
}): Promise<{ packages: AgentUpdatePackage[]; total: number; limit: number; offset: number }> => {
const response = await api.get('/updates/packages', { params });
return response.data;
},
// Sign new update package
signPackage: async (packageData: {
version: string;
platform: string;
architecture: string;
binary_path: string;
}): Promise<{ message: string; package: UpdatePackage }> => {
const response = await api.post('/updates/packages/sign', packageData);
return response.data;
},
};
export const statsApi = {

View File

@@ -25,6 +25,7 @@ import {
Database,
Settings,
MonitorPlay,
Upload,
} from 'lucide-react';
import { useAgents, useAgent, useScanAgent, useScanMultipleAgents, useUnregisterAgent } from '@/hooks/useAgents';
import { useActiveCommands, useCancelCommand } from '@/hooks/useCommands';
@@ -38,6 +39,7 @@ import { AgentSystemUpdates } from '@/components/AgentUpdates';
import { AgentStorage } from '@/components/AgentStorage';
import { AgentUpdatesEnhanced } from '@/components/AgentUpdatesEnhanced';
import { AgentScanners } from '@/components/AgentScanners';
import { AgentUpdatesModal } from '@/components/AgentUpdatesModal';
import ChatTimeline from '@/components/ChatTimeline';
const Agents: React.FC = () => {
@@ -56,6 +58,7 @@ const Agents: React.FC = () => {
const [showDurationDropdown, setShowDurationDropdown] = useState(false);
const [heartbeatLoading, setHeartbeatLoading] = useState(false); // Loading state for heartbeat toggle
const [heartbeatCommandId, setHeartbeatCommandId] = useState<string | null>(null); // Track specific heartbeat command
const [showUpdateModal, setShowUpdateModal] = useState(false); // Update modal state
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
@@ -1142,18 +1145,27 @@ const Agents: React.FC = () => {
{/* Bulk actions */}
{selectedAgents.length > 0 && (
<button
onClick={handleScanSelected}
disabled={scanMultipleMutation.isPending}
className="btn btn-primary"
>
{scanMultipleMutation.isPending ? (
<RefreshCw className="animate-spin h-4 w-4 mr-2" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
)}
Scan Selected ({selectedAgents.length})
</button>
<>
<button
onClick={handleScanSelected}
disabled={scanMultipleMutation.isPending}
className="btn btn-primary"
>
{scanMultipleMutation.isPending ? (
<RefreshCw className="animate-spin h-4 w-4 mr-2" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
)}
Scan Selected ({selectedAgents.length})
</button>
<button
onClick={() => setShowUpdateModal(true)}
className="btn btn-secondary"
>
<Upload className="h-4 w-4 mr-2" />
Update Selected ({selectedAgents.length})
</button>
</>
)}
</div>
@@ -1358,6 +1370,20 @@ const Agents: React.FC = () => {
>
<RefreshCw className="h-4 w-4" />
</button>
<button
onClick={() => {
setSelectedAgents([agent.id]);
setShowUpdateModal(true);
}}
disabled={agent.is_updating}
className={cn(
"text-gray-400 hover:text-primary-600",
agent.is_updating && "text-amber-600 animate-pulse"
)}
title={agent.is_updating ? "Agent is updating..." : "Update agent"}
>
<Upload className="h-4 w-4" />
</button>
<button
onClick={() => handleRemoveAgent(agent.id, agent.hostname)}
disabled={unregisterAgentMutation.isPending}
@@ -1382,6 +1408,18 @@ const Agents: React.FC = () => {
</div>
</div>
)}
{/* Agent Updates Modal */}
<AgentUpdatesModal
isOpen={showUpdateModal}
onClose={() => setShowUpdateModal(false)}
selectedAgentIds={selectedAgents}
onAgentsUpdated={() => {
// Refresh agents data after update
queryClient.invalidateQueries({ queryKey: ['agents'] });
setSelectedAgents([]);
}}
/>
</div>
);
};

View File

@@ -26,6 +26,9 @@ export interface Agent {
last_reboot_at?: string | null;
reboot_reason?: string;
metadata?: Record<string, any>;
system_info?: Record<string, any>;
is_updating?: boolean;
updating_to_version?: string;
// Note: ip_address not available from API yet
}
@@ -57,9 +60,21 @@ export interface UpdatePackage {
approved_at: string | null;
scheduled_at: string | null;
installed_at: string | null;
created_at: string;
recent_command_id?: string;
metadata: Record<string, any>;
}
// Agent Update Package (for agent binary updates)
export interface AgentUpdatePackage {
id: string;
version: string;
platform: string;
file_size: number;
checksum: string;
created_at: string;
}
// Update specific types
export interface DockerUpdateInfo {
local_digest: string;