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"