Refresh Codex CLI during self-update

Refresh an existing Codex CLI after successful or already-current self-updates without making npm failures fatal. Bootstrap the new behavior when startup synchronizes a stale runtime updater, and cover both paths with focused regressions.

Alessandro committed Jul 10, 2026 at 16:53 UTC 1410bdcf6c95219941ff8ef442e725d0b9b62223
6 files changed +121 -1
docker/run/AGENTS.md
+1
@@ -23,6 +23,7 @@
23 - Runtime startup must ensure `/a0/usr/uploads` exists before supervised services start.
24 - Runtime startup raises the soft open-file limit toward `A0_NOFILE_LIMIT` (default `65535`) before supervisord starts, bounded by the container hard limit.
25 - Self-update user-data backups skip Time Travel shadow history under `usr/.time_travel/` and transient Desktop agent state.
26 +- Successful or already-current self-updates refresh an installed Codex CLI with npm on a best-effort basis; missing CLIs and registry failures must not block Agent Zero startup.
27
28 ## Work Guidance
29
docker/run/fs/exe/self_update_manager.py
+29 -1
@@ -635,6 +635,29 @@ def clean_uv_cache(logger: AttemptLogger) -> None:
635 logger.log(f"uv cache clean skipped after error: {exc}")
636
637
638 +def refresh_codex_cli(logger: AttemptLogger) -> None:
639 + codex_path = shutil.which("codex")
640 + if not codex_path:
641 + logger.log("Codex CLI not installed, skipping Codex refresh.")
642 + return
643 +
644 + npm_path = shutil.which("npm")
645 + if not npm_path:
646 + logger.log("npm executable not found, skipping Codex refresh.")
647 + return
648 +
649 + logger.log("Refreshing the installed Codex CLI after self-update.")
650 + try:
651 + run_command(
652 + [npm_path, "install", "--global", "@openai/codex@latest"],
653 + cwd=None,
654 + logger=logger,
655 + error_message="Failed to refresh the installed Codex CLI.",
656 + )
657 + except Exception as exc:
658 + logger.log(f"Codex CLI refresh skipped after error: {exc}")
659 +
660 +
661 def has_local_rollback_changes(repo_dir: Path) -> bool:
662 status = git_output(repo_dir, "status", "--porcelain=v1", "--untracked-files=all")
663 return bool(status.strip())
@@ -1148,6 +1171,7 @@ def execute_pending_update(
1171 logger=logger,
1172 )
1173 if healthy:
1174 + refresh_codex_cli(logger)
1175 record_result(
1176 status="success",
1177 message=f"Updated Agent Zero to branch {branch}, {resolved_target['target_description']}.",
@@ -1425,6 +1449,7 @@ def docker_run_ui() -> int:
1449 logger.log(
1450 "Requested tag already matches the installed version, skipping file replacement."
1451 )
1452 + refresh_codex_cli(logger)
1453 record_result(
1454 status="skipped",
1455 message="Requested tag already matches the installed version.",
@@ -1468,8 +1493,11 @@ def main(argv: list[str] | None = None) -> int:
1493 return docker_run_ui()
1494 if args[0] == "trigger-update":
1495 return trigger_update_command(args[1:])
1496 + if args[0] == "refresh-codex":
1497 + refresh_codex_cli(AttemptLogger(LOG_FILE))
1498 + return 0
1499 if args[0] in {"-h", "--help"}:
1472 - print("Usage: self_update_manager.py [docker-run-ui | trigger-update ...]")
1500 + print("Usage: self_update_manager.py [docker-run-ui | trigger-update ... | refresh-codex]")
1501 return 0
1502 print(f"Unknown command: {args[0]}", file=sys.stderr)
1503 return 1
extensions/python/startup_migration/AGENTS.md
+1
@@ -14,6 +14,7 @@
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 +- After synchronizing a stale self-update manager, `_10_self_update_manager.py` starts that manager's best-effort Codex CLI refresh in the background so the update that introduces the hook does not need a second restart.
18
19 ## Work Guidance
20
extensions/python/startup_migration/_10_self_update_manager.py
+21
@@ -3,6 +3,8 @@ from __future__ import annotations
3 import os
4 import shutil
5 import stat
6 +import subprocess
7 +import sys
8 from pathlib import Path
9 from typing import Any
10
@@ -25,6 +27,8 @@ REQUIRED_RUNTIME_MARKERS = (
27 "Skipping non-regular usr backup entry",
28 "def clean_transient_desktop_agent_state(",
29 "clean_transient_desktop_agent_state(REPO_DIR, logger)",
30 + "def refresh_codex_cli(",
31 + "refresh_codex_cli(logger)",
32 )
33
34
@@ -33,6 +37,9 @@ class SelfUpdateManagerRuntimeSync(Extension):
37 result = ensure_self_update_manager_runtime_current()
38 if result.get("updated"):
39 PrintStyle.info("Self-update manager runtime synchronized:", result["target"])
40 + warning = start_codex_cli_refresh(result["target"])
41 + if warning:
42 + PrintStyle.warning("Codex CLI refresh could not be started:", warning)
43 elif result.get("warning"):
44 PrintStyle.warning("Self-update manager runtime sync skipped:", result["warning"])
45
@@ -84,6 +91,20 @@ def ensure_self_update_manager_runtime_current(
91 }
92
93
94 +def start_codex_cli_refresh(manager_path: Path | str) -> str:
95 + try:
96 + subprocess.Popen(
97 + [sys.executable, str(manager_path), "refresh-codex"],
98 + stdin=subprocess.DEVNULL,
99 + stdout=subprocess.DEVNULL,
100 + stderr=subprocess.DEVNULL,
101 + start_new_session=True,
102 + )
103 + except OSError as exc:
104 + return str(exc)
105 + return ""
106 +
107 +
108 def _missing_required_markers(text: str) -> list[str]:
109 return [marker for marker in REQUIRED_RUNTIME_MARKERS if marker not in text]
110
tests/test_self_update_runtime_sync.py
+19
@@ -28,8 +28,13 @@ def clean_transient_desktop_agent_state(repo_dir, logger):
28 return None
29
30
31 +def refresh_codex_cli(logger):
32 + return None
33 +
34 +
35 def docker_run_ui():
36 clean_transient_desktop_agent_state(REPO_DIR, logger)
37 + refresh_codex_cli(logger)
38 """
39
40
@@ -70,6 +75,20 @@ def test_self_update_runtime_sync_accepts_repository_manager_source(tmp_path):
75 assert target.read_text(encoding="utf-8") == source.read_text(encoding="utf-8")
76
77
78 +def test_self_update_runtime_sync_starts_codex_refresh(monkeypatch, tmp_path):
79 + calls = []
80 + manager_path = tmp_path / "self_update_manager.py"
81 + monkeypatch.setattr(
82 + migration.subprocess,
83 + "Popen",
84 + lambda *args, **kwargs: calls.append((args, kwargs)),
85 + )
86 +
87 + assert migration.start_codex_cli_refresh(manager_path) == ""
88 + assert calls[0][0][0] == [sys.executable, str(manager_path), "refresh-codex"]
89 + assert calls[0][1]["start_new_session"] is True
90 +
91 +
92 def test_self_update_runtime_sync_skips_current_manager(tmp_path):
93 source = tmp_path / "source_self_update_manager.py"
94 target = tmp_path / "self_update_manager.py"
tests/test_self_update_tag_filter.py
+50
@@ -1106,6 +1106,56 @@ def test_self_update_manager_clean_uv_cache_is_best_effort(monkeypatch):
1106 assert any("uv cache clean skipped after error" in message for message in messages)
1107
1108
1109 +def test_self_update_manager_refreshes_installed_codex_best_effort(monkeypatch):
1110 + manager = load_self_update_manager()
1111 + commands = []
1112 + messages = []
1113 + paths = {"codex": "/usr/local/bin/codex", "npm": "/usr/bin/npm"}
1114 + monkeypatch.setattr(manager.shutil, "which", paths.get)
1115 +
1116 + class Logger:
1117 + def log(self, message=""):
1118 + messages.append(message)
1119 +
1120 + def log_block(self, title, content):
1121 + return None
1122 +
1123 + def fail_run_command(command, **kwargs):
1124 + commands.append(command)
1125 + raise RuntimeError("registry unavailable")
1126 +
1127 + monkeypatch.setattr(manager, "run_command", fail_run_command)
1128 +
1129 + manager.refresh_codex_cli(Logger())
1130 +
1131 + assert commands == [["/usr/bin/npm", "install", "--global", "@openai/codex@latest"]]
1132 + assert any("Codex CLI refresh skipped after error" in message for message in messages)
1133 +
1134 +
1135 +def test_self_update_manager_skips_codex_refresh_when_not_installed(monkeypatch):
1136 + manager = load_self_update_manager()
1137 + commands = []
1138 + monkeypatch.setattr(manager.shutil, "which", lambda executable: None)
1139 + monkeypatch.setattr(
1140 + manager,
1141 + "run_command",
1142 + lambda command, **kwargs: commands.append(command),
1143 + )
1144 +
1145 + manager.refresh_codex_cli(manager.NullLogger())
1146 +
1147 + assert commands == []
1148 +
1149 +
1150 +def test_self_update_manager_refresh_codex_command(monkeypatch):
1151 + manager = load_self_update_manager()
1152 + loggers = []
1153 + monkeypatch.setattr(manager, "refresh_codex_cli", lambda logger: loggers.append(logger))
1154 +
1155 + assert manager.main(["refresh-codex"]) == 0
1156 + assert len(loggers) == 1
1157 +
1158 +
1159 def test_self_update_manager_latest_on_main_uses_current_major_release(monkeypatch):
1160 manager = load_self_update_manager()
1161 monkeypatch.setattr(