main
py 188 lines 5.71 KB
Raw
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())