Clean legacy Desktop agent sockets during self-update

Extend the self-update preflight cleanup to cover current Desktop profile state, retired Desktop state, and legacy Office-owned Desktop profile state. Remove transient .ssh/agent entries and non-regular .gnupg/S.gpg-agent* sockets before usr backup while preserving GnuPG key material such as pubring.kbx and private-keys-v1.d. Add regression coverage for the v1.13 legacy profile layout that can block upgrades.

Alessandro committed Jun 2, 2026 at 00:04 UTC a153bb1b40da6ab6457dde3a22e77ac69947acff
2 files changed +169 -53
docker/run/fs/exe/self_update_manager.py
+120 -41
@@ -46,8 +46,10 @@ 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"
49 +DESKTOP_PROFILE_STATE_RELATIVE_DIRS = (
50 + Path("usr/plugins/_desktop/profiles"),
51 + Path("usr/_desktop/profiles"),
52 + Path("tmp/_office/desktop/profiles"),
53 )
54
55
@@ -451,59 +453,136 @@ def should_include_usr_backup_entry(source_file: Path, logger: AttemptLogger) ->
453 return True
454
455
454 -def clean_transient_desktop_ssh_agent_dir(
456 +def clean_transient_desktop_agent_state(
457 repo_dir: Path,
458 logger: AttemptLogger,
459 ) -> None:
458 - agent_dir = repo_dir / TRANSIENT_DESKTOP_SSH_AGENT_RELATIVE_DIR
460 + profile_roots = 0
461 + removed = 0
462 + for relative_root in DESKTOP_PROFILE_STATE_RELATIVE_DIRS:
463 + profile_root = repo_dir / relative_root
464 + if not _is_cleanup_directory(
465 + profile_root,
466 + logger,
467 + "Desktop profile state",
468 + missing_ok=True,
469 + ):
470 + continue
471 + profile_roots += 1
472 + try:
473 + profiles = list(profile_root.iterdir())
474 + except OSError as exc:
475 + logger.log(f"Desktop profile state could not be listed: {profile_root}: {exc}")
476 + continue
477 + for profile_dir in profiles:
478 + if not _is_cleanup_directory(profile_dir, logger, "Desktop profile"):
479 + continue
480 + removed += _clean_directory_entries(
481 + profile_dir / ".ssh" / "agent",
482 + logger,
483 + label="desktop SSH agent",
484 + )
485 + removed += _clean_gnupg_agent_entries(profile_dir / ".gnupg", logger)
486 +
487 + if removed:
488 + logger.log(f"Removed {removed} transient desktop agent entries.")
489 + elif profile_roots:
490 + logger.log("Transient desktop agent state already clean.")
491 + else:
492 + logger.log("No desktop profile runtime state found, skipping transient agent cleanup.")
493 +
494 +
495 +def _clean_gnupg_agent_entries(gnupg_dir: Path, logger: AttemptLogger) -> int:
496 + if not _is_cleanup_directory(gnupg_dir, logger, "desktop GnuPG state", missing_ok=True):
497 + return 0
498 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
499 + entries = list(gnupg_dir.iterdir())
500 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
501 + logger.log(f"Desktop GnuPG state could not be listed: {gnupg_dir}: {exc}")
502 + return 0
503
504 removed = 0
505 + for entry in entries:
506 + if not entry.name.startswith("S.gpg-agent"):
507 + continue
508 + try:
509 + entry_stat = entry.lstat()
510 + except FileNotFoundError:
511 + continue
512 + except OSError as exc:
513 + logger.log(f"Skipping transient desktop GnuPG agent entry after stat error: {entry}: {exc}")
514 + continue
515 + if stat.S_ISREG(entry_stat.st_mode):
516 + continue
517 + if _remove_cleanup_entry(entry, entry_stat, logger, label="desktop GnuPG agent"):
518 + removed += 1
519 + return removed
520 +
521 +
522 +def _clean_directory_entries(directory: Path, logger: AttemptLogger, *, label: str) -> int:
523 + if not _is_cleanup_directory(directory, logger, label, missing_ok=True):
524 + return 0
525 try:
480 - entries = list(agent_dir.iterdir())
526 + entries = list(directory.iterdir())
527 except OSError as exc:
482 - logger.log(
483 - f"Transient desktop SSH agent directory could not be listed: {agent_dir}: {exc}"
484 - )
485 - return
528 + logger.log(f"Transient {label} directory could not be listed: {directory}: {exc}")
529 + return 0
530
531 + removed = 0
532 for entry in entries:
533 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
534 + entry_stat = entry.lstat()
535 except FileNotFoundError:
536 continue
537 except OSError as exc:
499 - logger.log(
500 - f"Skipping transient desktop SSH agent entry after error: {entry}: {exc}"
501 - )
538 + logger.log(f"Skipping transient {label} entry after stat error: {entry}: {exc}")
539 + continue
540 + if _remove_cleanup_entry(entry, entry_stat, logger, label=label):
541 + removed += 1
542 + return removed
543
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}")
544 +
545 +def _is_cleanup_directory(
546 + directory: Path,
547 + logger: AttemptLogger,
548 + label: str,
549 + *,
550 + missing_ok: bool = False,
551 +) -> bool:
552 + try:
553 + directory_stat = directory.lstat()
554 + except FileNotFoundError:
555 + if not missing_ok:
556 + logger.log(f"{label} directory not found, skipping: {directory}")
557 + return False
558 + except OSError as exc:
559 + logger.log(f"{label} directory could not be inspected: {directory}: {exc}")
560 + return False
561 +
562 + if stat.S_ISLNK(directory_stat.st_mode) or not stat.S_ISDIR(directory_stat.st_mode):
563 + logger.log(f"{label} path is not a directory, skipping: {directory}")
564 + return False
565 + return True
566 +
567 +
568 +def _remove_cleanup_entry(
569 + entry: Path,
570 + entry_stat: os.stat_result,
571 + logger: AttemptLogger,
572 + *,
573 + label: str,
574 +) -> bool:
575 + try:
576 + if stat.S_ISDIR(entry_stat.st_mode):
577 + shutil.rmtree(entry)
578 + else:
579 + entry.unlink(missing_ok=True)
580 + return True
581 + except FileNotFoundError:
582 + return False
583 + except OSError as exc:
584 + logger.log(f"Skipping transient {label} entry after error: {entry}: {exc}")
585 + return False
586
587
588 def run_command(
@@ -1325,9 +1404,9 @@ def docker_run_ui() -> int:
1404 logger.log_block("Trigger file content", raw_text)
1405 clean_uv_cache(logger)
1406 try:
1328 - clean_transient_desktop_ssh_agent_dir(REPO_DIR, logger)
1407 + clean_transient_desktop_agent_state(REPO_DIR, logger)
1408 except Exception as exc:
1330 - logger.log(f"Transient desktop SSH agent cleanup skipped after error: {exc}")
1409 + logger.log(f"Transient desktop agent cleanup skipped after error: {exc}")
1410
1411 try:
1412 current = get_repo_version_info(REPO_DIR)
tests/test_self_update_tag_filter.py
+49 -12
@@ -876,38 +876,75 @@ 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():
879 +def test_self_update_manager_cleans_transient_desktop_agent_state():
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
883 + current_profile = (
884 + repo_dir
885 + / "usr"
886 + / "plugins"
887 + / "_desktop"
888 + / "profiles"
889 + / "agent-zero-desktop"
890 + )
891 + legacy_profile = (
892 + repo_dir
893 + / "tmp"
894 + / "_office"
895 + / "desktop"
896 + / "profiles"
897 + / "agent-zero-desktop"
898 + )
899 + agent_dir = current_profile / ".ssh" / "agent"
900 + gnupg_dir = legacy_profile / ".gnupg"
901 nested_dir = agent_dir / "nested"
902 agent_dir.mkdir(parents=True)
903 + gnupg_dir.mkdir(parents=True)
904 nested_dir.mkdir()
905 (agent_dir / "socket").write_text("ephemeral\n", encoding="utf-8")
906 (nested_dir / "token").write_text("ephemeral\n", encoding="utf-8")
907 (agent_dir / "broken-link").symlink_to("/missing/ssh-agent-socket")
890 - socket_path = agent_dir / "runtime.sock"
908 + (gnupg_dir / "pubring.kbx").write_text("keyring\n", encoding="utf-8")
909 + (gnupg_dir / "private-keys-v1.d").mkdir()
910 + (gnupg_dir / "private-keys-v1.d" / "key.key").write_text(
911 + "private\n",
912 + encoding="utf-8",
913 + )
914 + (gnupg_dir / "S.gpg-agent.regular").write_text(
915 + "regular files do not break backup reads\n",
916 + encoding="utf-8",
917 + )
918 + ssh_socket_path = agent_dir / "runtime.sock"
919 + gpg_socket_path = gnupg_dir / "S.gpg-agent"
920 messages = []
921
922 class ListLogger:
923 def log(self, message=""):
924 messages.append(message)
925
897 - with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as runtime_socket:
898 - runtime_socket.bind(str(socket_path))
926 + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as ssh_socket:
927 + ssh_socket.bind(str(ssh_socket_path))
928 + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as gpg_socket:
929 + gpg_socket.bind(str(gpg_socket_path))
930
900 - manager.clean_transient_desktop_ssh_agent_dir(repo_dir, ListLogger())
931 + manager.clean_transient_desktop_agent_state(repo_dir, ListLogger())
932
933 assert agent_dir.exists()
934 assert list(agent_dir.iterdir()) == []
935 + assert (gnupg_dir / "pubring.kbx").read_text(encoding="utf-8") == "keyring\n"
936 + assert (gnupg_dir / "private-keys-v1.d" / "key.key").read_text(
937 + encoding="utf-8"
938 + ) == "private\n"
939 + assert (gnupg_dir / "S.gpg-agent.regular").read_text(encoding="utf-8")
940 + assert not gpg_socket_path.exists()
941 assert any(
905 - f"Removed 4 transient desktop SSH agent entries from {agent_dir}" in message
942 + "Removed 5 transient desktop agent entries." in message
943 for message in messages
944 )
945
946
910 -def test_self_update_manager_cleans_transient_desktop_ssh_agent_dir_skips_missing(
947 +def test_self_update_manager_cleans_transient_desktop_agent_state_skips_missing(
948 tmp_path,
949 ):
950 manager = load_self_update_manager()
@@ -918,10 +955,10 @@ def test_self_update_manager_cleans_transient_desktop_ssh_agent_dir_skips_missin
955 def log(self, message=""):
956 messages.append(message)
957
921 - manager.clean_transient_desktop_ssh_agent_dir(repo_dir, ListLogger())
958 + manager.clean_transient_desktop_agent_state(repo_dir, ListLogger())
959
960 assert any(
924 - "Transient desktop SSH agent directory not found, skipping:" in message
961 + "No desktop profile runtime state found, skipping transient agent cleanup." in message
962 for message in messages
963 )
964
@@ -951,7 +988,7 @@ def test_self_update_manager_desktop_ssh_cleanup_failure_does_not_block_startup(
988 monkeypatch.setattr(manager, "clean_uv_cache", lambda logger: None)
989 monkeypatch.setattr(
990 manager,
954 - "clean_transient_desktop_ssh_agent_dir",
991 + "clean_transient_desktop_agent_state",
992 lambda repo_dir, logger: (_ for _ in ()).throw(RuntimeError("cleanup boom")),
993 )
994 monkeypatch.setattr(manager, "get_repo_version_info", lambda repo_dir: current_info)
@@ -965,7 +1002,7 @@ def test_self_update_manager_desktop_ssh_cleanup_failure_does_not_block_startup(
1002
1003 assert manager.docker_run_ui() == 0
1004 assert launched == [manager.REPO_DIR]
968 - assert "Transient desktop SSH agent cleanup skipped after error: cleanup boom" in (
1005 + assert "Transient desktop agent cleanup skipped after error: cleanup boom" in (
1006 tmp_path / "a0-self-update.log"
1007 ).read_text(encoding="utf-8")
1008