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(