""" Regression tests for KIN-096: Pipeline из веб-интерфейса не передаёт SSH_AUTH_SOCK в subprocess агентов. Root causes fixed: (1) agents/runner.py _build_claude_env() — уже содержит glob-детекцию SSH_AUTH_SOCK из /private/tmp/com.apple.launchd.*/Listeners. (2) agents/runner.py _build_claude_env() — SSH_AGENT_PID detection (строки 74-77) был логическим no-op (env = os.environ.copy(), поэтому проверка 'SSH_AGENT_PID not in env' == 'SSH_AGENT_PID not in os.environ'). (3) web/api.py _launch_pipeline_subprocess() — должна добавить аналогичную glob-детекцию SSH_AUTH_SOCK, чтобы промежуточный subprocess получал SSH env при старте из launchd. Acceptance criteria: - При наличии SSH_AUTH_SOCK в os.environ subprocess получает его в env. - При отсутствии SSH_AUTH_SOCK и наличии macOS-сокета — детектируется через glob. - При отсутствии SSH_AUTH_SOCK и пустом glob — subprocess стартует без него (graceful). """ import os from unittest.mock import patch, MagicMock import pytest from agents.runner import _build_claude_env # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_popen_mock(): proc = MagicMock() proc.pid = 12345 return proc # --------------------------------------------------------------------------- # _build_claude_env: SSH_AUTH_SOCK detection (agents/runner.py) # --------------------------------------------------------------------------- class TestBuildClaudeEnvSSH: """Тесты SSH env в _build_claude_env (agents/runner.py).""" def test_ssh_auth_sock_preserved_when_present_in_environ(self): """Если SSH_AUTH_SOCK уже есть в os.environ — он попадает в env без изменений.""" sock_path = "/tmp/ssh-agent-test.sock" with patch.dict("os.environ", {"SSH_AUTH_SOCK": sock_path}, clear=False): env = _build_claude_env() assert env.get("SSH_AUTH_SOCK") == sock_path def test_ssh_auth_sock_detected_via_glob_when_absent(self): """Если SSH_AUTH_SOCK отсутствует в os.environ — детектируется через glob. Примечание: в runner.py glob импортируется локально внутри if-блока, поэтому патчим glob.glob напрямую, а не agents.runner.glob. """ fake_sock = "/private/tmp/com.apple.launchd.ABCDEF/Listeners" env_without_sock = {k: v for k, v in os.environ.items() if k != "SSH_AUTH_SOCK"} with patch.dict("os.environ", env_without_sock, clear=True): with patch("glob.glob", return_value=[fake_sock]) as mock_glob: env = _build_claude_env() assert env.get("SSH_AUTH_SOCK") == fake_sock mock_glob.assert_called_with("/private/tmp/com.apple.launchd.*/Listeners") def test_ssh_auth_sock_absent_when_glob_finds_nothing(self): """Graceful degradation: если glob ничего не нашёл — SSH_AUTH_SOCK не устанавливается.""" env_without_sock = {k: v for k, v in os.environ.items() if k != "SSH_AUTH_SOCK"} with patch.dict("os.environ", env_without_sock, clear=True): with patch("glob.glob", return_value=[]): env = _build_claude_env() assert "SSH_AUTH_SOCK" not in env def test_ssh_agent_pid_is_inherited_via_environ_copy(self): """SSH_AGENT_PID попадает в env через os.environ.copy() — без специального кода. Проверяет что корректный путь работает: env=os.environ.copy() автоматически включает SSH_AGENT_PID, если он был в окружении. """ with patch.dict("os.environ", {"SSH_AGENT_PID": "9999"}, clear=False): env = _build_claude_env() assert env.get("SSH_AGENT_PID") == "9999" def test_ssh_agent_pid_absent_when_not_in_environ(self): """Если SSH_AGENT_PID нет в os.environ, он не появляется в env.""" env_without_pid = {k: v for k, v in os.environ.items() if k != "SSH_AGENT_PID"} with patch.dict("os.environ", env_without_pid, clear=True): with patch("glob.glob", return_value=[]): env = _build_claude_env() assert "SSH_AGENT_PID" not in env # --------------------------------------------------------------------------- # _launch_pipeline_subprocess: SSH_AUTH_SOCK передаётся в Popen (web/api.py) # --------------------------------------------------------------------------- class TestLaunchPipelineSubprocessSSH: """Тесты передачи SSH-переменных в subprocess из _launch_pipeline_subprocess.""" def test_ssh_auth_sock_passed_to_subprocess_when_in_environ(self): """KIN-096: SSH_AUTH_SOCK из os.environ попадает в env Popen через copy().""" from web.api import _launch_pipeline_subprocess sock_path = "/tmp/ssh-TEST-known.sock" with patch.dict("os.environ", {"SSH_AUTH_SOCK": sock_path}, clear=False): with patch("web.api.subprocess.Popen", return_value=_make_popen_mock()) as mock_popen: _launch_pipeline_subprocess("P1-001") assert mock_popen.called kwargs = mock_popen.call_args[1] env_passed = kwargs.get("env", {}) assert env_passed.get("SSH_AUTH_SOCK") == sock_path, ( "KIN-096: SSH_AUTH_SOCK должен передаваться в Popen env через os.environ.copy()" ) def test_kin_noninteractive_always_set_in_subprocess_env(self): """KIN_NONINTERACTIVE=1 всегда присутствует в env Popen — независимо от SSH.""" from web.api import _launch_pipeline_subprocess with patch("web.api.subprocess.Popen", return_value=_make_popen_mock()) as mock_popen: _launch_pipeline_subprocess("P1-001") kwargs = mock_popen.call_args[1] env_passed = kwargs.get("env", {}) assert env_passed.get("KIN_NONINTERACTIVE") == "1" def test_popen_failure_does_not_propagate_exception(self): """Если Popen бросает исключение, _launch_pipeline_subprocess не пробрасывает его.""" from web.api import _launch_pipeline_subprocess with patch("web.api.subprocess.Popen", side_effect=OSError("no such file")): # не должно бросать _launch_pipeline_subprocess("P1-001") def test_subprocess_launched_without_ssh_auth_sock_does_not_raise(self): """Graceful degradation: при отсутствии SSH_AUTH_SOCK Popen вызывается без ошибок. Тест воспроизводит сценарий launchd-среды без SSH_AUTH_SOCK. После применения фикса (glob-детекция в _launch_pipeline_subprocess) macOS-сокет будет добавлен; до применения — subprocess стартует без него. В обоих случаях исключений быть не должно. """ from web.api import _launch_pipeline_subprocess env_without_sock = {k: v for k, v in os.environ.items() if k != "SSH_AUTH_SOCK"} with patch.dict("os.environ", env_without_sock, clear=True): with patch("web.api.subprocess.Popen", return_value=_make_popen_mock()) as mock_popen: _launch_pipeline_subprocess("P1-001") assert mock_popen.called, "Popen должен быть вызван даже без SSH_AUTH_SOCK в окружении" def test_ssh_auth_sock_detected_via_glob_in_launch_subprocess(self): """KIN-096 FIX VERIFICATION: glob-детекция SSH_AUTH_SOCK в _launch_pipeline_subprocess. После применения фикса (добавление import glob + glob.glob детекции в _launch_pipeline_subprocess), SSH_AUTH_SOCK должен определяться автоматически из macOS launchd-сокета. ВНИМАНИЕ: этот тест проверяет фикс, который ещё НЕ применён в web/api.py. При текущем коде (без фикса) — тест упадёт, т.к. glob-детекция отсутствует. """ from web.api import _launch_pipeline_subprocess fake_sock = "/private/tmp/com.apple.launchd.XYZ123/Listeners" env_without_sock = {k: v for k, v in os.environ.items() if k != "SSH_AUTH_SOCK"} with patch.dict("os.environ", env_without_sock, clear=True): with patch("glob.glob", return_value=[fake_sock]): with patch("web.api.subprocess.Popen", return_value=_make_popen_mock()) as mock_popen: _launch_pipeline_subprocess("P1-001") kwargs = mock_popen.call_args[1] env_passed = kwargs.get("env", {}) assert env_passed.get("SSH_AUTH_SOCK") == fake_sock, ( "KIN-096: после фикса SSH_AUTH_SOCK должен детектироваться через glob " "в _launch_pipeline_subprocess когда его нет в os.environ" )