Sync stale self-update manager at startup

Add a core startup migration that refreshes /exe/self_update_manager.py from the repository copy when the installed runtime updater is missing the socket-safe backup and Desktop cleanup markers. Validate the source updater before replacing anything, preserve the previous runtime script as a backup, and leave current or non-regular targets untouched. Add regression coverage for stale runtime replacement, safe no-op/refusal paths, and the real repository updater source.

Alessandro committed Jun 8, 2026 at 16:07 UTC f9031b75758c8ead686e7c44222ccd1a63f22f2a
3 files changed +267
extensions/python/startup_migration/AGENTS.md
+1
@@ -13,6 +13,7 @@
13 - Migrations must be safe to run repeatedly.
14 - Preserve user data and create backups or reversible paths when changing durable state.
15 - Keep long-running work bounded and observable.
16 +- `_10_self_update_manager.py` may replace `/exe/self_update_manager.py` from the repository copy when the installed runtime updater is stale; it must validate required safety markers and keep a backup before replacement.
17
18 ## Work Guidance
19
extensions/python/startup_migration/_10_self_update_manager.py new
+125
@@ -0,0 +1,125 @@
1 +from __future__ import annotations
2 +
3 +import os
4 +import shutil
5 +import stat
6 +from pathlib import Path
7 +from typing import Any
8 +
9 +from helpers.extension import Extension
10 +from helpers.print_style import PrintStyle
11 +
12 +
13 +SELF_UPDATE_MANAGER_PATH = Path(
14 + os.environ.get("A0_SELF_UPDATE_MANAGER_PATH", "/exe/self_update_manager.py")
15 +)
16 +SELF_UPDATE_MANAGER_SOURCE_PATH = Path(
17 + os.environ.get(
18 + "A0_SELF_UPDATE_MANAGER_SOURCE_PATH",
19 + "/a0/docker/run/fs/exe/self_update_manager.py",
20 + )
21 +)
22 +BACKUP_SUFFIX = ".startup-migration-backup"
23 +REQUIRED_RUNTIME_MARKERS = (
24 + "def should_include_usr_backup_entry(",
25 + "Skipping non-regular usr backup entry",
26 + "def clean_transient_desktop_agent_state(",
27 + "clean_transient_desktop_agent_state(REPO_DIR, logger)",
28 +)
29 +
30 +
31 +class SelfUpdateManagerRuntimeSync(Extension):
32 + def execute(self, **kwargs):
33 + result = ensure_self_update_manager_runtime_current()
34 + if result.get("updated"):
35 + PrintStyle.info("Self-update manager runtime synchronized:", result["target"])
36 + elif result.get("warning"):
37 + PrintStyle.warning("Self-update manager runtime sync skipped:", result["warning"])
38 +
39 +
40 +def ensure_self_update_manager_runtime_current(
41 + *,
42 + target_path: Path | str | None = None,
43 + source_path: Path | str | None = None,
44 +) -> dict[str, Any]:
45 + target = Path(target_path) if target_path is not None else SELF_UPDATE_MANAGER_PATH
46 + source = Path(source_path) if source_path is not None else SELF_UPDATE_MANAGER_SOURCE_PATH
47 +
48 + target_text, target_warning = _read_regular_text(target, role="runtime self-update manager")
49 + if target_text is None:
50 + return {"ok": True, "updated": False, "reason": target_warning}
51 +
52 + source_text, source_warning = _read_regular_text(source, role="source self-update manager")
53 + if source_text is None:
54 + return {"ok": False, "updated": False, "warning": source_warning}
55 +
56 + missing_source_markers = _missing_required_markers(source_text)
57 + if missing_source_markers:
58 + return {
59 + "ok": False,
60 + "updated": False,
61 + "warning": (
62 + "source self-update manager is missing required safety markers: "
63 + + ", ".join(missing_source_markers)
64 + ),
65 + }
66 +
67 + if not _missing_required_markers(target_text):
68 + return {"ok": True, "updated": False, "reason": "already-current"}
69 +
70 + try:
71 + backup = _replace_runtime_manager(target, source_text)
72 + except OSError as exc:
73 + return {
74 + "ok": False,
75 + "updated": False,
76 + "warning": f"could not update {target}: {exc}",
77 + }
78 +
79 + return {
80 + "ok": True,
81 + "updated": True,
82 + "target": str(target),
83 + "backup": str(backup),
84 + }
85 +
86 +
87 +def _missing_required_markers(text: str) -> list[str]:
88 + return [marker for marker in REQUIRED_RUNTIME_MARKERS if marker not in text]
89 +
90 +
91 +def _read_regular_text(path: Path, *, role: str) -> tuple[str | None, str]:
92 + try:
93 + path_stat = path.lstat()
94 + except FileNotFoundError:
95 + return None, f"{role} not found: {path}"
96 + except OSError as exc:
97 + return None, f"{role} could not be inspected: {path}: {exc}"
98 +
99 + if not stat.S_ISREG(path_stat.st_mode):
100 + return None, f"{role} is not a regular file: {path}"
101 +
102 + try:
103 + return path.read_text(encoding="utf-8"), ""
104 + except OSError as exc:
105 + return None, f"{role} could not be read: {path}: {exc}"
106 +
107 +
108 +def _replace_runtime_manager(target: Path, source_text: str) -> Path:
109 + target_stat = target.stat()
110 + backup = _ensure_backup(target)
111 + temp_path = target.with_name(f".{target.name}.{os.getpid()}.tmp")
112 + try:
113 + temp_path.write_text(source_text, encoding="utf-8")
114 + os.chmod(temp_path, stat.S_IMODE(target_stat.st_mode))
115 + os.replace(temp_path, target)
116 + finally:
117 + temp_path.unlink(missing_ok=True)
118 + return backup
119 +
120 +
121 +def _ensure_backup(target: Path) -> Path:
122 + backup = target.with_name(f"{target.name}{BACKUP_SUFFIX}")
123 + if not backup.exists():
124 + shutil.copy2(target, backup)
125 + return backup
tests/test_self_update_runtime_sync.py new
+141
@@ -0,0 +1,141 @@
1 +from __future__ import annotations
2 +
3 +import stat
4 +import sys
5 +from pathlib import Path
6 +
7 +
8 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
9 +if str(PROJECT_ROOT) not in sys.path:
10 + sys.path.insert(0, str(PROJECT_ROOT))
11 +
12 +from extensions.python.startup_migration import _10_self_update_manager as migration
13 +
14 +
15 +SAFE_SOURCE = """#!/usr/bin/env python3
16 +from pathlib import Path
17 +
18 +
19 +REPO_DIR = Path("/a0")
20 +
21 +
22 +def should_include_usr_backup_entry(source_file, logger):
23 + logger.log("Skipping non-regular usr backup entry")
24 + return False
25 +
26 +
27 +def clean_transient_desktop_agent_state(repo_dir, logger):
28 + return None
29 +
30 +
31 +def docker_run_ui():
32 + clean_transient_desktop_agent_state(REPO_DIR, logger)
33 +"""
34 +
35 +
36 +def test_self_update_runtime_sync_replaces_stale_manager(tmp_path):
37 + source = tmp_path / "source_self_update_manager.py"
38 + target = tmp_path / "self_update_manager.py"
39 + stale = "# old updater without non-regular usr backup guards\n"
40 + source.write_text(SAFE_SOURCE, encoding="utf-8")
41 + target.write_text(stale, encoding="utf-8")
42 + target.chmod(0o600)
43 +
44 + result = migration.ensure_self_update_manager_runtime_current(
45 + target_path=target,
46 + source_path=source,
47 + )
48 +
49 + assert result["ok"] is True
50 + assert result["updated"] is True
51 + assert target.read_text(encoding="utf-8") == SAFE_SOURCE
52 + assert (target.stat().st_mode & 0o777) == 0o600
53 + backup = target.with_name(f"{target.name}{migration.BACKUP_SUFFIX}")
54 + assert backup.read_text(encoding="utf-8") == stale
55 +
56 +
57 +def test_self_update_runtime_sync_accepts_repository_manager_source(tmp_path):
58 + source = PROJECT_ROOT / "docker" / "run" / "fs" / "exe" / "self_update_manager.py"
59 + target = tmp_path / "self_update_manager.py"
60 + stale = "# old updater without non-regular usr backup guards\n"
61 + target.write_text(stale, encoding="utf-8")
62 +
63 + result = migration.ensure_self_update_manager_runtime_current(
64 + target_path=target,
65 + source_path=source,
66 + )
67 +
68 + assert result["ok"] is True
69 + assert result["updated"] is True
70 + assert target.read_text(encoding="utf-8") == source.read_text(encoding="utf-8")
71 +
72 +
73 +def test_self_update_runtime_sync_skips_current_manager(tmp_path):
74 + source = tmp_path / "source_self_update_manager.py"
75 + target = tmp_path / "self_update_manager.py"
76 + source.write_text(SAFE_SOURCE, encoding="utf-8")
77 + target.write_text(SAFE_SOURCE, encoding="utf-8")
78 +
79 + result = migration.ensure_self_update_manager_runtime_current(
80 + target_path=target,
81 + source_path=source,
82 + )
83 +
84 + assert result == {"ok": True, "updated": False, "reason": "already-current"}
85 + backup = target.with_name(f"{target.name}{migration.BACKUP_SUFFIX}")
86 + assert not backup.exists()
87 +
88 +
89 +def test_self_update_runtime_sync_refuses_source_without_required_markers(tmp_path):
90 + source = tmp_path / "source_self_update_manager.py"
91 + target = tmp_path / "self_update_manager.py"
92 + stale = "# old updater without non-regular usr backup guards\n"
93 + source.write_text("def create_usr_backup():\n pass\n", encoding="utf-8")
94 + target.write_text(stale, encoding="utf-8")
95 +
96 + result = migration.ensure_self_update_manager_runtime_current(
97 + target_path=target,
98 + source_path=source,
99 + )
100 +
101 + assert result["ok"] is False
102 + assert result["updated"] is False
103 + assert "missing required safety markers" in result["warning"]
104 + assert target.read_text(encoding="utf-8") == stale
105 + backup = target.with_name(f"{target.name}{migration.BACKUP_SUFFIX}")
106 + assert not backup.exists()
107 +
108 +
109 +def test_self_update_runtime_sync_missing_target_is_quiet(tmp_path):
110 + source = tmp_path / "source_self_update_manager.py"
111 + target = tmp_path / "missing_self_update_manager.py"
112 + source.write_text(SAFE_SOURCE, encoding="utf-8")
113 +
114 + result = migration.ensure_self_update_manager_runtime_current(
115 + target_path=target,
116 + source_path=source,
117 + )
118 +
119 + assert result["ok"] is True
120 + assert result["updated"] is False
121 + assert "not found" in result["reason"]
122 +
123 +
124 +def test_self_update_runtime_sync_skips_non_regular_target(tmp_path):
125 + source = tmp_path / "source_self_update_manager.py"
126 + target = tmp_path / "self_update_manager.py"
127 + link_target = tmp_path / "linked_self_update_manager.py"
128 + source.write_text(SAFE_SOURCE, encoding="utf-8")
129 + link_target.write_text("# linked updater\n", encoding="utf-8")
130 + target.symlink_to(link_target)
131 +
132 + result = migration.ensure_self_update_manager_runtime_current(
133 + target_path=target,
134 + source_path=source,
135 + )
136 +
137 + assert result["ok"] is True
138 + assert result["updated"] is False
139 + assert "not a regular file" in result["reason"]
140 + assert stat.S_ISLNK(target.lstat().st_mode)
141 + assert link_target.read_text(encoding="utf-8") == "# linked updater\n"