fix(cli): skip special files when cloning a profile with --clone-all

Route the --clone-all copytree ignore through _non_exportable_entries so a
live source profile holding a gateway or agent-browser socket (or a FIFO)
no longer aborts the clone with [Errno 6] No such device or address.
.pyc/.pyo and the root exclude sets keep their existing handling.

hermes_cli/profile_distribution.py:_copy_dist_payload is left alone: it
copies from a freshly extracted distribution archive (a staged temp tree),
never from a live profile, so it cannot meet a socket.

Extends test_clone_all_does_not_copy_cron_jobs to cover the clone path.
This commit is contained in:
kshitijk4poor
2026-09-15 11:57:38 +05:30
committed by kshitij
parent 9a82c44e38
commit fe6d3016cf
2 changed files with 17 additions and 6 deletions
+8 -6
View File
@@ -80,19 +80,21 @@ def _clone_all_copytree_ignore(source_dir: Path):
if source_resolved == _get_default_hermes_home().resolve():
root_exclude |= _CLONE_ALL_DEFAULT_EXCLUDE_ROOT
def _ignore(directory: str, names: List[str]) -> List[str]:
def _ignore(directory: str, names: List[str]) -> set:
try:
at_root = Path(directory).resolve() == source_resolved
except (OSError, ValueError):
# resolve() can fail on odd FS layouts (broken symlinks, missing parents).
# Fail open — better to over-copy than silently drop user data.
at_root = False
return [
# A live source profile carries gateway/agent-browser sockets that copytree
# cannot copy; _non_exportable_entries drops them (and __pycache__/*.sock/*.tmp).
ignored = _non_exportable_entries(directory, names)
ignored.update(
entry for entry in names
if entry == "__pycache__"
or entry.endswith((".pyc", ".pyo", ".sock", ".tmp"))
or (at_root and entry in root_exclude)
]
if entry.endswith((".pyc", ".pyo")) or (at_root and entry in root_exclude)
)
return ignored
return _ignore
+9
View File
@@ -201,6 +201,7 @@ class TestCreateProfile:
assert (profile_dir / ".env").read_text().strip() == "KEY=val"
assert (profile_dir / "SOUL.md").read_text() == "Be helpful."
@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="special files need a POSIX filesystem")
def test_clone_all_does_not_copy_cron_jobs(self, profile_env):
# Cron jobs are scheduled work bound to the source profile + origin channel; a clone
# that inherits jobs.json fires every job twice (two gateways, same job ids).
@@ -209,12 +210,20 @@ class TestCreateProfile:
(default_home / "cron").mkdir()
(default_home / "cron" / "jobs.json").write_text(json.dumps({"jobs": [{"id": "abc123def456"}]}))
(default_home / "cron" / "output").mkdir()
# A live source profile holds special files copytree cannot copy (e.g. a suffixless
# agent-browser control socket); one of them must not abort the whole clone.
browser_dir = default_home / "home" / ".agent-browser"
browser_dir.mkdir(parents=True)
(browser_dir / "state.json").write_text("{}")
os.mkfifo(browser_dir / "control")
profile_dir = create_profile("coder", clone_all=True, no_alias=True)
assert (profile_dir / "cron").is_dir()
assert not any((profile_dir / "cron").iterdir())
assert yaml.safe_load((profile_dir / "config.yaml").read_text())["model"] == "test"
assert (profile_dir / "home" / ".agent-browser" / "state.json").is_file()
assert not (profile_dir / "home" / ".agent-browser" / "control").exists()