fix(ssh): handle WinError 1314 symlink failure with shutil.copy2 fallback

On Windows, os.symlink() raises OSError (WinError 1314) unless the
process has Administrator rights or Developer Mode is enabled. The SSH
bulk-upload staging logic used symlinks to mirror the remote layout
before piping through tar; this caused all ssh_bulk_upload tests to
fail on Windows.

- ssh.py: wrap os.symlink() in try/except OSError and fall back to
  shutil.copy2() so staging works on every platform. shutil was already
  imported, no new dependency introduced.
- file_sync.py: replace str(Path(remote).parent) with
  posixpath.dirname(remote) in unique_parent_dirs(). pathlib.Path uses
  the host separator (\ on Windows), but these paths are sent to a
  remote Linux host over SSH and must always use forward slashes.
- test_ssh_bulk_upload.py: make test_staging_symlinks_mirror_remote_layout
  platform-agnostic — assert file existence and content instead of
  os.path.islink() + os.readlink(), since the staged entry may be a
  copy on Windows.
This commit is contained in:
kewe63
2026-06-04 18:06:21 -07:00
committed by Teknium
parent ea44011d15
commit 46abf04012
3 changed files with 20 additions and 6 deletions
+12 -4
View File
@@ -90,7 +90,12 @@ class TestSSHBulkUpload:
assert "/home/testuser/.hermes/credentials" in mkdir_str
def test_staging_symlinks_mirror_remote_layout(self, mock_env, tmp_path):
"""Symlinks in staging dir should mirror the .hermes-relative layout."""
"""Staged file in staging dir should mirror the remote path structure.
On platforms where symlinks are available (Linux/macOS) the staged
entry is a symlink; on Windows it may be a regular copy. Either way
the file must exist at the expected path and contain the right data.
"""
f1 = tmp_path / "local_a.txt"
f1.write_text("content a")
@@ -105,11 +110,14 @@ class TestSSHBulkUpload:
# Capture the staging dir from -C argument
c_idx = cmd.index("-C")
staging_dir = cmd[c_idx + 1]
# Check the symlink exists
# Check the staged entry exists at the base-relative path
expected = os.path.join(staging_dir, "skills/my_skill.md")
staging_paths.append(expected)
assert os.path.islink(expected), f"Expected symlink at {expected}"
assert os.readlink(expected) == os.path.abspath(str(f1))
# File must exist (either as symlink or copy)
assert os.path.exists(expected), f"Expected staged file at {expected}"
# Content must match the source
with open(expected, "r") as fh:
assert fh.read() == "content a"
mock = MagicMock()
mock.stdout = MagicMock()