Files
hermes-agent/scripts/docker_config_migrate.py
T
Teknium c225308275 fix(config): legacy AGENT_BROWSER_* settings survive homes that skip the v52 step
The runtime reads only browser.* now and the AGENT_BROWSER_* -> browser.* move
lived only in the versioned v52 config step, so three kinds of home lost their
engine, headed mode and persistent profile without a word: a missing
config.yaml (reads as current), an unstamped one (boot skipped it as below the
support floor) and one first stamped by an ordinary save before any migration.

The move is now done once, at the home's first stamp:
- note_first_stamp (every save_config first stamp, and left-core's created
  file) moves the settings along with the left-core rows it already records;
- stamp_missing_config creates config.yaml with that first stamp when a
  missing config's .env still carries them, before the missing-config shortcut
  in boot (post_update.step_migrate_config), migrate_config and the Docker
  entrypoint;
- boot migrates an unstamped config whose .env still carries them through
  migrate_config (legacy-key steps, v52 among them, then the stamp); other
  unstamped configs are left alone as before.

The conversion reuses the v52 step's code (move_agent_browser_env) and only
ever runs while a home is unstamped or missing, so a retained .env never
overrides a later config edit.
2026-10-23 01:43:17 -07:00

112 lines
4.0 KiB
Python

#!/usr/bin/env python3
"""Run Docker boot-time config migrations safely."""
from __future__ import annotations
import shutil
import sys
from pathlib import Path
from typing import Iterable
from hermes_cli.config import (
InvalidUserConfigError,
get_config_path,
get_env_path,
migrate_config,
)
from hermes_cli.config_version_stamp import check_config_version, read_config_version_stamp, stamp_missing_config
from hermes_cli.config_backups import backup_config, list_config_backups
from hermes_cli.config_migrations import (
SUPPORT_FLOOR_VERSION,
support_floor_message,
)
from utils import env_var_enabled
def _backup_existing(paths: Iterable[Path]) -> dict[Path, Path]:
"""Snapshot each file into backups/config/; an identical existing snapshot is reused."""
backups: dict[Path, Path] = {}
for path in paths:
dest = backup_config(path, "pre-docker-migrate") or next(
iter(list_config_backups(path, "pre-docker-migrate")), None)
if dest is not None:
backups[path] = dest
return backups
def _restore_backups(backups: dict[Path, Path]) -> list[Path]:
restored: list[Path] = []
for original, backup in backups.items():
if not backup.is_file():
continue
shutil.copy2(backup, original)
restored.append(original)
return restored
def main() -> int:
if env_var_enabled("HERMES_SKIP_CONFIG_MIGRATION"):
print("[config-migrate] HERMES_SKIP_CONFIG_MIGRATION is set; skipping config migration")
return 0
# Strict read: malformed YAML or a non-mapping root is left alone with a warning and the
# boot continues, instead of running the backup/migrate dance that migrate_config() would
# refuse anyway. A missing config.yaml reads as current, so its legacy browser settings get their
# first stamp before that read.
stamp_missing_config()
try:
stamp, latest_ver = read_config_version_stamp(raise_on_parse_error=True)
except InvalidUserConfigError as exc:
print(f"[config-migrate] WARNING: {exc}; leaving config.yaml untouched", file=sys.stderr)
return 0
current_ver = 0 if stamp is None else stamp
if current_ver >= latest_ver:
return 0
# Below the auto-migration support floor: migrate_config() refuses (and
# leaves the file untouched), so don't run the backup/verify dance that
# would raise "did not advance config version" and block the boot.
# Warn-and-continue matches the CLI's fail-safe posture. A config with no
# _config_version (stamp None: a volume seeded from the template) is not
# below the floor: migrate_config() stamps it.
if stamp is not None and current_ver < SUPPORT_FLOOR_VERSION:
print(
f"[config-migrate] WARNING: {support_floor_message()}",
file=sys.stderr,
)
return 0
backups = _backup_existing((get_config_path(), get_env_path()))
backup_text = ", ".join(str(path) for path in backups.values()) if backups else "none"
print(
f"[config-migrate] Migrating config schema {current_ver} -> {latest_ver}; "
f"backups: {backup_text}"
)
try:
migrate_config(interactive=False, quiet=False)
except Exception:
restored = _restore_backups(backups)
if restored:
print(
"[config-migrate] Migration failed; restored "
+ ", ".join(str(path) for path in restored)
)
raise
post_ver, _ = check_config_version()
if post_ver < latest_ver:
restored = _restore_backups(backups)
restored_text = ", ".join(str(path) for path in restored) if restored else "none"
raise RuntimeError(
f"migration did not advance config version to {latest_ver} "
f"(still {post_ver}); restored: {restored_text}"
)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except Exception as exc:
print(f"[config-migrate] ERROR: {exc}", file=sys.stderr)
raise SystemExit(1)