| 1 | """Regression tests for code execution shell lifecycle behavior. |
| 2 | |
| 3 | Pagers (more/less) must be disabled in the non-interactive shells created by the |
| 4 | code execution tool: without user input they block forever and spin at 100% CPU. |
| 5 | """ |
| 6 | |
| 7 | import asyncio |
| 8 | import importlib |
| 9 | from types import SimpleNamespace |
| 10 | |
| 11 | from plugins._code_execution.helpers import shell_local, shell_ssh |
| 12 | from plugins._code_execution.helpers.tty_session import TTYSession |
| 13 | from plugins._code_execution.tools.code_execution_tool import ( |
| 14 | CodeExecution, |
| 15 | ShellWrap, |
| 16 | State, |
| 17 | _group_multiline_command, |
| 18 | _is_closed_pty_error, |
| 19 | ) |
| 20 | |
| 21 | |
| 22 | def test_local_env_disables_pagers_and_preserves_existing(): |
| 23 | env = shell_local.disable_pagers_in_env({"PATH": "/usr/bin", "PAGER": "less"}) |
| 24 | assert env["PAGER"] == "cat" |
| 25 | assert env["GIT_PAGER"] == "cat" |
| 26 | # pre-existing keys are preserved |
| 27 | assert env["PATH"] == "/usr/bin" |
| 28 | |
| 29 | |
| 30 | def test_local_env_defaults_to_environ(): |
| 31 | env = shell_local.disable_pagers_in_env() |
| 32 | assert env["PAGER"] == "cat" |
| 33 | assert env["GIT_PAGER"] == "cat" |
| 34 | |
| 35 | |
| 36 | def test_local_env_does_not_mutate_input(): |
| 37 | src = {"PATH": "/usr/bin"} |
| 38 | shell_local.disable_pagers_in_env(src) |
| 39 | assert src == {"PATH": "/usr/bin"} |
| 40 | |
| 41 | |
| 42 | def test_ssh_command_disables_pagers(): |
| 43 | assert "GIT_PAGER=cat" in shell_ssh.PAGER_DISABLE_COMMAND |
| 44 | assert "PAGER=cat" in shell_ssh.PAGER_DISABLE_COMMAND |
| 45 | |
| 46 | |
| 47 | def test_paramiko_import_error_does_not_retain_tool_loading_stack(monkeypatch): |
| 48 | try: |
| 49 | raise ImportError("invoke") |
| 50 | except ImportError as error: |
| 51 | saved_error = error |
| 52 | monkeypatch.setattr(shell_ssh.paramiko.config, "invoke_import_error", error) |
| 53 | |
| 54 | importlib.reload(shell_ssh) |
| 55 | |
| 56 | assert shell_ssh.paramiko.config.invoke_import_error is saved_error |
| 57 | assert saved_error.__traceback__ is None |
| 58 | |
| 59 | |
| 60 | def test_multiline_terminal_commands_are_one_current_shell_compound(): |
| 61 | assert _group_multiline_command("pwd") == "pwd" |
| 62 | assert _group_multiline_command("cd /tmp\npwd") == "{\ncd /tmp\npwd\n}" |
| 63 | assert _group_multiline_command("$env:FOO='bar'\n$env:FOO", powershell=True) == ( |
| 64 | ". {\n$env:FOO='bar'\n$env:FOO\n}" |
| 65 | ) |
| 66 | |
| 67 | |
| 68 | def test_exited_tty_process_is_a_recoverable_closed_session(): |
| 69 | assert _is_closed_pty_error(RuntimeError("TTYSpawn process has exited")) |
| 70 | |
| 71 | |
| 72 | def test_tty_close_kills_term_resistant_process(): |
| 73 | async def run(): |
| 74 | session = TTYSession("bash -lc 'trap \"\" TERM; sleep 30'") |
| 75 | await session.start() |
| 76 | await asyncio.wait_for(session.close(), timeout=6) |
| 77 | assert session._proc is None |
| 78 | |
| 79 | asyncio.run(run()) |
| 80 | |
| 81 | |
| 82 | def test_tty_reports_strict_mode_shell_exit(): |
| 83 | async def run(): |
| 84 | session = TTYSession("/bin/bash --noprofile --norc -i") |
| 85 | await session.start() |
| 86 | await session.read_full_until_idle(idle_timeout=0.05, total_timeout=1) |
| 87 | await session.sendline("{\nset -euo pipefail\nfalse\nprintf 'unreachable\\n'\n}") |
| 88 | |
| 89 | exit_code = await asyncio.wait_for(session.wait(), timeout=5) |
| 90 | |
| 91 | assert exit_code != 0 |
| 92 | assert session.is_terminated() |
| 93 | assert session.get_exit_code() == exit_code |
| 94 | await session.close() |
| 95 | |
| 96 | asyncio.run(run()) |
| 97 | |
| 98 | |
| 99 | def test_ssh_session_reports_channel_exit_status(): |
| 100 | class FakeChannel: |
| 101 | closed = False |
| 102 | |
| 103 | @staticmethod |
| 104 | def exit_status_ready(): |
| 105 | return True |
| 106 | |
| 107 | @staticmethod |
| 108 | def recv_exit_status(): |
| 109 | return 7 |
| 110 | |
| 111 | session = object.__new__(shell_ssh.SSHInteractiveSession) |
| 112 | session.shell = FakeChannel() |
| 113 | session.client = SimpleNamespace( |
| 114 | get_transport=lambda: SimpleNamespace(is_active=lambda: True) |
| 115 | ) |
| 116 | session._exit_code = None |
| 117 | |
| 118 | assert session.is_terminated() |
| 119 | assert session.get_exit_code() == 7 |
| 120 | |
| 121 | |
| 122 | def test_code_execution_returns_immediately_when_shell_exits(): |
| 123 | class FinishedSession: |
| 124 | async def read_output(self, timeout=0, reset_full_output=False): |
| 125 | return "nothing to commit, working tree clean\n", "nothing to commit, working tree clean\n" |
| 126 | |
| 127 | @staticmethod |
| 128 | def is_terminated(): |
| 129 | return True |
| 130 | |
| 131 | @staticmethod |
| 132 | def get_exit_code(): |
| 133 | return 1 |
| 134 | |
| 135 | class FakeAgent: |
| 136 | agent_name = "test" |
| 137 | |
| 138 | async def handle_intervention(self): |
| 139 | return None |
| 140 | |
| 141 | @staticmethod |
| 142 | def read_prompt(name, **kwargs): |
| 143 | if name == "fw.code.shell_exit.md": |
| 144 | return f"Terminal shell exited{kwargs['status']}. The command has finished." |
| 145 | if name == "fw.code.info.md": |
| 146 | return f"[SYSTEM: {kwargs['info']}]" |
| 147 | raise AssertionError(f"Unexpected prompt: {name}") |
| 148 | |
| 149 | async def run(): |
| 150 | session = FinishedSession() |
| 151 | state = State( |
| 152 | ssh_enabled=False, |
| 153 | shells={0: ShellWrap(id=0, session=session, running=True)}, |
| 154 | ) |
| 155 | tool = CodeExecution( |
| 156 | FakeAgent(), |
| 157 | "code_execution_tool", |
| 158 | "", |
| 159 | {"runtime": "terminal", "session": 0}, |
| 160 | "", |
| 161 | None, |
| 162 | ) |
| 163 | updates = [] |
| 164 | tool.log = SimpleNamespace(update=lambda **kwargs: updates.append(kwargs)) |
| 165 | |
| 166 | async def prepare_state(*args, **kwargs): |
| 167 | return state |
| 168 | |
| 169 | async def set_progress(content): |
| 170 | return None |
| 171 | |
| 172 | tool.prepare_state = prepare_state |
| 173 | tool.set_progress = set_progress |
| 174 | tool.fix_full_output = lambda output: output |
| 175 | |
| 176 | response = await tool.get_terminal_output( |
| 177 | {"prompt_patterns": [], "dialog_patterns": []}, |
| 178 | session=0, |
| 179 | sleep_time=0, |
| 180 | ) |
| 181 | |
| 182 | assert "nothing to commit" in response |
| 183 | assert "exit code 1" in response |
| 184 | assert "command has finished" in response |
| 185 | assert not state.shells[0].running |
| 186 | assert updates[-1]["heading"].endswith(" icon://done_all") |
| 187 | |
| 188 | asyncio.run(run()) |