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:
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user