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