Clean desktop SSH agent state during self-update

Remove stale runtime entries from the desktop SSH agent directory when a self-update request is consumed. Keep the cleanup best-effort so missing paths, non-directory paths, and unexpected cleanup failures do not block update startup. Cover successful cleanup, missing-directory skips, and failure fallback with focused self-update manager tests.

Alessandro committed May 27, 2026 at 16:25 UTC 8af14fcd93b62776a1b18a477ad56fa4a2637e78
2 files changed +156
docker/run/fs/exe/self_update_manager.py
+62
@@ -46,6 +46,9 @@ DEFAULT_BACKUP_CONFLICT_POLICY = "rename"
46 BACKUP_CONFLICT_POLICIES = {"rename", "overwrite", "fail"}
47 MIN_SELECTOR_VERSION = (1, 0)
48 LATEST_SELECTOR_TAG = "latest"
49 +TRANSIENT_DESKTOP_SSH_AGENT_RELATIVE_DIR = Path(
50 + "usr/plugins/_desktop/profiles/agent-zero-desktop/.ssh/agent"
51 +)
52
53
54 def now_iso() -> str:
@@ -448,6 +451,61 @@ def should_include_usr_backup_entry(source_file: Path, logger: AttemptLogger) ->
451 return True
452
453
454 +def clean_transient_desktop_ssh_agent_dir(
455 + repo_dir: Path,
456 + logger: AttemptLogger,
457 +) -> None:
458 + agent_dir = repo_dir / TRANSIENT_DESKTOP_SSH_AGENT_RELATIVE_DIR
459 + try:
460 + agent_stat = agent_dir.lstat()
461 + except FileNotFoundError:
462 + logger.log(
463 + f"Transient desktop SSH agent directory not found, skipping: {agent_dir}"
464 + )
465 + return
466 + except OSError as exc:
467 + logger.log(
468 + f"Transient desktop SSH agent directory could not be inspected: {agent_dir}: {exc}"
469 + )
470 + return
471 +
472 + if stat.S_ISLNK(agent_stat.st_mode) or not stat.S_ISDIR(agent_stat.st_mode):
473 + logger.log(
474 + f"Transient desktop SSH agent path is not a directory, skipping: {agent_dir}"
475 + )
476 + return
477 +
478 + removed = 0
479 + try:
480 + entries = list(agent_dir.iterdir())
481 + except OSError as exc:
482 + logger.log(
483 + f"Transient desktop SSH agent directory could not be listed: {agent_dir}: {exc}"
484 + )
485 + return
486 +
487 + for entry in entries:
488 + try:
489 + if entry.is_symlink():
490 + entry.unlink(missing_ok=True)
491 + elif entry.is_dir():
492 + shutil.rmtree(entry)
493 + else:
494 + entry.unlink(missing_ok=True)
495 + removed += 1
496 + except FileNotFoundError:
497 + continue
498 + except OSError as exc:
499 + logger.log(
500 + f"Skipping transient desktop SSH agent entry after error: {entry}: {exc}"
501 + )
502 +
503 + if removed:
504 + logger.log(f"Removed {removed} transient desktop SSH agent entries from {agent_dir}")
505 + else:
506 + logger.log(f"Transient desktop SSH agent directory already empty: {agent_dir}")
507 +
508 +
509 def run_command(
510 command: list[str],
511 *,
@@ -1266,6 +1324,10 @@ def docker_run_ui() -> int:
1324 logger.log(f"Consumed update file at {TRIGGER_FILE}")
1325 logger.log_block("Trigger file content", raw_text)
1326 clean_uv_cache(logger)
1327 + try:
1328 + clean_transient_desktop_ssh_agent_dir(REPO_DIR, logger)
1329 + except Exception as exc:
1330 + logger.log(f"Transient desktop SSH agent cleanup skipped after error: {exc}")
1331
1332 try:
1333 current = get_repo_version_info(REPO_DIR)
tests/test_self_update_tag_filter.py
+94
@@ -876,6 +876,100 @@ def test_self_update_manager_usr_backup_skips_transient_desktop_ssh_agent_dir(tm
876 )
877
878
879 +def test_self_update_manager_cleans_transient_desktop_ssh_agent_dir():
880 + manager = load_self_update_manager()
881 + with tempfile.TemporaryDirectory(prefix="a0su-", dir="/tmp") as temp_root:
882 + repo_dir = Path(temp_root) / "repo"
883 + agent_dir = repo_dir / manager.TRANSIENT_DESKTOP_SSH_AGENT_RELATIVE_DIR
884 + nested_dir = agent_dir / "nested"
885 + agent_dir.mkdir(parents=True)
886 + nested_dir.mkdir()
887 + (agent_dir / "socket").write_text("ephemeral\n", encoding="utf-8")
888 + (nested_dir / "token").write_text("ephemeral\n", encoding="utf-8")
889 + (agent_dir / "broken-link").symlink_to("/missing/ssh-agent-socket")
890 + socket_path = agent_dir / "runtime.sock"
891 + messages = []
892 +
893 + class ListLogger:
894 + def log(self, message=""):
895 + messages.append(message)
896 +
897 + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as runtime_socket:
898 + runtime_socket.bind(str(socket_path))
899 +
900 + manager.clean_transient_desktop_ssh_agent_dir(repo_dir, ListLogger())
901 +
902 + assert agent_dir.exists()
903 + assert list(agent_dir.iterdir()) == []
904 + assert any(
905 + f"Removed 4 transient desktop SSH agent entries from {agent_dir}" in message
906 + for message in messages
907 + )
908 +
909 +
910 +def test_self_update_manager_cleans_transient_desktop_ssh_agent_dir_skips_missing(
911 + tmp_path,
912 +):
913 + manager = load_self_update_manager()
914 + repo_dir = tmp_path / "repo"
915 + messages = []
916 +
917 + class ListLogger:
918 + def log(self, message=""):
919 + messages.append(message)
920 +
921 + manager.clean_transient_desktop_ssh_agent_dir(repo_dir, ListLogger())
922 +
923 + assert any(
924 + "Transient desktop SSH agent directory not found, skipping:" in message
925 + for message in messages
926 + )
927 +
928 +
929 +def test_self_update_manager_desktop_ssh_cleanup_failure_does_not_block_startup(
930 + monkeypatch,
931 + tmp_path,
932 +):
933 + manager = load_self_update_manager()
934 + request_data = {"branch": "main", "tag": "v1.2", "requested_at": "now"}
935 + current_info = {
936 + "branch": "main",
937 + "describe": "v1.2",
938 + "short_tag": "v1.2",
939 + "commit": "abc1234",
940 + "short_commit": "abc1234",
941 + }
942 + launched = []
943 + fake_process = object()
944 +
945 + monkeypatch.setattr(manager, "LOG_FILE", tmp_path / "a0-self-update.log")
946 + monkeypatch.setattr(
947 + manager,
948 + "load_request_file",
949 + lambda: (request_data, "branch: main\ntag: v1.2\n"),
950 + )
951 + monkeypatch.setattr(manager, "clean_uv_cache", lambda logger: None)
952 + monkeypatch.setattr(
953 + manager,
954 + "clean_transient_desktop_ssh_agent_dir",
955 + lambda repo_dir, logger: (_ for _ in ()).throw(RuntimeError("cleanup boom")),
956 + )
957 + monkeypatch.setattr(manager, "get_repo_version_info", lambda repo_dir: current_info)
958 + monkeypatch.setattr(manager, "record_result", lambda **kwargs: None)
959 + monkeypatch.setattr(
960 + manager,
961 + "launch_ui_process",
962 + lambda repo_dir, logger: launched.append(repo_dir) or fake_process,
963 + )
964 + monkeypatch.setattr(manager, "wait_for_process", lambda process: 0)
965 +
966 + assert manager.docker_run_ui() == 0
967 + assert launched == [manager.REPO_DIR]
968 + assert "Transient desktop SSH agent cleanup skipped after error: cleanup boom" in (
969 + tmp_path / "a0-self-update.log"
970 + ).read_text(encoding="utf-8")
971 +
972 +
973 def test_self_update_manager_clean_uv_cache_uses_uv_when_available(monkeypatch):
974 manager = load_self_update_manager()
975 commands = []