File size: 81,474 Bytes
4df9a41 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 | """Backup and import commands for hermes CLI."""
import json
import logging
import os
import shutil
import sqlite3
import stat
import sys
import tempfile
import threading
import time
import zipfile
from contextlib import closing, contextmanager, suppress
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from hermes_constants import (
_get_platform_default_hermes_home, get_default_hermes_root, get_hermes_home, display_hermes_home,
)
from hermes_state_dbfile import RETIRED_GENERATION_DIR_SUFFIX
from utils import (
_preserve_file_mode, _preserve_file_owner, _restore_file_mode, _restore_file_owner, atomic_replace,
default_new_file_mode,
)
from hermes_cli.sizefmt import format_bytes as _format_size
logger = logging.getLogger(__name__)
# --- Exclusion rules ---
# Where ``hermes backup --quick`` / ``/snapshot`` / the pre-update safety net write state
# snapshots (see ``create_quick_snapshot``); defined here because the exclusion set needs it.
_QUICK_SNAPSHOTS_DIR = "state-snapshots"
# Directory names to skip (matched against each path component). ``hermes-agent`` only matches at
# the root (``_should_exclude``) so skill dirs like ``skills/.../hermes-agent/`` survive. The
# dependency/cache entries matter: one plugin venv or pip/uv cache under HERMES_HOME walked
# file-by-file balloons a backup to hundreds of thousands of entries ("backup stuck for days").
# Mostly mirrors ``agent.skill_utils.EXCLUDED_SKILL_DIRS``; ``.cache`` is backup-only. ``.archive``
# is deliberately NOT excluded: the curator's ``skills/.archive/`` holds restorable user skills.
_EXCLUDED_DIRS = {
"hermes-agent", # the codebase repo β re-clone instead
"__pycache__", # bytecode caches β regenerated on import
".git", # nested git dirs (profiles shouldn't have these, but safety)
"node_modules", # js deps β reinstalled on demand
"backups", # prior auto-backups β don't nest backups exponentially
_QUICK_SNAPSHOTS_DIR, # each holds a full state.db copy β same reason as ``backups``
"checkpoints", # session-hash-keyed trajectory caches β regenerated, don't port
# Live CDP browser profiles: Chromium holds their SQLite DBs exclusively locked while running
# and sqlite3.backup() retries SQLITE_BUSY forever, hanging the backup. Regenerable anyway.
"browser-profiles",
# Real-profile browsing snapshot (browser.use_real_profile): copies of the user's Cookies /
# Login Data β a credential store that must NOT enter an archive. Regenerated on next launch.
"browser-profile",
# Python dependency trees (plugin / MCP-server venvs) β regenerated by reinstalling.
".venv", "venv", "site-packages",
# Tool / build caches β all regeneratable.
".cache", ".tox", ".nox", ".pytest_cache", ".mypy_cache", ".ruff_cache",
}
# Hermes-managed runtime downloads (GGUF models, llama.cpp runtimes, managed Node): re-downloaded
# on demand and routinely tens to hundreds of GB. Matched ONLY at the root of HERMES_HOME and at
# ``profiles/<name>/`` β a deeper dir of the same name (a skill's ``models/``) is user data.
_EXCLUDED_ROOT_DIRS = {"models", "runtimes", "node"}
# ``cache/`` at those same roots mixes regenerable state (model/plugin catalogs, stamps, browser
# profiles with locked SQLite, tool-output spill) with durable artifacts nothing can rebuild: media
# the gateway delivered to or received from the user (``gateway.platforms.base``'s media-delivery
# subdirs) and the grounded-citations evidence ledger. Only these subdirs are archived.
_KEPT_CACHE_SUBDIRS = {"images", "audio", "videos", "documents", "screenshots", "citations"}
def _in_excluded_root_dir(rel_path: Path) -> bool:
"""True when *rel_path* is inside a regenerable tree at a profile-home root."""
parts = rel_path.parts
if len(parts) >= 3 and parts[0] == "profiles":
parts = parts[2:]
if not parts:
return False
if parts[0] in _EXCLUDED_ROOT_DIRS:
return True
return parts[0] == "cache" and len(parts) >= 2 and parts[1] not in _KEPT_CACHE_SUBDIRS
# SQLite sidecars are excluded because ``*.db`` is snapshotted via ``sqlite3.backup()``:
# shipping the live WAL/SHM/journal alongside would pair a fresh snapshot with stale sidecar
# state and produce a torn restore on next open. They are regenerated on first connection.
_SQLITE_SIDECAR_SUFFIXES = (".db-wal", ".db-shm", ".db-journal")
_EXCLUDED_SUFFIXES = (".pyc", ".pyo", *_SQLITE_SIDECAR_SUFFIXES)
# File names to skip (runtime state that's meaningless on another machine)
_EXCLUDED_NAMES = {".backup.lock", "gateway.pid", "cron.pid"}
# The desktop updater's pre-flight drops ``state.db.pre-update-emergency-<ts>.bak`` at the root
# β a backup artifact like ``backups/``. Prefix-matched because the name carries a timestamp;
# a plain ``.bak`` suffix rule would drop user files.
# Retired-WAL capture dirs (``<name>.retired-wal-<ts>-<pid>/``) are excluded whole: a
# ``sqlite3.backup()`` snapshot of the live db paired with the captured ``-wal`` is exactly the
# torn-restore hazard the sidecar exclusion below exists to prevent, and the capture is an
# operator-recovery artifact that must move as a unit (manifest + image + WAL), never partially.
_EXCLUDED_PREFIXES = (
"state.db.pre-update-emergency-",
f"state.db{RETIRED_GENERATION_DIR_SUFFIX}",
)
# Files ``hermes import`` must never overwrite, matched by basename so root and named profiles are
# both covered. They hold runtime state namespaced to the SOURCE machine: ``gateway_state.json``
# drives the container-boot reconciler (a foreign value leaves the gateway stuck "starting" and
# disconnected from the Nous portal); PID/lock/registry files reference source PIDs. Mirrors
# ``container_boot._STALE_RUNTIME_FILES``; import filters too because older backups predate the
# backup-side exclusions.
_IMPORT_SKIP_NAMES = {"gateway_state.json", "gateway.pid", "cron.pid", "gateway.lock", "processes.json"}
# zipfile.open() drops Unix mode bits on extract; restore tightens these to 0600.
# vault.key / vault.json.enc: the local credential vault (agent/vault_store.py)
# IS included in backups (user-entered secrets, not regenerable β unlike the
# excluded browser-profile/ snapshot) but must come back owner-only.
_SECRET_FILE_NAMES = {".env", "auth.json", "state.db", "vault.key", "vault.json.enc"}
# Reserved archive subtree for memory-provider state OUTSIDE HERMES_HOME (e.g. ~/.honcho, via
# MemoryProvider.backup_paths()), stored and restored relative to the user's home; paths not
# under home are skipped.
_EXTERNAL_PREFIX = "_external/"
class BackupInProgressError(RuntimeError):
"""Raised when another process already owns the Hermes backup slot."""
class _SQLiteSnapshotError(RuntimeError):
pass
class _SQLiteBackupTimeout(RuntimeError):
"""Raised when a SQLite snapshot remains busy past its deadline."""
@contextmanager
def _backup_operation_lock(hermes_home: Path, timeout_seconds: float = 0.25):
"""Acquire one cross-process backup slot for full and quick snapshots."""
lock_path = hermes_home / ".backup.lock"
lock_path.parent.mkdir(parents=True, exist_ok=True)
handle = lock_path.open("a+b")
acquired = False
deadline = time.monotonic() + max(0.0, timeout_seconds)
try:
if os.name == "nt":
import msvcrt
if lock_path.stat().st_size == 0:
handle.write(b" ")
handle.flush()
def _lock_op(flag: int) -> None:
handle.seek(0)
msvcrt.locking(handle.fileno(), flag, 1)
lock_flag, unlock_flag = msvcrt.LK_NBLCK, msvcrt.LK_UNLCK
else:
import fcntl
def _lock_op(flag: int) -> None:
fcntl.flock(handle.fileno(), flag)
lock_flag, unlock_flag = fcntl.LOCK_EX | fcntl.LOCK_NB, fcntl.LOCK_UN
while not acquired:
try:
_lock_op(lock_flag)
acquired = True
except OSError:
if time.monotonic() >= deadline:
raise BackupInProgressError("another Hermes backup is already running")
time.sleep(0.05)
yield
finally:
if acquired:
with suppress(OSError):
_lock_op(unlock_flag)
handle.close()
@contextmanager
def _atomic_output_path(final_path: Path):
"""Yield a hidden sibling path and publish it only after a clean close."""
partial_path = final_path.with_name(f".{final_path.name}.{os.getpid()}-{threading.get_ident()}.partial")
partial_path.unlink(missing_ok=True)
try:
yield partial_path
os.replace(partial_path, final_path)
except BaseException:
partial_path.unlink(missing_ok=True)
raise
def _is_within(path: Path, root: Path) -> bool:
"""True when *path* resolves inside the already-resolved *root* (traversal / symlink guard)."""
return path.resolve().is_relative_to(root)
def _collect_memory_provider_external_paths() -> List[Path]:
"""Existing paths the active memory provider declares via ``backup_paths()``; ``[]`` on any
provider failure (backup must never fail because of a flaky plugin)."""
try:
from plugins.memory import _get_active_memory_provider, load_memory_provider
active = _get_active_memory_provider()
provider = load_memory_provider(active) if active else None
except Exception:
return []
if provider is None:
return []
try:
declared = provider.backup_paths() or []
except Exception as exc:
logger.warning("backup_paths() failed for memory provider %r: %s", active, exc)
return []
out: Dict[Path, Path] = {} # resolved -> first declared spelling
for raw in declared:
try:
p = Path(raw).expanduser()
except Exception:
continue
if not p.exists():
continue
try:
resolved = p.resolve()
except (OSError, ValueError):
continue
out.setdefault(resolved, p)
return list(out.values())
def _iter_external_files(base: Path) -> List[Path]:
"""Regular files under *base* (a file or a directory), skipping symlinks, caches, and pyc."""
if base.is_file() and not base.is_symlink():
return [base]
if not base.is_dir():
return []
files: List[Path] = []
for dirpath, dirnames, filenames in os.walk(base, followlinks=False):
dirnames[:] = [d for d in dirnames if d not in _EXCLUDED_DIRS]
files.extend(fp for fp in (Path(dirpath) / f for f in filenames)
if not (_is_non_regular_path(fp) or fp.name in _EXCLUDED_NAMES
or fp.name.endswith(_EXCLUDED_SUFFIXES)))
return files
def _is_non_regular_path(path: Path) -> bool:
"""True for symlinks, sockets, devices, and other non-regular filesystem entries.
A failed ``lstat`` is not treated as an exclusion: the archive writer must see the path and
report the read failure instead of silently claiming a complete backup.
"""
try:
return not stat.S_ISREG(path.lstat().st_mode)
except OSError:
return False
def _should_exclude(rel_path: Path) -> bool:
"""Return True if *rel_path* (relative to hermes root) should be skipped."""
parts = rel_path.parts
if _in_excluded_root_dir(rel_path):
return True
# ``hermes-agent`` only matches at the root level; nested same-named dirs are preserved.
if any(p in _EXCLUDED_DIRS and (p != "hermes-agent" or p == parts[0]) for p in parts):
return True
name = rel_path.name
return name in _EXCLUDED_NAMES or name.startswith(_EXCLUDED_PREFIXES) or name.endswith(_EXCLUDED_SUFFIXES)
def _iter_backup_files(hermes_root: Path, out_path: Path, skipped_dirs: Optional[set] = None):
"""Yield ``(abs_path, rel_path)`` for every file a full backup should hold.
The one owner of the walk policy (directory pruning so os.walk never descends a multi-GB
excluded tree, the root-only ``hermes-agent`` carve-out, root runtime trees, per-file rules),
shared by ``hermes backup`` and the pre-update / pre-migration path so they can never drift.
"""
for dirpath, dirnames, filenames in os.walk(hermes_root, followlinks=False):
rel_dir = Path(dirpath).relative_to(hermes_root)
is_root = rel_dir == Path(".")
kept = [
d for d in dirnames
if (d not in _EXCLUDED_DIRS or (d == "hermes-agent" and not is_root))
and not _in_excluded_root_dir(rel_dir / d)]
if skipped_dirs is not None:
skipped_dirs.update(str(rel_dir / d) for d in set(dirnames) - set(kept))
dirnames[:] = kept
for fname in filenames:
rel = rel_dir / fname
fpath = hermes_root / rel
# zipfile.write() follows file symlinks, so skip links before any archive write can
# copy data from outside HERMES_HOME; never archive the output zip into itself.
if _should_exclude(rel) or _is_non_regular_path(fpath):
continue
with suppress(OSError, ValueError):
if fpath.resolve() == out_path.resolve():
continue
yield fpath, rel
# --- SQLite safe copy ---
def _close_quietly(conn: Optional[sqlite3.Connection]) -> None:
if conn is not None:
with suppress(Exception):
conn.close()
def _query_ro_sqlite(path: Path, fn):
"""Run ``fn(conn)`` on a read-only connection to *path*; return ``(value, None)`` or ``(None, exc)``."""
conn = None
try:
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=1.0)
return fn(conn), None
except Exception as exc:
return None, exc
finally:
_close_quietly(conn)
def _safe_copy_db(src: Path, dst: Path, *, timeout_seconds: float = 10.0) -> bool:
"""Copy a SQLite database with the backup() API (WAL-safe consistent snapshot).
Fails closed when no consistent snapshot can be made: copying only the main file loses WAL data.
"""
conn = backup_conn = None
try:
# sqlite3.connect() creates a missing destination with the process
# umask, which is commonly 0022 (0644). Snapshot databases contain
# session and tool state, so create the inode owner-only before SQLite
# writes its first byte. O_NOFOLLOW also refuses a planted symlink on
# platforms that support it. Tighten an existing internal staging
# file as well (NamedTemporaryFile callers already create it 0600).
if os.name != "nt":
open_flags = os.O_WRONLY | os.O_CREAT
if hasattr(os, "O_NOFOLLOW"):
open_flags |= os.O_NOFOLLOW
secure_fd = os.open(dst, open_flags, 0o600)
try:
os.fchmod(secure_fd, 0o600)
finally:
os.close(secure_fd)
# timeout=0.0 disables sqlite3's implicit busy wait so the progress callback owns the
# full locked-source deadline instead of adding the default timeout before each callback.
conn = sqlite3.connect(f"file:{src}?mode=ro", uri=True, timeout=0.0)
backup_conn = sqlite3.connect(str(dst))
busy_deadline = time.monotonic() + max(0.0, timeout_seconds)
def _check_backup_progress(status: int, _remaining: int, _total: int) -> None:
nonlocal busy_deadline
now = time.monotonic()
if status in (sqlite3.SQLITE_BUSY, sqlite3.SQLITE_LOCKED):
if now >= busy_deadline:
raise _SQLiteBackupTimeout(f"database remained locked for {timeout_seconds:g} seconds")
else:
busy_deadline = now + max(0.0, timeout_seconds)
conn.backup(backup_conn, pages=256, progress=_check_backup_progress, sleep=0.1)
return True
except Exception as exc:
logger.warning("SQLite safe copy failed for %s: %s", src, exc)
# Windows won't remove the partial destination while SQLite still has it open.
_close_quietly(backup_conn)
backup_conn = None
with suppress(OSError):
dst.unlink(missing_ok=True)
return False
finally:
_close_quietly(backup_conn)
_close_quietly(conn)
def is_zeroed_sqlite_file(path: Path, *, probe_bytes: int = 100, force: bool = False) -> bool:
"""True when *path* looks like the #68474 zeroed-state.db signature.
Only regular files qualify: probing a FIFO/device/socket could block indefinitely.
Signature: no ``SQLite format 3`` header and no data β either empty (size 0, the total-loss case,
#97568) or first *probe_bytes* all NUL. Used at SessionDB open and for snapshot diagnostics so a silent
all-zero file becomes a guided recovery instead of a generic failure.
"""
try:
if not path.is_file():
return False
except OSError:
return False
from hermes_cli.sqlite_safe_read import has_live_connection, read_header_bytes_preopen
if not force and has_live_connection(path):
return False
head = read_header_bytes_preopen(path, length=max(16, probe_bytes), force=force)
# Empty or all-NUL header => zeroed; a real header (or unreadable) => not.
return head is not None and not head.startswith(b"SQLite format 3") and not any(head)
# --- SQLite integrity verification ---
_SQLITE_HEADER = b"SQLite format 3\0"
# Above this size ``PRAGMA integrity_check`` (walks every b-tree page β minutes of pegged CPU on a
# 30 GB state.db, reading as a hung ``hermes update``) is replaced by the O(1) header+schema probe.
# Default ceiling above which ``PRAGMA integrity_check`` is skipped in favour of the (O(1)) header +
# structural probe. Sessions databases in the tens of GB are normal for heavy users, so the size-unbounded
# check is never an acceptable default on the update path. See #70553.
DEFAULT_INTEGRITY_CHECK_MAX_BYTES = 2 << 30 # 2 GiB
def verify_sqlite_integrity(
path: Path, *, check_header: bool = True, run_pragma: bool = True,
max_bytes: int = DEFAULT_INTEGRITY_CHECK_MAX_BYTES) -> dict:
"""Verify a SQLite database: existence + minimum size, header magic, then a read-only
``PRAGMA integrity_check`` (or a cheap structural probe above ``max_bytes``)."""
def _done(message: str, valid: bool = False, size: Optional[int] = None) -> dict:
return {"valid": valid, "message": message, "size": size}
try:
st = path.stat()
except FileNotFoundError:
return _done(f"not found: {path}")
except OSError as exc:
return _done(f"cannot stat: {exc}")
size = st.st_size
if size < 100: # SQLite minimum viable size (header + 1 page)
return _done(f"too small ({size} bytes) to be a valid SQLite database", size=size)
if check_header:
# Refused when a live connection exists (close() would cancel this process's POSIX locks
# β see sqlite_safe_read); verification targets offline snapshots/backup artifacts anyway.
from hermes_cli.sqlite_safe_read import read_header_bytes_preopen
head = read_header_bytes_preopen(path, length=len(_SQLITE_HEADER))
if head is None:
return _done("cannot read header", size=size)
if head != _SQLITE_HEADER:
return _done(f"missing SQLite header magic (got {head[:16].hex()!r})", size=size)
if max_bytes > 0 and size > max_bytes:
# O(1) probe: the header check caught the zeroed signature; reading sqlite_master + page
# geometry catches malformed-schema and truncated-header-page classes without a data walk.
_, exc = _query_ro_sqlite(path, lambda c: (
c.execute("PRAGMA schema_version").fetchone(),
c.execute("SELECT count(*) FROM sqlite_master").fetchone()))
if exc is not None:
kind = "failed" if isinstance(exc, sqlite3.DatabaseError) else "error"
return _done(f"schema probe {kind}: {exc}", size=size)
return _done(
f"size {size:,} bytes exceeds max_bytes {max_bytes:,}; "
"skipped PRAGMA integrity_check (header + schema probe passed)",
valid=True, size=size)
if run_pragma:
rows, exc = _query_ro_sqlite(
path, lambda c: [str(r[0]) for r in c.execute("PRAGMA integrity_check")])
if exc is not None:
kind = "cannot open database" if isinstance(exc, sqlite3.DatabaseError) else "integrity check error"
return _done(f"{kind}: {exc}", size=size)
if rows == ["ok"]:
return _done("integrity check passed", valid=True, size=size)
return _done(f"integrity check failed: {'; '.join(rows[:5])}", size=size)
return _done("header check passed", valid=True, size=size)
def _foreign_db_holder_pids(db_path: Path) -> Optional[List[int]]:
"""PIDs of OTHER processes holding *db_path* or its WAL/SHM open (Linux ``/proc`` scan).
An already-unlinked ``(deleted)`` sidecar β the #90950 split-brain fingerprint β still
counts as held. None off-Linux or when /proc fails.
"""
if not sys.platform.startswith("linux"):
return None
def _canonical(path: str) -> str:
return os.path.normcase(os.path.abspath(path.removesuffix(" (deleted)")))
def _holds_watched(fds: List[str], fd_dir: str) -> bool:
for fd in fds:
try:
target = os.readlink(f"{fd_dir}/{fd}")
except OSError:
continue
if _canonical(target) in watched:
return True
return False
canonical_db = _canonical(os.fspath(db_path))
watched = {canonical_db, canonical_db + "-wal", canonical_db + "-shm"}
pids: List[int] = []
try:
own_pid = os.getpid()
for pid_str in os.listdir("/proc"):
if not pid_str.isdigit() or int(pid_str) == own_pid:
continue
fd_dir = f"/proc/{pid_str}/fd"
try:
fds = os.listdir(fd_dir)
except OSError:
continue
if _holds_watched(fds, fd_dir):
pids.append(int(pid_str))
except OSError:
return None
return pids
def _safe_restore_db(src: Path, dst: Path) -> bool:
"""Restore snapshot *src* into live *dst* through the backup() API; unlink+move fallback.
Writing pages into the live file preserves its inode and WAL state, so other holders (gateway,
dashboard, another CLI) see the restored data instead of stale pages from a replaced inode.
The fallback runs ONLY when no other process or in-process connection holds the file
(replacing the inode under a live holder is the #90950 split-brain); otherwise it fails closed
(``False``) and the caller reports the file as skipped.
"""
try:
dst_conn = sqlite3.connect(str(dst))
# Checkpoint first so the backup starts clean rather than writing on top of a deep WAL.
with suppress(Exception):
dst_conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
with closing(sqlite3.connect(f"file:{src}?mode=ro", uri=True)) as src_conn:
src_conn.backup(dst_conn)
dst_conn.close()
with suppress(Exception):
dst.chmod(src.stat().st_mode)
return True
except Exception as exc:
logger.warning("SQLite safe restore failed for %s -> %s: %s", src, dst, exc)
return _unlink_move_restore_db(src, dst)
def _unlink_move_restore_db(src: Path, dst: Path) -> bool:
"""Fallback restore: unlink+move. Only safe when no process holds the DB open.
Replacing the inode under a live holder is the #90950 corruption class (the holder keeps
writing through a deleted-inode fd and loses its WAL index), so fail closed. The foreign-pid
scan excludes THIS process, so ``offline_file_access`` also fails CLOSED on any live
in-process connection to *dst* and holds the connection-lifecycle lock across the swap.
"""
from hermes_cli.sqlite_safe_read import LiveConnectionError, offline_file_access
try:
holders = _foreign_db_holder_pids(dst)
if holders:
logger.error("Refusing unlink+move restore of %s: process(es) %s still "
"hold the database or its WAL open. Stop them and retry.", dst, holders)
return False
with offline_file_access(dst, what="unlink+move restore of"):
tmp = dst.parent / f".{dst.name}.snap_restore"
shutil.copy2(src, tmp)
dst.unlink(missing_ok=True)
# The snapshot owns no WAL, so any -wal/-shm here belongs to the DB just unlinked (a
# killed gateway leaves them β exactly when a restore runs); SQLite would replay that
# foreign WAL over the restored file: "malformed" or resurrected post-snapshot rows.
for _sidecar_suffix in ("-wal", "-shm", "-journal"):
dst.with_name(dst.name + _sidecar_suffix).unlink(missing_ok=True)
shutil.move(str(tmp), str(dst))
return True
except LiveConnectionError as exc2:
logger.error("Refusing unlink+move restore of %s: %s Close the in-process "
"database handles (or restart Hermes) and retry.", dst, exc2)
return False
except Exception as exc2:
logger.error("Fallback restore also failed for %s -> %s: %s", src, dst, exc2)
return False
def _zip_sqlite_snapshot(zf: zipfile.ZipFile, abs_path: Path, rel_path: Path, out_path: Path) -> Optional[int]:
"""Add a WAL-safe snapshot of *abs_path* to *zf*; return its byte size, or None on failure.
Staged beside the output zip: /tmp may be a small tmpfs that cannot hold large databases.
"""
with tempfile.NamedTemporaryFile(suffix=".db", delete=False, dir=str(out_path.parent)) as tmp:
tmp_db = Path(tmp.name)
try:
if not _safe_copy_db(abs_path, tmp_db):
return None
zf.write(tmp_db, arcname=str(rel_path))
return tmp_db.stat().st_size
finally:
tmp_db.unlink(missing_ok=True)
def _write_zip_entries(
zf: zipfile.ZipFile, files_to_add: List[Tuple[Path, Path]], out_path: Path,
*, on_db_failure, on_error, on_progress, track_bytes: bool) -> int:
"""Add every ``(abs_path, rel_path)`` to *zf*, WAL-safe for ``*.db``; return bytes archived.
``on_db_failure(rel_path)`` runs when a SQLite snapshot fails (may raise to abort);
``on_error(rel_path, exc)`` records a read failure; ``on_progress(i)`` fires every 500 files;
``track_bytes`` stats plain files for the size total.
"""
total_bytes = 0
for i, (abs_path, rel_path) in enumerate(files_to_add, 1):
try:
if abs_path.suffix == ".db":
size = _zip_sqlite_snapshot(zf, abs_path, rel_path, out_path)
if size is None:
on_db_failure(rel_path)
continue
total_bytes += size
else:
zf.write(abs_path, arcname=str(rel_path))
if track_bytes:
total_bytes += abs_path.stat().st_size
except (PermissionError, OSError, ValueError) as exc:
on_error(rel_path, exc)
continue
if i % 500 == 0:
on_progress(i)
return total_bytes
def _print_capped(header: str, lines: List[str], indent: str) -> None:
"""Print *header*, then at most 10 of *lines* (each prefixed by *indent*) and a "... and N more" tail."""
print(header)
for line in lines[:10]:
print(f"{indent}{line}")
if len(lines) > 10:
print(f"{indent}... and {len(lines) - 10} more")
# --- Backup ---
_RUN_BACKUP_PREFIX = "hermes-backup-"
def _resolve_backup_output_path(output: Optional[str]) -> Path:
"""Turn ``--output`` (file, directory, or None) into a ``.zip`` path whose parent exists;
an unwritable path exits with a one-line error, not a traceback."""
out_path = None
default_name = f"{_RUN_BACKUP_PREFIX}{datetime.now().strftime('%Y-%m-%d-%H%M%S')}.zip"
try:
if output:
out_path = Path(output).expanduser().resolve()
if out_path.is_dir():
out_path = out_path / default_name
else:
out_path = Path.home() / default_name
if out_path.suffix.lower() != ".zip":
out_path = out_path.with_suffix(out_path.suffix + ".zip")
out_path.parent.mkdir(parents=True, exist_ok=True)
except OSError as exc:
print(f"Error: cannot write backup to {output or out_path}: {exc}")
raise SystemExit(1) from exc
return out_path
def _collect_external_entries() -> tuple[list[tuple[Path, str]], list[str]]:
"""``([(abs_path, arcname)], [skipped])`` for the memory provider's external state, arc-named
``_external/<home-relative>``; paths outside home are skipped (security + portability)."""
home_dir = Path.home().resolve()
external_to_add: list[tuple[Path, str]] = []
skipped_external: list[str] = []
for base in _collect_memory_provider_external_paths():
try:
base.resolve().relative_to(home_dir)
except (ValueError, OSError):
skipped_external.append(str(base))
continue
for fpath in _iter_external_files(base):
with suppress(ValueError, OSError):
rel_to_home = fpath.resolve().relative_to(home_dir)
external_to_add.append((fpath, _EXTERNAL_PREFIX + rel_to_home.as_posix()))
return external_to_add, skipped_external
def run_backup(args) -> None:
"""Create a zip backup of the Hermes home directory."""
hermes_root = get_default_hermes_root()
if not hermes_root.is_dir():
print(f"Error: Hermes home directory not found at {hermes_root}")
sys.exit(1)
try:
with _backup_operation_lock(hermes_root):
_run_backup_locked(args, hermes_root)
except BackupInProgressError as exc:
print(f"Error: {exc}")
raise SystemExit(2) from exc
def _run_backup_locked(args, hermes_root: Path) -> None:
"""Write a full backup while the cross-process backup slot is held."""
out_path = _resolve_backup_output_path(args.output)
scan_started = time.monotonic()
logger.info("backup phase=scan status=started")
print(f"Scanning {display_hermes_home()} ...")
skipped_dirs: set = set()
files_to_add: list[tuple[Path, Path]] = list(_iter_backup_files(hermes_root, out_path, skipped_dirs))
external_to_add, skipped_external = _collect_external_entries()
if not files_to_add and not external_to_add:
logger.info("backup phase=scan status=empty duration_ms=%.1f", (time.monotonic() - scan_started) * 1000)
print("No files to back up.")
return
file_count = len(files_to_add) + len(external_to_add)
logger.info("backup phase=scan status=complete duration_ms=%.1f files=%d",
(time.monotonic() - scan_started) * 1000, file_count)
logger.info("backup phase=archive status=started files=%d", file_count)
print(f"Backing up {file_count} files ...")
errors = []
t0 = time.monotonic()
def _progress(i: int) -> None:
print(f" {i}/{file_count} files ...")
logger.info("backup phase=archive status=progress completed=%d total=%d", i, file_count)
with _atomic_output_path(out_path) as archive_path, zipfile.ZipFile(
archive_path, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as zf:
total_bytes = _write_zip_entries(
zf, files_to_add, out_path, on_progress=_progress, track_bytes=True,
on_db_failure=lambda rel: errors.append(f"{rel}: SQLite safe copy failed"),
on_error=lambda rel, exc: errors.append(f"{rel}: {exc}"))
# External memory-provider state never includes ``.db`` files in practice, so a
# straight zf.write is fine.
for abs_path, arcname in external_to_add:
try:
zf.write(abs_path, arcname=arcname)
total_bytes += abs_path.stat().st_size
except (PermissionError, OSError, ValueError) as exc:
errors.append(f"{arcname}: {exc}")
elapsed = time.monotonic() - t0
zip_size = out_path.stat().st_size
logger.info("backup phase=archive status=complete duration_ms=%.1f files=%d errors=%d bytes=%d",
elapsed * 1000, file_count, len(errors), zip_size)
print(f"\nBackup {'incomplete' if errors else 'complete'}: {out_path}\n"
f" Files: {file_count}\n"
f" Original: {_format_size(total_bytes)}\n"
f" Compressed: {_format_size(zip_size)}\n"
f" Time: {elapsed:.1f}s")
if external_to_add:
print(f"\n Included {len(external_to_add)} memory-provider file(s) stored outside {display_hermes_home()}.")
if skipped_external:
print(f"\n Skipped {len(skipped_external)} memory-provider path(s) outside your home directory "
"(not portable):\n" + "\n".join(f" {p}" for p in sorted(skipped_external)[:10]))
if skipped_dirs:
print("\n Excluded directories:\n" + "\n".join(f" {d}/" for d in sorted(skipped_dirs)))
if errors:
_print_capped(f"\n Warnings ({len(errors)} files skipped):", errors, " ")
else:
print(f"\nRestore with: hermes import {out_path.name}")
keep = getattr(args, "keep", 0) # 0 / absent: never prune (non-CLI callers)
if keep and out_path.name.startswith(_RUN_BACKUP_PREFIX):
pruned = _prune_prefixed_zips(out_path.parent, _RUN_BACKUP_PREFIX, keep, "backup")
if pruned:
print(f" Pruned {pruned} older {_RUN_BACKUP_PREFIX}*.zip (keeping {keep}).")
# --- Import ---
def _validate_backup_zip(zf: zipfile.ZipFile) -> tuple[bool, str]:
"""Check that a zip looks like a Hermes backup."""
names = zf.namelist()
if not names:
return False, "zip archive is empty"
# Telltale files a hermes home has β at the root or one level deep (zipped directory).
if not any(Path(n).name in {"config.yaml", ".env", "state.db"} for n in names):
return False, "zip does not appear to be a Hermes backup (no config.yaml, .env, or state databases found)"
return True, ""
def _detect_prefix(zf: zipfile.ZipFile) -> str:
"""Detect if the zip has a common directory prefix wrapping all entries."""
names = [n for n in zf.namelist() if not n.endswith("/")]
first_parts = {Path(n).parts[0] for n in names if len(Path(n).parts) > 1}
if len(first_parts) == 1 and first_parts <= {".hermes", "hermes"}:
return first_parts.pop() + "/"
return ""
def _extract_member_atomically(
zf: zipfile.ZipFile, member: str, target: Path, new_file_mode: Optional[int] = None) -> None:
"""Restore one zip member onto *target* with no truncation window.
``open(target, "wb")`` would truncate the user's file before any replacement bytes exist.
``atomic_replace`` (not bare ``os.replace``) resolves a symlinked target first, so a
dotfiles-linked ``config.yaml`` keeps the link (#16743), and falls back to copy/fsync/unlink
on ``EXDEV``/``EBUSY`` for cross-device and bind-mount installs.
"""
# Mode is None when the target does not exist: the umask-derived create-mode applies.
mode = _preserve_file_mode(target)
owner = _preserve_file_owner(target)
if mode is None:
mode = new_file_mode
else:
# Deliberately NOT a faithful copy: setuid/setgid are dropped. The bytes come from the
# archive, so carrying elevated bits would hand the zip's author the identity an existing
# setuid file runs as β and ``_external/`` publishes members anywhere under ``$HOME``.
mode &= ~(stat.S_ISUID | stat.S_ISGID)
# Truncate the stem: mkstemp adds ~16 chars and a member near NAME_MAX would otherwise fail.
fd, tmp_name = tempfile.mkstemp(dir=str(target.parent), prefix=f".{target.name[:80]}.", suffix=".partial")
try:
with os.fdopen(fd, "wb") as dst:
if mode is not None:
# Apply the mode BEFORE the replace so the target never transits through mkstemp's
# 0600 and the EXDEV/EBUSY ``copystat`` fallback copies the intended bits.
if hasattr(os, "fchmod"): # Unix-only; Windows takes the path-based chmod
os.fchmod(dst.fileno(), mode)
else:
os.chmod(tmp_name, mode)
# Stream: a multi-gigabyte state.db member must not be held in memory in one piece.
with zf.open(member) as src:
shutil.copyfileobj(src, dst)
dst.flush()
os.fsync(dst.fileno())
real_path = Path(atomic_replace(tmp_name, target))
# Owner first, mode second (as ``atomic_yaml_write``): chown drops setuid/setgid and
# ``mode`` no longer carries them, so neither step can re-elevate the restored file.
_restore_file_owner(real_path, owner)
_restore_file_mode(real_path, mode)
except BaseException:
with suppress(OSError):
os.unlink(tmp_name)
raise
def _count_session_rows(path: Path) -> Optional[Tuple[int, int]]:
"""``(sessions, messages)`` in session database *path*; read-only, best effort.
``None`` means "unknown" (missing, not a Hermes session store, unreadable) β never "zero":
acting on an unreadable database would mask the very loss this count exists to surface.
Same contract as :func:`_count_cron_jobs`.
"""
if not path.is_file():
return None
try:
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
except sqlite3.Error:
return None
try:
sessions = conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0]
messages = conn.execute("SELECT COUNT(*) FROM messages").fetchone()[0]
return int(sessions), int(messages)
except (sqlite3.Error, TypeError, ValueError):
return None
finally:
conn.close()
def _import_db_member(
zf: zipfile.ZipFile, member: str, target: Path, new_file_mode: Optional[int] = None) -> None:
"""Publish a SQLite ``.db`` member onto *target* without replacing its inode.
A rename-publish over a live database is the #65942 / #90950 corruption class: a gateway,
dashboard, or WebUI holding it open keeps serving the unlinked inode and writing sessions no
other process will see, and a sidecar WAL beside the new file describes the old database β
nothing fails, the sessions are simply gone (#100960). Route the member through the same
``_safe_restore_db`` page copy ``/snapshot restore`` uses, so the live inode is preserved and
every open connection converges. A target that does not exist yet has no holders, so it takes
the ordinary atomic publish. Raises ``OSError`` when the database could not be replaced
safely, so the caller reports a skipped file instead of a silent success.
"""
if not target.exists():
_extract_member_atomically(zf, member, target, new_file_mode)
return
# The database keeps its own mode/ownership: the bytes come from the archive, the file does not.
mode = _preserve_file_mode(target)
owner = _preserve_file_owner(target)
fd, tmp_name = tempfile.mkstemp(dir=str(target.parent), prefix=f".{target.name[:80]}.", suffix=".dbimport")
try:
with os.fdopen(fd, "wb") as dst:
with zf.open(member) as src: # stream: never hold a multi-GB state.db in memory
shutil.copyfileobj(src, dst)
dst.flush()
os.fsync(dst.fileno())
if not _safe_restore_db(Path(tmp_name), target):
raise OSError(
"live-safe restore refused or failed; the existing database was "
"left untouched. Stop the gateway/dashboard processes holding it "
"open and re-run the import."
)
_restore_file_owner(target, owner)
_restore_file_mode(target, mode)
finally:
with suppress(OSError):
os.unlink(tmp_name)
def _confirm_import_overwrite(hermes_root: Path) -> bool:
"""Prompt before importing over an existing installation; True when import may proceed."""
if not any((hermes_root / m).exists() for m in ("config.yaml", ".env")):
return True
print("\nWarning: Target directory already has Hermes configuration.\n"
"Importing will overwrite existing files with backup contents.\n")
try:
answer = input("Continue? [y/N] ").strip().lower()
except (EOFError, KeyboardInterrupt):
print("\nAborted.")
sys.exit(1)
if answer in {"y", "yes"}:
return True
print("Aborted.")
return False
def _import_members(
zf: zipfile.ZipFile, members: List[str], prefix: str, hermes_root: Path, file_count: int
) -> tuple[int, int, list[str], list[str], list[tuple[str, tuple[int, int], tuple[int, int]]]]:
"""Publish every member; return ``(restored, restored_external, errors, skipped_runtime, db_shrunk)``.
``db_shrunk`` holds ``(rel, live_counts, imported_counts)`` for every session database the
import replaced with one holding fewer rows β allowed, but never silent (#100960).
"""
errors: list[str] = []
skipped_runtime: list[str] = []
db_shrunk: list[tuple[str, tuple[int, int], tuple[int, int]]] = []
restored = restored_external = 0
home_dir = Path.home().resolve()
new_file_mode = default_new_file_mode() # once: every member is published via mkstemp (0600)
for member in members:
# ``_external/`` members restore to their home-relative location (~/.honcho/config.json),
# NOT under HERMES_HOME; provider configs commonly hold credentials, so tighten to 0600.
external = member.startswith(_EXTERNAL_PREFIX)
if external:
rel = member[len(_EXTERNAL_PREFIX):]
target = home_dir / rel
root = home_dir
tighten = target.suffix in {".json", ".env", ".conf"} or target.name in _SECRET_FILE_NAMES
else:
rel = member[len(prefix):] if prefix and member.startswith(prefix) else member
if rel and Path(rel).name in _IMPORT_SKIP_NAMES: # see ``_IMPORT_SKIP_NAMES``
skipped_runtime.append(rel)
continue
# A ``.db`` member is page-restored into the live file; an archived WAL/SHM/journal
# describes a different database image and installed beside it (over a live sidecar)
# would replay a foreign WAL on next open. Current backups never ship these
# (_EXCLUDED_SUFFIXES); older or hand-built archives might.
if rel.endswith(_SQLITE_SIDECAR_SUFFIXES):
skipped_runtime.append(rel)
continue
target = hermes_root / rel
root = hermes_root.resolve()
tighten = target.name in _SECRET_FILE_NAMES
if not rel:
continue
label = member if external else rel
if not _is_within(target, root):
errors.append(f"{label}: path traversal blocked")
else:
try:
target.parent.mkdir(parents=True, exist_ok=True)
if target.suffix == ".db":
# Count before the write: afterwards the dropped rows are gone.
before = _count_session_rows(target)
_import_db_member(zf, member, target, new_file_mode)
after = _count_session_rows(target)
if before and after and after[1] < before[1]:
db_shrunk.append((rel, before, after))
else:
_extract_member_atomically(zf, member, target, new_file_mode)
if tighten:
try:
os.chmod(target, 0o600)
except OSError:
if not external: # external configs are tightened best-effort only
raise
restored += 1
restored_external += external
except (PermissionError, OSError) as exc:
errors.append(f"{label}: {exc}")
if restored % 500 == 0:
print(f" {restored}/{file_count} files ...")
return restored, restored_external, errors, skipped_runtime, db_shrunk
def run_import(args) -> None:
"""Restore a Hermes backup from a zip file."""
zip_path = Path(args.zipfile).expanduser().resolve()
if not zip_path.is_file():
print(f"Error: File not found: {zip_path}")
sys.exit(1)
if not zipfile.is_zipfile(zip_path):
print(f"Error: Not a valid zip file: {zip_path}")
sys.exit(1)
# The restore target is the home the command operates under (the printed "Target:");
# ``get_default_hermes_root()`` would silently retarget a profile restore at the live root.
hermes_root = get_hermes_home()
with zipfile.ZipFile(zip_path, "r") as zf:
ok, reason = _validate_backup_zip(zf)
if not ok:
print(f"Error: {reason}")
sys.exit(1)
prefix = _detect_prefix(zf)
members = [n for n in zf.namelist() if not n.endswith("/")]
file_count = len(members)
print(f"Backup contains {file_count} files\nTarget: {display_hermes_home()}")
if prefix:
print(f"Detected archive prefix: {prefix!r} (will be stripped)")
if not args.force and not _confirm_import_overwrite(hermes_root):
return
print(f"\nImporting {file_count} files ...")
hermes_root.mkdir(parents=True, exist_ok=True)
t0 = time.monotonic()
restored, restored_external, errors, skipped_runtime, db_shrunk = _import_members(
zf, members, prefix, hermes_root, file_count)
elapsed = time.monotonic() - t0
print(f"\nImport complete: {restored} files restored in {elapsed:.1f}s\n Target: {display_hermes_home()}")
if restored_external:
print(f"\n Restored {restored_external} memory-provider file(s) to "
f"their original location(s) outside {display_hermes_home()}.")
if errors:
_print_capped(f"\n Warnings ({len(errors)} files skipped):", errors, " ")
if db_shrunk:
# The backup predates work that is now overwritten β say so (#100960: twelve sessions
# disappeared with nothing logged anywhere).
print("\n β Session data replaced by older backup contents:")
for rel, before, after in db_shrunk:
print(f" {rel}: {before[0]} session(s) / {before[1]} message(s)"
f" -> {after[0]} / {after[1]}")
print(" Anything recorded after the backup was taken is not in it. "
"Recover from a newer backup or snapshot: hermes snapshot list")
if skipped_runtime:
_print_capped(f"\n Preserved {len(skipped_runtime)} runtime state "
f"file(s) (kept this machine's, not the backup's):",
sorted(skipped_runtime), " ")
restored_profiles = _restore_profile_wrappers(hermes_root)
print()
if not (hermes_root / "hermes-agent").is_dir():
print("Note: The hermes-agent codebase was not included in the backup.\n"
" If this is a fresh install, run: hermes update")
if restored_profiles:
print("\nTo re-enable gateway services for profiles:")
for pname in restored_profiles:
print(f" hermes -p {pname} gateway install")
_revive_gateway_after_import(hermes_root)
print("Done. Your Hermes configuration has been restored.")
def _restore_profile_wrappers(hermes_root: Path) -> List[str]:
"""Re-create shell wrapper scripts for restored named profiles; return the profile names seen."""
profiles_dir = hermes_root / "profiles"
restored_profiles: list[tuple[str, bool]] = []
if not profiles_dir.is_dir():
return []
try:
from hermes_cli.profiles import (
create_wrapper_script, check_alias_collision, _is_wrapper_dir_in_path, _get_wrapper_dir)
for entry in sorted(profiles_dir.iterdir()):
if not entry.is_dir() or not any((entry / m).exists() for m in ("config.yaml", ".env")):
continue # only profiles with config get wrappers
profile_name = entry.name
collision = check_alias_collision(profile_name)
if collision:
print(f" Skipped alias '{profile_name}': {collision}")
restored_profiles.append(
(profile_name, not collision and create_wrapper_script(profile_name) is not None))
if restored_profiles:
created = [n for n, ok in restored_profiles if ok]
skipped = [n for n, ok in restored_profiles if not ok]
if created:
print(f"\n Profile aliases restored: {', '.join(created)}")
if skipped:
print(f" Profile aliases skipped: {', '.join(skipped)}")
if not _is_wrapper_dir_in_path():
print(f"\n Note: {_get_wrapper_dir()} is not in your PATH.\n"
" Add to your shell config (~/.bashrc or ~/.zshrc):\n"
' export PATH="$HOME/.local/bin:$PATH"')
except ImportError: # hermes_cli.profiles unavailable (fresh install)
if any(profiles_dir.iterdir()):
print("\n Profiles detected but aliases could not be created.\n"
" Run: hermes profile list (after installing hermes)")
return [n for n, _ in restored_profiles]
def _revive_gateway_after_import(hermes_root: Path) -> None:
"""Install/start the gateway service after a restore, best-effort and prompt-free.
Bot tokens and cron jobs are inert without a gateway (a platform-less gateway is supported, so
this is safe for any backup); failures print a manual fallback, never fail the import. Only
revived when the restore landed in the default home or no other install exists: a sandbox or
profile restore must not install a second gateway on the default service name.
"""
native_default = _get_platform_default_hermes_home()
if hermes_root != native_default and any(
(native_default / marker).exists() for marker in ("config.yaml", ".env", "state.db")):
print("\nRestored into a non-default home; leaving the gateway service alone to avoid clashing "
f"with the install at {native_default}.\n"
"To start a gateway for this home, run: hermes gateway install")
return
try:
from hermes_cli.gateway import ensure_gateway_service, _is_service_running
if not _is_service_running():
print()
ensure_gateway_service(context="import")
except Exception:
print("\nStart the gateway to activate cron jobs and messaging:\n hermes gateway install")
# --- Quick state snapshots (used by /snapshot slash command and hermes backup --quick) ---
# Critical state files (relative to HERMES_HOME) for quick snapshots; everything else is
# regeneratable or managed separately (skills, repo, sessions/). Entries may be files OR
# directories (recursive); missing entries are skipped. Pairing data lives in platform JSON blobs
# outside state.db, so it is listed explicitly β ``hermes update`` snapshots this set (#15733).
_QUICK_STATE_FILES = (
"state.db", "config.yaml", ".env", "auth.json", "cron/jobs.json", "cron/executions.db",
"gateway_state.json", "channel_directory.json", "channel_aliases.json", "processes.json",
"gateway/discord_message_recovery.db", # Discord reconnect replay ledger
# Per-profile user stores, destroyed if the update flow replaces the file and the post-update
# schema-init re-creates an empty one (#52889). Skipped when outside HERMES_HOME.
"projects.db", # per-profile project store
"response_store.db", # gateway conversation history / tool payloads
"memory_store.db", # holographic memory facts/entities
"verification_evidence.db", # agent verification audit trail
"kanban.db", # default board (back-compat <root>/kanban.db)
"kanban/boards", # non-default boards (workspaces/ + attachments/ skipped as regenerable)
# Pairing stores (generic + per-platform JSONs outside state.db)
"pairing", # legacy location (gateway/pairing.py)
"platforms/pairing", # new location (gateway/pairing.py)
"feishu_comment_pairing.json", # Feishu comment subscription pairings
)
_QUICK_DEFAULT_KEEP = 20
def _quick_snapshot_root(hermes_home: Optional[Path] = None) -> Path:
home = hermes_home or get_hermes_home()
return home / _QUICK_SNAPSHOTS_DIR
def create_quick_snapshot(
label: Optional[str] = None, hermes_home: Optional[Path] = None, keep: Optional[int] = None,
max_file_size: Optional[int] = None) -> Optional[str]:
"""Create one atomic quick snapshot while holding the shared backup slot."""
home = hermes_home or get_hermes_home()
with _backup_operation_lock(home):
return _create_quick_snapshot_locked(label, home, keep, max_file_size)
def _quick_snapshot_candidates(home: Path):
"""Yield ``(src, rel_posix, in_dir)`` for every regular file a quick snapshot captures; heavy
regenerable per-board subtrees (workspaces, attachments) are skipped."""
for rel in _QUICK_STATE_FILES:
src = home / rel
if src.is_dir():
for sub in filter(Path.is_file, src.rglob("*")):
sub_rel = sub.relative_to(home).as_posix()
if "/workspaces/" in f"/{sub_rel}/" or "/attachments/" in f"/{sub_rel}/":
continue
yield sub, sub_rel, True
elif src.is_file():
yield src, rel, False
def _copy_quick_snapshot_files(
home: Path, staging_dir: Path, max_file_size: Optional[int]
) -> tuple[Dict[str, int], list[str], list[str]]:
"""Copy every quick-snapshot candidate into *staging_dir*.
Returns ``(manifest {rel: size}, failed_dbs, oversized_skipped)``. The last two are snapshot
incompleteness (#68805): the caller must suppress pruning so the older snapshot that may hold
the only recoverable DB survives.
"""
manifest: Dict[str, int] = {}
failed_dbs: list[str] = []
oversized_skipped: list[str] = []
for src, rel, in_dir in _quick_snapshot_candidates(home):
if max_file_size is not None:
try:
size = src.stat().st_size
except OSError:
size = None
if size is not None and size > max_file_size:
print(f" β Snapshot: skipping {rel} "
f"({_format_size(size)} exceeds {_format_size(max_file_size)} limit)")
logger.warning("Quick snapshot skipped %s: %d bytes exceeds %d byte limit", rel, size, max_file_size)
if src.suffix == ".db":
oversized_skipped.append(rel)
continue
dst = staging_dir / rel
dst.parent.mkdir(parents=True, exist_ok=True)
try:
# SQLite DBs go through the WAL-safe backup() path (the gateway may hold the WAL open).
if src.suffix == ".db":
if not _safe_copy_db(src, dst):
failed_dbs.append(rel)
print(f" β Snapshot: SQLite safe copy FAILED for {rel} β file may be locked or corrupted")
if is_zeroed_sqlite_file(src):
nuls = " of NULs?" if in_dir else ""
print(f" β Snapshot: {rel} looks ZEROED "
f"(no SQLite header; {src.stat().st_size} bytes{nuls})")
continue
else:
shutil.copy2(src, dst)
manifest[rel] = dst.stat().st_size
except (OSError, PermissionError) as exc:
logger.warning("Could not snapshot %s: %s", rel, exc)
return manifest, failed_dbs, oversized_skipped
def _secure_quick_snapshot_tree(root: Path, snapshot_dir: Path) -> None:
"""Make a staged quick snapshot owner-only before it is published.
The staging directory is private from creation, so copied source modes can
be normalized safely before the final atomic rename exposes the snapshot.
Permission failures are intentionally fatal: publishing a readable
recovery bundle is worse than reporting a failed snapshot.
"""
if os.name == "nt":
return
os.chmod(root, 0o700)
os.chmod(snapshot_dir, 0o700)
for path in snapshot_dir.rglob("*"):
if path.is_dir():
os.chmod(path, 0o700)
elif path.is_file():
os.chmod(path, 0o600)
def _create_quick_snapshot_locked(
label: Optional[str], home: Path, keep: Optional[int], max_file_size: Optional[int]
) -> Optional[str]:
"""Copy the quick-snapshot set to a timestamped dir under state-snapshots/ and prune old ones.
``max_file_size`` skips (with a warning) larger files: the pre-update snapshot uses it so a
multi-GB ``state.db`` never stalls ``hermes update`` while the small files are always captured.
"""
root = _quick_snapshot_root(home)
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
base_snap_id = f"{ts}-{label}" if label else ts
snap_id, suffix = base_snap_id, 2
while (root / snap_id).exists():
snap_id = f"{base_snap_id}-{suffix}"
suffix += 1
staging_dir = root / f".{snap_id}.{os.getpid()}.partial"
shutil.rmtree(staging_dir, ignore_errors=True)
root.mkdir(parents=True, exist_ok=True, mode=0o700)
if os.name != "nt":
os.chmod(root, 0o700)
staging_dir.mkdir(mode=0o700, exist_ok=False)
logger.info("quick snapshot phase=copy status=started id=%s", snap_id)
manifest, failed_dbs, oversized_skipped = _copy_quick_snapshot_files(home, staging_dir, max_file_size)
if failed_dbs:
# Surface on stdout: a log-and-continue made a missing state.db backup look like a
# successful pre-update snapshot (#68474).
print(f" β CRITICAL: could not snapshot DB file(s): {', '.join(failed_dbs)}\n"
f" β If sessions disappear after update, check {root} and run: hermes snapshot list")
logger.error("Quick snapshot failed to capture DB file(s): %s", ", ".join(failed_dbs))
if not manifest:
shutil.rmtree(staging_dir, ignore_errors=True)
if failed_dbs:
# Distinguish "nothing to snapshot" from "state.db present but unreadable"
print(f" β Snapshot aborted: no files captured (failed DBs: {', '.join(failed_dbs)})")
return None
meta = {
"id": snap_id, "timestamp": ts, "label": label, "file_count": len(manifest),
"total_size": sum(manifest.values()), "files": manifest,
"failed_dbs": failed_dbs, "oversized_skipped": oversized_skipped,
}
with open(staging_dir / "manifest.json", "w", encoding="utf-8") as f:
json.dump(meta, f, indent=2)
_secure_quick_snapshot_tree(root, staging_dir)
os.replace(staging_dir, root / snap_id)
# Auto-prune (pre-update callers pass a smaller keep so state.db copies don't accumulate).
# Skip when a DB failed to capture OR was skipped for size (#68805): the snapshot is
# incomplete and the older one may hold the only recoverable database.
if not (failed_dbs or oversized_skipped):
_prune_oldest(_snapshot_dirs(root), _QUICK_DEFAULT_KEEP if keep is None else keep, shutil.rmtree, "snapshot")
else:
if oversized_skipped:
print(" β Skipping snapshot prune: DB file(s) skipped for size: " + ", ".join(oversized_skipped))
logger.warning("Quick snapshot skipped oversized DB file(s): %s", ", ".join(oversized_skipped))
logger.warning(
"Skipping snapshot prune because %d DB(s) failed to capture and/or %d were oversized "
"β preserving older snapshots as recovery source",
len(failed_dbs), len(oversized_skipped))
logger.info("quick snapshot phase=copy status=complete id=%s files=%d bytes=%d",
snap_id, len(manifest), sum(manifest.values()))
return snap_id
def _newest_first(root: Path, keep_entry) -> List[Path]:
"""Entries of *root* passing ``keep_entry``, newest (by name) first; ``[]`` if *root* is missing."""
if not root.exists():
return []
return sorted(filter(keep_entry, root.iterdir()), key=lambda p: p.name, reverse=True)
def _snapshot_dirs(root: Path) -> List[Path]:
"""Published snapshot directories under *root*, newest first."""
return _newest_first(root, lambda d: d.is_dir() and not d.name.startswith(".")
and not d.name.endswith(".partial"))
def list_quick_snapshots(limit: int = 20, hermes_home: Optional[Path] = None) -> List[Dict[str, Any]]:
"""List existing quick state snapshots, most recent first."""
results = []
for d in _snapshot_dirs(_quick_snapshot_root(hermes_home)):
manifest_path = d / "manifest.json"
if manifest_path.exists():
try:
results.append(json.loads(manifest_path.read_text(encoding="utf-8")))
except (json.JSONDecodeError, OSError):
results.append({"id": d.name, "file_count": 0, "total_size": 0})
if len(results) >= limit:
break
return results
def restore_quick_snapshot(snapshot_id: str, hermes_home: Optional[Path] = None) -> bool:
"""Restore state from a quick snapshot."""
home = hermes_home or get_hermes_home()
root = _quick_snapshot_root(home)
# Reject ids with separators or traversal so ``root / snapshot_id`` stays inside root.
if not snapshot_id or "/" in snapshot_id or "\\" in snapshot_id or snapshot_id in (".", ".."):
logger.error("Invalid snapshot_id: %s", snapshot_id)
return False
snap_dir = root / snapshot_id
if not _is_within(snap_dir, root.resolve()): # handles symlinks etc.
logger.error("Snapshot path traversal blocked for id: %s", snapshot_id)
return False
manifest_path = snap_dir / "manifest.json"
if not snap_dir.is_dir() or not manifest_path.exists():
return False
with open(manifest_path, encoding="utf-8") as f:
meta = json.load(f)
snap_res, home_res = snap_dir.resolve(), home.resolve()
restored = 0
for rel in meta.get("files", {}):
src = snap_dir / rel
dst = home / rel
if not (_is_within(src, snap_res) and _is_within(dst, home_res)):
logger.error("Manifest path traversal blocked: %s", rel)
continue
if not src.exists():
continue
dst.parent.mkdir(parents=True, exist_ok=True)
try:
if dst.suffix == ".db":
# Through the backup API so live connections see the restored data instead of
# stale pages from a replaced inode (#65942).
if not _safe_restore_db(src, dst):
# Refused (live holder) or failed: destination untouched β a failure, not a restore.
logger.error("Failed to restore %s: live-safe restore refused", rel)
continue
else:
shutil.copy2(src, dst)
restored += 1
except (OSError, PermissionError) as exc:
logger.error("Failed to restore %s: %s", rel, exc)
logger.info("Restored %d files from snapshot %s", restored, snapshot_id)
return restored > 0
# Kept in sync with ``_QUICK_STATE_FILES`` and ``cron/jobs.py``'s ``JOBS_FILE``.
_CRON_JOBS_REL = "cron/jobs.json"
def _count_cron_jobs(path: Path) -> Optional[int]:
"""Number of cron jobs in ``path`` (canonical ``{"jobs": [...]}`` or legacy bare list).
``None`` if missing or unparseable β "unknown", not zero: acting on an unreadable file could
mask a real corruption the user needs to see.
"""
if not path.is_file():
return None
try:
# utf-8-sig as cron/jobs.load_jobs: a Windows-editor BOM would otherwise read as
# "unreadable" and silently disable the post-update auto-restore safety net.
with open(path, "r", encoding="utf-8-sig") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
return None
if isinstance(data, dict):
data = data.get("jobs", [])
return len(data) if isinstance(data, list) else None
def restore_cron_jobs_if_emptied(snapshot_id: str, hermes_home: Optional[Path] = None) -> Optional[Dict[str, Any]]:
"""Safety net for silent cron-job loss across ``hermes update``.
Conservative: restores only when the snapshot had MORE jobs than the live file (a user who
deleted jobs is never second-guessed); an unreadable live file is left so corruption surfaces.
Config-version migrations have been observed to leave ``cron/jobs.json`` valid-but-empty after an
update, silently dropping every scheduled job (issue #34600). The desktop scheduler can also overwrite
the file with its own small set of internally-tracked crons, causing partial loss (issue 52144).
"""
if not snapshot_id:
return None
home = hermes_home or get_hermes_home()
live_path = home / _CRON_JOBS_REL
live_count = _count_cron_jobs(live_path)
if live_count is None:
return None
snap_path = _quick_snapshot_root(home) / snapshot_id / _CRON_JOBS_REL
snap_count = _count_cron_jobs(snap_path)
# Fewer live jobs than the snapshot catches both total loss (0 vs N) and partial loss
# (1 vs 19) β the desktop scheduler can overwrite jobs.json with its own small set.
if not snap_count or live_count >= snap_count: # None or 0 β nothing worth restoring
return None
try:
live_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(snap_path, live_path)
except (OSError, PermissionError) as exc:
logger.error("Cron jobs were emptied during update but auto-restore failed: %s", exc)
return None
logger.warning(
"Restored %d cron job(s) from pre-update snapshot %s "
"(live file had %d job(s), snapshot had %d β jobs were lost during migration)",
snap_count, snapshot_id, live_count, snap_count)
return {"restored": True, "job_count": snap_count, "snapshot_id": snapshot_id}
def _sibling_profile_homes(invoking_home: Path) -> list[tuple[str, Path]]:
"""(name, home) for every OTHER profile on this install (the invoking one is snapshotted
separately). The update's code swap touches every profile, so its snapshot must too (#66140).
Never raises."""
homes: list[tuple[str, Path]] = []
try:
from hermes_cli.profiles import _get_default_hermes_home, _get_profiles_root, _PROFILE_ID_RE
invoking = invoking_home.resolve()
default_home = _get_default_hermes_home()
if default_home.is_dir() and default_home.resolve() != invoking:
homes.append(("default", default_home))
root = _get_profiles_root()
if root.is_dir():
for entry in sorted(root.iterdir()):
if (entry.is_dir() and entry.name != "default" and _PROFILE_ID_RE.match(entry.name)
and entry.resolve() != invoking):
homes.append((entry.name, entry))
except Exception as exc:
logger.debug("Sibling profile enumeration failed: %s", exc)
return homes
def create_pre_update_snapshots_all_profiles(
invoking_home: Optional[Path] = None, keep: Optional[int] = None, max_file_size: Optional[int] = None
) -> Dict[str, str]:
"""Pre-update quick snapshots for every SIBLING profile (#66140), same set/size cap/keep policy
as the invoking profile's; each lands under its OWN ``<home>/state-snapshots/``."""
results: Dict[str, str] = {}
home = invoking_home or get_hermes_home()
for name, profile_home in _sibling_profile_homes(home):
try:
snap_id = create_quick_snapshot(
label="pre-update", hermes_home=profile_home, keep=keep, max_file_size=max_file_size)
if snap_id:
results[name] = snap_id
except Exception as exc:
logger.debug("Pre-update snapshot for profile %s failed: %s", name, exc)
return results
# Config paths the update flow must never change (#64160): model routing and the MoA section are
# consumed machine-wide, so an update/repair cycle that rewrites them silently redirects paid
# inference. Dotted paths into raw config.yaml; a single-element tuple protects a whole section.
_PROTECTED_CONFIG_PATHS: Tuple[Tuple[str, ...], ...] = (
("model", "provider"), ("model", "default"), ("model", "base_url"), ("model", "api_key"),
("moa",))
def _read_raw_yaml_dict(path: Path) -> Optional[Dict[str, Any]]:
"""Parse ``path`` as a YAML mapping. ``None`` = missing/unreadable/non-dict."""
if not path.is_file():
return None
try:
import yaml
data = yaml.safe_load(path.read_text(encoding="utf-8"))
except Exception:
return None
return data if isinstance(data, dict) else None
def _get_config_path_value(data: Dict[str, Any], dotted: Tuple[str, ...]) -> Any:
node: Any = data
for key in dotted:
if not isinstance(node, dict):
return None
node = node.get(key)
return node
def _set_config_path_value(data: Dict[str, Any], dotted: Tuple[str, ...], value: Any) -> None:
node = data
for key in dotted[:-1]:
if not isinstance(node.get(key), dict):
node[key] = {}
node = node[key]
node[dotted[-1]] = value
def restore_config_model_settings_if_rewritten(
snapshot_id: str, hermes_home: Optional[Path] = None) -> Optional[Dict[str, Any]]:
"""Safety net for silent config.yaml model/MoA loss across ``hermes update``.
Mirrors :func:`restore_cron_jobs_if_emptied`: restore only the protected keys β never the
whole file β whose user-set value in the same-run pre-update snapshot changed or vanished.
"""
if not snapshot_id:
return None
home = hermes_home or get_hermes_home()
live_path = home / "config.yaml"
snap = _read_raw_yaml_dict(_quick_snapshot_root(home) / snapshot_id / "config.yaml")
if not snap:
return None # no snapshot copy β nothing to compare against
live = _read_raw_yaml_dict(live_path)
if live is None:
# Missing/unparseable live config is a failure the user should see (matches the cron net).
return None
restored_keys: list[str] = []
for dotted in _PROTECTED_CONFIG_PATHS:
snap_val = _get_config_path_value(snap, dotted)
if snap_val in (None, "", {}, []):
continue # user never set it β nothing to protect
if _get_config_path_value(live, dotted) != snap_val:
_set_config_path_value(live, dotted, snap_val)
restored_keys.append(".".join(dotted))
if not restored_keys:
return None
try:
from utils import atomic_yaml_write
atomic_yaml_write(live_path, live)
except (OSError, PermissionError) as exc:
logger.error("config.yaml model settings were rewritten during update but auto-restore failed: %s", exc)
return None
logger.warning(
"Restored user config value(s) %s from pre-update snapshot %s β "
"the update flow rewrote them (#64160)", ", ".join(restored_keys), snapshot_id)
return {"restored": True, "keys": restored_keys, "snapshot_id": snapshot_id}
def _restore_all_sibling_profiles(
profile_snapshots: Dict[str, str], invoking_home: Optional[Path], restore_fn, failure_log: str
) -> list[Dict[str, Any]]:
"""Run ``restore_fn(snap_id, hermes_home=...)`` for every sibling against ITS OWN
same-generation snapshot; one result dict (plus ``profile`` key) per restored profile.
Never raises."""
restored: list[Dict[str, Any]] = []
if not profile_snapshots:
return restored
home = invoking_home or get_hermes_home()
by_name = dict(_sibling_profile_homes(home))
for name, snap_id in profile_snapshots.items():
profile_home = by_name.get(name)
if profile_home is None:
continue
try:
result = restore_fn(snap_id, hermes_home=profile_home)
except Exception as exc:
logger.debug(failure_log, name, exc)
continue
if result:
result["profile"] = name
restored.append(result)
return restored
def restore_config_model_settings_all_profiles(
profile_snapshots: Dict[str, str], invoking_home: Optional[Path] = None) -> list[Dict[str, Any]]:
"""Run the config model-settings safety net for every sibling profile (see ``_restore_all_sibling_profiles``)."""
return _restore_all_sibling_profiles(
profile_snapshots, invoking_home, restore_config_model_settings_if_rewritten,
"Config model-settings restore check for profile %s failed: %s")
def restore_cron_jobs_all_profiles(
profile_snapshots: Dict[str, str], invoking_home: Optional[Path] = None) -> list[Dict[str, Any]]:
"""Run the cron-jobs safety net for every sibling profile (#66140); ``profile_snapshots`` comes
from :func:`create_pre_update_snapshots_all_profiles`, so restores are same-generation."""
return _restore_all_sibling_profiles(
profile_snapshots, invoking_home, restore_cron_jobs_if_emptied,
"Cron restore check for profile %s failed: %s")
def _prune_oldest(newest_first: List[Path], keep: int, remove, what: str) -> int:
"""``remove(path)`` every entry past the first *keep*; return how many succeeded."""
deleted = 0
for p in newest_first[keep:]:
try:
remove(p)
deleted += 1
except OSError as exc:
logger.warning("Failed to prune %s %s: %s", what, p.name, exc)
return deleted
def prune_quick_snapshots(keep: int = _QUICK_DEFAULT_KEEP, hermes_home: Optional[Path] = None) -> int:
"""Remove oldest quick snapshots beyond the keep limit. Returns count deleted."""
return _prune_oldest(_snapshot_dirs(_quick_snapshot_root(hermes_home)), keep, shutil.rmtree, "snapshot")
def run_quick_backup(args) -> None:
"""CLI entry point for hermes backup --quick."""
snap_id = create_quick_snapshot(label=getattr(args, "label", None))
if snap_id:
print(f"State snapshot created: {snap_id}\n"
f" {len(list_quick_snapshots())} snapshot(s) stored in {display_hermes_home()}/state-snapshots/\n"
f" Restore with: /snapshot restore {snap_id}")
else:
print("No state files found to snapshot.")
# --- Shared full-zip backup helper ---
def _write_full_zip_backup(out_path: Path, hermes_root: Path) -> Optional[Path]:
"""Full zip snapshot of ``hermes_root`` to ``out_path`` under the backup slot (same rules as
:func:`run_backup`); None when nothing to back up, another backup running, or write error."""
try:
with _backup_operation_lock(hermes_root):
return _write_full_zip_backup_locked(out_path, hermes_root)
except BackupInProgressError as exc:
logger.warning("Full-zip backup skipped: %s", exc)
return None
def _write_full_zip_backup_locked(out_path: Path, hermes_root: Path) -> Optional[Path]:
scan_started = time.monotonic()
logger.info("automatic backup phase=scan status=started")
try:
files_to_add = list(_iter_backup_files(hermes_root, out_path))
except OSError as exc:
logger.warning("Full-zip backup: walk failed: %s", exc)
return None
if not files_to_add:
return None
logger.info("automatic backup phase=scan status=complete duration_ms=%.1f files=%d",
(time.monotonic() - scan_started) * 1000, len(files_to_add))
def _db_failure(rel_path: Path) -> None:
logger.warning("Full-zip backup aborted: SQLite snapshot failed for %s", rel_path)
raise _SQLiteSnapshotError(str(rel_path))
archive_started = time.monotonic()
try:
with _atomic_output_path(out_path) as archive_path, zipfile.ZipFile(
archive_path, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as zf:
_write_zip_entries(
zf, files_to_add, out_path, on_db_failure=_db_failure, track_bytes=False,
on_error=lambda rel, exc: logger.debug("Skipping %s in zip backup: %s", rel, exc),
on_progress=lambda i: logger.info(
"automatic backup phase=archive status=progress completed=%d total=%d", i, len(files_to_add)))
except (OSError, _SQLiteSnapshotError) as exc:
# The hidden partial is already gone; ``out_path`` may be a previous valid backup: keep it.
logger.warning("Full-zip backup: zip write failed: %s", exc)
return None
logger.info("automatic backup phase=archive status=complete duration_ms=%.1f files=%d bytes=%d",
(time.monotonic() - archive_started) * 1000, len(files_to_add),
out_path.stat().st_size)
return out_path
# --- Pre-update / pre-migration auto-backups ---
_PRE_UPDATE_BACKUPS_DIR = "backups"
_PRE_UPDATE_PREFIX = "pre-update-"
_PRE_UPDATE_DEFAULT_KEEP = 5
_PRE_MIGRATION_PREFIX = "pre-migration-"
_PRE_MIGRATION_DEFAULT_KEEP = 5
def _prune_prefixed_zips(backup_dir: Path, prefix: str, keep: int, what: str) -> int:
"""Remove oldest ``<prefix>*.zip`` in *backup_dir* beyond *keep*; return count deleted.
Only prefix-matched files are touched, so hand-made zips or other backup kinds survive.
"""
backups = _newest_first(backup_dir, lambda p: p.is_file() and p.name.startswith(prefix)
and p.suffix.lower() == ".zip")
return _prune_oldest(backups, keep, Path.unlink, what)
def _create_prefixed_full_backup(
hermes_home: Optional[Path], prefix: str, keep: int, what: str, prune_what: str) -> Optional[Path]:
"""Write ``<HERMES_HOME>/backups/<prefix><timestamp>.zip`` and prune older same-prefix zips.
Returns the path, or ``None`` if nothing to back up or the write failed. Never raises."""
hermes_root = hermes_home or get_default_hermes_root()
if not hermes_root.is_dir():
return None
backup_dir = hermes_root / _PRE_UPDATE_BACKUPS_DIR
try:
backup_dir.mkdir(parents=True, exist_ok=True)
except OSError as exc:
logger.warning("Could not create %s backup dir %s: %s", what, backup_dir, exc)
return None
out_path = backup_dir / f"{prefix}{datetime.now().strftime('%Y-%m-%d-%H%M%S')}.zip"
if _write_full_zip_backup(out_path, hermes_root) is None:
return None
_prune_prefixed_zips(backup_dir, prefix, keep, prune_what)
return out_path
def create_pre_update_backup(
hermes_home: Optional[Path] = None, keep: int = _PRE_UPDATE_DEFAULT_KEEP) -> Optional[Path]:
"""Full zip backup to ``backups/pre-update-<timestamp>.zip``, auto-pruned; ``None`` if nothing
was found or the backup failed. Never raises β ``hermes update`` continues anyway."""
return _create_prefixed_full_backup(hermes_home, _PRE_UPDATE_PREFIX, max(keep, 1), "pre-update", "backup")
def create_pre_migration_backup(
hermes_home: Optional[Path] = None, keep: int = _PRE_MIGRATION_DEFAULT_KEEP) -> Optional[Path]:
"""Full zip backup to ``backups/pre-migration-<timestamp>.zip`` before ``hermes claw migrate``
(same dir as update backups so listings/``hermes import`` find it); ``None`` if nothing was
found or the write failed. Never raises."""
return _create_prefixed_full_backup(
hermes_home, _PRE_MIGRATION_PREFIX, max(keep, 0), "pre-migration", "pre-migration backup")
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
def copy_db_and_verify(src: Path, dst: Path) -> bool:
"""Like :func:`_safe_copy_db` but verifies the destination after copy.
Returns True only when the copy succeeded AND the destination is valid
SQLite (header + integrity check). Verification honours the default
size ceiling β a multi-GB destination gets the header + schema probe
rather than a full ``PRAGMA integrity_check`` that would page through
the whole file.
"""
if not _safe_copy_db(src, dst):
return False
integrity = verify_sqlite_integrity(dst, run_pragma=True)
if not integrity.get("valid"):
try:
dst.unlink(missing_ok=True)
except OSError:
pass
logger.warning("Backup of %s failed integrity verification: %s", src, integrity.get("message"))
return False
return True
# ---- END PLUGIN-COMPAT ----
|