Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions tests/tools/test_ssh_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import json
import os
import subprocess
from pathlib import Path
from unittest.mock import MagicMock

import pytest
Expand Down Expand Up @@ -135,6 +136,70 @@ def test_path_differs_for_different_targets(self):
assert SSHEnvironment(host="g", user="u", port=22).control_socket != base


class TestSSHBulkUploadTarCompatibility:
def _make_env(self, supports_no_overwrite_dir):
env = object.__new__(SSHEnvironment)
env.host = "h"
env.user = "u"
env.port = 22
env.key_path = ""
env.control_socket = Path("/tmp/hermes-test.sock")
env._build_ssh_command = lambda extra_args=None: ["ssh"] + (extra_args or [])
env._remote_tar_supports_no_overwrite_dir = lambda: supports_no_overwrite_dir
return env

def _fake_popen(self, calls):
class Proc:
def __init__(self, args):
self.args = args
self.returncode = 0
self.stdout = MagicMock()
self.stderr = MagicMock()

def poll(self):
return self.returncode

def communicate(self, timeout=None):
return (b"", b"")

def wait(self):
return self.returncode

def kill(self):
self.returncode = -9

def _popen(args, **kwargs):
calls.append(args)
return Proc(args)

return _popen

def test_bulk_upload_uses_gnu_no_overwrite_dir_when_supported(self, tmp_path, monkeypatch):
src = tmp_path / "file.txt"
src.write_text("data")
env = self._make_env(supports_no_overwrite_dir=True)
monkeypatch.setattr("tools.environments.ssh.subprocess.run", lambda *a, **k: subprocess.CompletedProcess([], 0))
popen_calls = []
monkeypatch.setattr("tools.environments.ssh.subprocess.Popen", self._fake_popen(popen_calls))

env._ssh_bulk_upload([(str(src), "/Users/devhub/.hermes/file.txt")])

assert any("tar xf - --no-overwrite-dir -C /" in call for call in popen_calls)

def test_bulk_upload_falls_back_for_bsd_tar_without_no_overwrite_dir(self, tmp_path, monkeypatch):
src = tmp_path / "file.txt"
src.write_text("data")
env = self._make_env(supports_no_overwrite_dir=False)
monkeypatch.setattr("tools.environments.ssh.subprocess.run", lambda *a, **k: subprocess.CompletedProcess([], 0))
popen_calls = []
monkeypatch.setattr("tools.environments.ssh.subprocess.Popen", self._fake_popen(popen_calls))

env._ssh_bulk_upload([(str(src), "/Users/devhub/.hermes/file.txt")])

assert any("tar xmf - -C /" in call for call in popen_calls)
assert not any("--no-overwrite-dir" in " ".join(call) for call in popen_calls)


class TestTerminalToolConfig:
def test_ssh_persistent_default_true(self, monkeypatch):
"""SSH persistent defaults to True (via TERMINAL_PERSISTENT_SHELL)."""
Expand Down
27 changes: 22 additions & 5 deletions tools/environments/ssh.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,11 +186,18 @@ def _ssh_bulk_upload(self, files: list[tuple[str, str]]) -> None:

tar_cmd = ["tar", "-chf", "-", "-C", staging, "."]
ssh_cmd = self._build_ssh_command()
# --no-overwrite-dir prevents tar from overwriting the mode of
# existing directories (e.g. /home/<user>) with the staging
# directory's mode. Without this, a umask 002 produces 0775
# dirs which breaks sshd StrictModes (refuses authorized_keys).
ssh_cmd.append("tar xf - --no-overwrite-dir -C /")
# GNU tar supports --no-overwrite-dir, but macOS ships bsdtar,
# which rejects that flag. Detect support on the remote side so
# SSH workers targeting macOS can still run without a persistent
# file-sync warning on every tool call. Keep the safer GNU-tar
# flag where available to avoid clobbering directory modes.
tar_extract_cmd = "tar xf - --no-overwrite-dir -C /"
if not self._remote_tar_supports_no_overwrite_dir():
# bsdtar on macOS also tries to restore mtimes on existing
# system/user directories from the archive and exits non-zero
# when that is not permitted. -m skips mtime restoration.
tar_extract_cmd = "tar xmf - -C /"
ssh_cmd.append(tar_extract_cmd)

tar_proc = subprocess.Popen(
tar_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
Expand Down Expand Up @@ -237,6 +244,16 @@ def _ssh_bulk_upload(self, files: list[tuple[str, str]]) -> None:

logger.debug("SSH: bulk-uploaded %d file(s) via tar pipe", len(files))

def _remote_tar_supports_no_overwrite_dir(self) -> bool:
"""Return whether remote tar accepts GNU tar's --no-overwrite-dir."""
cmd = self._build_ssh_command()
cmd.append("tar --no-overwrite-dir --help >/dev/null 2>&1")
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
except (OSError, subprocess.SubprocessError):
return False
return result.returncode == 0

def _ssh_bulk_download(self, dest: Path) -> None:
"""Download remote .hermes/ as a tar archive."""
# Tar from / with the full path so archive entries preserve absolute
Expand Down