* fix(windows): SSH ControlMaster gating + stop hijacking the user's python Two Windows environment-integrity fixes: 1. tools/environments/ssh.py (#73927): Windows OpenSSH has no Unix-domain-socket ControlMaster support, so unconditionally passing ControlPath/ControlMaster/ControlPersist failed EVERY tool call on a Windows-hosted ssh terminal backend with 'getsockname failed: Not a socket'. Gate the three multiplexing options behind a module-level _SSH_MULTIPLEX = (os.name != 'nt'); the scp upload path is gated the same way. On Windows the backend now works without connection pooling (each command a fresh connection); POSIX behavior is unchanged. The teardown 'ssh -O exit' is naturally inert because the socket never exists on Windows. 2. scripts/install.ps1 (#83797): the installer put the whole venv\Scripts directory on the user PATH, which contains python.exe / pythonw.exe / pip.exe and so silently hijacked the 'python' command in every terminal on the machine — unrelated projects started resolving python to Hermes' runtime interpreter. Now copy only the launchers (hermes.exe, hermes-acp.exe) into a dedicated $InstallDir\bin and put THAT on PATH. Existing installs are migrated: the legacy venv\Scripts entry is stripped from the user PATH on the next install/update. The new bin dir is under $InstallDir (…\hermes-agent), which the uninstall PATH sweep already matches via its \hermes-agent marker. Updated the stale hermes_cli/update_cmd.py docstring that described the old venv\Scripts-on-PATH layout. Tests: SSH ControlMaster gating pinned both directions (multiplex on → flags present; off → absent but BatchMode/StrictHostKeyChecking retained). install.ps1 parses clean via the PowerShell AST parser. * docs: update windows-native install docs for the bin\ launcher layout CI (test_windows_native_docs) pins the docs and installer to the same PATH layout. The #83797 fix moved the PATH entry from venv\Scripts to a dedicated $InstallDir\bin holding only the hermes launchers, so update the Windows-native guide to match: PATH-after-install section, the install-steps list, the directory-layout table, the Get-Command verification line, and the 'command not found' pitfall. Test now asserts the bin\ layout and guards against a regression back to venv\Scripts on PATH. * fix: keep install.ps1 pure ASCII (PowerShell 5.1 codepage safety) The two comments I added in the #83797 PATH-hijack fix used em-dashes, tripping tests/test_install_ps1_ascii_only.py — Windows PowerShell 5.1 reads a BOM-less .ps1 in the system ANSI codepage (not UTF-8), so a non-ASCII byte can misdecode into a stray quote and desync the parser (issues #66994/#67000). Replace the em-dashes with ASCII '--'.
253 lines
10 KiB
Python
253 lines
10 KiB
Python
"""Tests for the SSH remote execution environment backend."""
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
from tools.environments.ssh import SSHEnvironment
|
|
from tools.environments import ssh as ssh_env
|
|
|
|
_SSH_HOST = os.getenv("TERMINAL_SSH_HOST", "")
|
|
_SSH_USER = os.getenv("TERMINAL_SSH_USER", "")
|
|
_SSH_PORT = int(os.getenv("TERMINAL_SSH_PORT", "22"))
|
|
_SSH_KEY = os.getenv("TERMINAL_SSH_KEY", "")
|
|
|
|
_has_ssh = bool(_SSH_HOST and _SSH_USER)
|
|
|
|
requires_ssh = pytest.mark.skipif(
|
|
not _has_ssh,
|
|
reason="TERMINAL_SSH_HOST / TERMINAL_SSH_USER not set",
|
|
)
|
|
|
|
|
|
def _run(command, task_id="ssh_test", **kwargs):
|
|
from tools.terminal_tool import terminal_tool
|
|
return json.loads(terminal_tool(command, task_id=task_id, **kwargs))
|
|
|
|
|
|
def _cleanup(task_id="ssh_test"):
|
|
from tools.terminal_tool import cleanup_vm
|
|
cleanup_vm(task_id)
|
|
|
|
|
|
class TestBuildSSHCommand:
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _mock_connection(self, monkeypatch):
|
|
monkeypatch.setattr("tools.environments.ssh.subprocess.run",
|
|
lambda *a, **k: subprocess.CompletedProcess([], 0))
|
|
monkeypatch.setattr("tools.environments.ssh.subprocess.Popen",
|
|
lambda *a, **k: MagicMock(stdout=iter([]),
|
|
stderr=iter([]),
|
|
stdin=MagicMock()))
|
|
monkeypatch.setattr("tools.environments.base.time.sleep", lambda _: None)
|
|
|
|
def test_base_flags(self, monkeypatch):
|
|
# ControlMaster flags are POSIX-only (#73927): assert them only
|
|
# where multiplexing is enabled so the test passes on Windows too.
|
|
monkeypatch.setattr(ssh_env, "_SSH_MULTIPLEX", True)
|
|
env = SSHEnvironment(host="h", user="u")
|
|
cmd = " ".join(env._build_ssh_command())
|
|
for flag in ("ControlMaster=auto", "ControlPersist=300",
|
|
"BatchMode=yes", "StrictHostKeyChecking=accept-new"):
|
|
assert flag in cmd
|
|
|
|
def test_controlmaster_gated_off_on_windows(self, monkeypatch):
|
|
"""#73927: Windows OpenSSH has no Unix-domain ControlMaster, so the
|
|
ControlPath/ControlMaster/ControlPersist options must be omitted —
|
|
passing them fails the connection with 'getsockname failed'."""
|
|
monkeypatch.setattr(ssh_env, "_SSH_MULTIPLEX", False)
|
|
env = SSHEnvironment(host="h", user="u")
|
|
cmd = " ".join(env._build_ssh_command())
|
|
assert "ControlMaster" not in cmd
|
|
assert "ControlPath" not in cmd
|
|
assert "ControlPersist" not in cmd
|
|
# Non-multiplex flags must still be present — the backend works,
|
|
# just without connection pooling.
|
|
assert "BatchMode=yes" in cmd
|
|
assert "StrictHostKeyChecking=accept-new" in cmd
|
|
assert env._build_ssh_command()[-1] == "u@h"
|
|
|
|
|
|
def test_user_host_suffix(self):
|
|
env = SSHEnvironment(host="h", user="u")
|
|
assert env._build_ssh_command()[-1] == "u@h"
|
|
|
|
|
|
class TestControlSocketPath:
|
|
"""Regression tests for issue #11840.
|
|
|
|
macOS caps Unix domain socket paths at 104 bytes (sun_path). SSH
|
|
appends a 16-byte random suffix to the control socket path when
|
|
operating in ControlMaster mode. An IPv6 host embedded in the
|
|
filename plus the deeply-nested macOS $TMPDIR easily blows past
|
|
the limit, causing every tool call to fail immediately.
|
|
"""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _mock_connection(self, monkeypatch):
|
|
monkeypatch.setattr("tools.environments.ssh.subprocess.run",
|
|
lambda *a, **k: subprocess.CompletedProcess([], 0))
|
|
monkeypatch.setattr("tools.environments.ssh.subprocess.Popen",
|
|
lambda *a, **k: MagicMock(stdout=iter([]),
|
|
stderr=iter([]),
|
|
stdin=MagicMock()))
|
|
monkeypatch.setattr("tools.environments.base.time.sleep", lambda _: None)
|
|
|
|
# SSH appends ``.XXXXXXXXXXXXXXXX`` (17 bytes) to the ControlPath in
|
|
# ControlMaster mode; the macOS sun_path field is 104 bytes including
|
|
# the NUL terminator, so the usable path length is 103 bytes.
|
|
_SSH_CONTROLMASTER_SUFFIX = 17
|
|
_MAX_SUN_PATH = 103
|
|
|
|
def test_fits_under_macos_socket_limit_with_ipv6_host(self, monkeypatch):
|
|
"""A realistic macOS $TMPDIR + IPv6 host must still produce a
|
|
control socket path that fits once SSH appends its ControlMaster
|
|
suffix (see issue #11840)."""
|
|
# Simulate the macOS $TMPDIR shape from the issue traceback —
|
|
# 48 bytes, the typical length of ``/var/folders/XX/YYYYYYYYY/T``.
|
|
fake_tmp = "/var/folders/2t/wbkw5yb158jc3zhswgl7tz9c0000gn/T"
|
|
monkeypatch.setattr("tools.environments.ssh.tempfile.gettempdir",
|
|
lambda: fake_tmp)
|
|
# The simulated path doesn't exist on the test host — skip the
|
|
# real mkdir so __init__ can proceed.
|
|
from pathlib import Path as _Path
|
|
monkeypatch.setattr(_Path, "mkdir", lambda *a, **k: None)
|
|
|
|
env = SSHEnvironment(
|
|
host="9373:9b91:4480:558d:708e:e601:24e8:d8d0",
|
|
user="hermes",
|
|
port=22,
|
|
)
|
|
|
|
total_len = len(str(env.control_socket)) + self._SSH_CONTROLMASTER_SUFFIX
|
|
assert total_len <= self._MAX_SUN_PATH, (
|
|
f"control socket path would exceed the {self._MAX_SUN_PATH}-byte "
|
|
f"Unix domain socket limit once SSH appends its 16-byte suffix: "
|
|
f"{env.control_socket} (+{self._SSH_CONTROLMASTER_SUFFIX} = {total_len})"
|
|
)
|
|
|
|
def test_path_is_deterministic_across_instances(self):
|
|
"""Same (user, host, port) must yield the same control socket so
|
|
ControlMaster reuse works across reconnects."""
|
|
first = SSHEnvironment(host="example.com", user="alice", port=2222)
|
|
second = SSHEnvironment(host="example.com", user="alice", port=2222)
|
|
assert first.control_socket == second.control_socket
|
|
|
|
def test_path_differs_for_different_targets(self):
|
|
"""Different (user, host, port) triples must produce different paths."""
|
|
base = SSHEnvironment(host="h", user="u", port=22).control_socket
|
|
assert SSHEnvironment(host="h", user="u", port=23).control_socket != base
|
|
assert SSHEnvironment(host="h", user="v", port=22).control_socket != base
|
|
assert SSHEnvironment(host="g", user="u", port=22).control_socket != base
|
|
|
|
|
|
class TestTerminalToolConfig:
|
|
def test_ssh_persistent_default_true(self, monkeypatch):
|
|
"""SSH persistent defaults to True (via TERMINAL_PERSISTENT_SHELL)."""
|
|
monkeypatch.delenv("TERMINAL_SSH_PERSISTENT", raising=False)
|
|
monkeypatch.delenv("TERMINAL_PERSISTENT_SHELL", raising=False)
|
|
from tools.terminal_tool import _get_env_config
|
|
assert _get_env_config()["ssh_persistent"] is True
|
|
|
|
|
|
def test_ssh_persistent_respects_config(self, monkeypatch):
|
|
"""TERMINAL_PERSISTENT_SHELL=false disables SSH persistent by default."""
|
|
monkeypatch.delenv("TERMINAL_SSH_PERSISTENT", raising=False)
|
|
monkeypatch.setenv("TERMINAL_PERSISTENT_SHELL", "false")
|
|
from tools.terminal_tool import _get_env_config
|
|
assert _get_env_config()["ssh_persistent"] is False
|
|
|
|
|
|
class TestSSHPreflight:
|
|
def test_ensure_ssh_available_raises_clear_error_when_missing(self, monkeypatch):
|
|
monkeypatch.setattr(ssh_env.shutil, "which", lambda _name: None)
|
|
|
|
with pytest.raises(RuntimeError, match="SSH is not installed or not in PATH"):
|
|
ssh_env._ensure_ssh_available()
|
|
|
|
|
|
def test_ssh_environment_connects_when_ssh_exists(self, monkeypatch):
|
|
called = {"count": 0}
|
|
|
|
monkeypatch.setattr(ssh_env.shutil, "which", lambda _name: "/usr/bin/ssh")
|
|
|
|
def _fake_establish(self):
|
|
called["count"] += 1
|
|
|
|
monkeypatch.setattr(ssh_env.SSHEnvironment, "_establish_connection", _fake_establish)
|
|
monkeypatch.setattr(ssh_env.SSHEnvironment, "_detect_remote_home", lambda self: "/home/alice")
|
|
monkeypatch.setattr(ssh_env.SSHEnvironment, "_ensure_remote_dirs", lambda self: None)
|
|
monkeypatch.setattr(ssh_env.SSHEnvironment, "init_session", lambda self: None)
|
|
monkeypatch.setattr(ssh_env, "FileSyncManager", lambda **kw: type("M", (), {"sync": lambda self, **k: None})())
|
|
|
|
env = ssh_env.SSHEnvironment(host="example.com", user="alice")
|
|
|
|
assert called["count"] == 1
|
|
assert env.host == "example.com"
|
|
assert env.user == "alice"
|
|
|
|
|
|
def _setup_ssh_env(monkeypatch, persistent: bool):
|
|
monkeypatch.setenv("TERMINAL_ENV", "ssh")
|
|
monkeypatch.setenv("TERMINAL_SSH_HOST", _SSH_HOST)
|
|
monkeypatch.setenv("TERMINAL_SSH_USER", _SSH_USER)
|
|
monkeypatch.setenv("TERMINAL_SSH_PERSISTENT", "true" if persistent else "false")
|
|
if _SSH_PORT != 22:
|
|
monkeypatch.setenv("TERMINAL_SSH_PORT", str(_SSH_PORT))
|
|
if _SSH_KEY:
|
|
monkeypatch.setenv("TERMINAL_SSH_KEY", _SSH_KEY)
|
|
|
|
|
|
@requires_ssh
|
|
class TestOneShotSSH:
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _setup(self, monkeypatch):
|
|
_setup_ssh_env(monkeypatch, persistent=False)
|
|
yield
|
|
_cleanup()
|
|
|
|
def test_echo(self):
|
|
r = _run("echo hello")
|
|
assert r["exit_code"] == 0
|
|
assert "hello" in r["output"]
|
|
|
|
|
|
def test_state_does_not_persist(self):
|
|
_run("export HERMES_ONESHOT_TEST=yes")
|
|
r = _run("echo $HERMES_ONESHOT_TEST")
|
|
assert r["output"].strip() == ""
|
|
|
|
|
|
@requires_ssh
|
|
class TestPersistentSSH:
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _setup(self, monkeypatch):
|
|
_setup_ssh_env(monkeypatch, persistent=True)
|
|
yield
|
|
_cleanup()
|
|
|
|
def test_echo(self):
|
|
r = _run("echo hello-persistent")
|
|
assert r["exit_code"] == 0
|
|
assert "hello-persistent" in r["output"]
|
|
|
|
def test_env_var_persists(self):
|
|
_run("export HERMES_PERSIST_TEST=works")
|
|
r = _run("echo $HERMES_PERSIST_TEST")
|
|
assert r["output"].strip() == "works"
|
|
|
|
|
|
def test_large_output(self):
|
|
r = _run("seq 1 1000")
|
|
assert r["exit_code"] == 0
|
|
lines = r["output"].strip().splitlines()
|
|
assert len(lines) == 1000
|
|
assert lines[0] == "1"
|
|
assert lines[-1] == "1000"
|