frdel commited on
Commit
48bdeac
·
1 Parent(s): 93d5e89

windows tty

Browse files
python/helpers/runtime.py CHANGED
@@ -7,9 +7,10 @@ from python.helpers import dotenv, rfc, settings, files
7
  import asyncio
8
  import threading
9
  import queue
 
10
 
11
- T = TypeVar('T')
12
- R = TypeVar('R')
13
 
14
  parser = argparse.ArgumentParser()
15
  args = {}
@@ -41,31 +42,38 @@ def initialize():
41
  key = key.lstrip("-")
42
  args[key] = value
43
 
 
44
  def get_arg(name: str):
45
  global args
46
  return args.get(name, None)
47
 
 
48
  def has_arg(name: str):
49
  global args
50
  return name in args
51
 
 
52
  def is_dockerized() -> bool:
53
  return bool(get_arg("dockerized"))
54
 
 
55
  def is_development() -> bool:
56
  return not is_dockerized()
57
 
 
58
  def get_local_url():
59
  if is_dockerized():
60
  return "host.docker.internal"
61
  return "127.0.0.1"
62
 
 
63
  def get_runtime_id() -> str:
64
  global runtime_id
65
  if not runtime_id:
66
- runtime_id = secrets.token_hex(8)
67
  return runtime_id
68
 
 
69
  def get_persistent_id() -> str:
70
  id = dotenv.get_dotenv_value("A0_PERSISTENT_RUNTIME_ID")
71
  if not id:
@@ -73,19 +81,28 @@ def get_persistent_id() -> str:
73
  dotenv.save_dotenv_value("A0_PERSISTENT_RUNTIME_ID", id)
74
  return id
75
 
 
76
  @overload
77
- async def call_development_function(func: Callable[..., Awaitable[T]], *args, **kwargs) -> T: ...
 
 
 
78
 
79
  @overload
80
  async def call_development_function(func: Callable[..., T], *args, **kwargs) -> T: ...
81
 
82
- async def call_development_function(func: Union[Callable[..., T], Callable[..., Awaitable[T]]], *args, **kwargs) -> T:
 
 
 
83
  if is_development():
84
  url = _get_rfc_url()
85
  password = _get_rfc_password()
86
  # Normalize path components to build a valid Python module path across OSes
87
- module_path = Path(files.deabsolute_path(func.__code__.co_filename)).with_suffix("")
88
- module = ".".join(module_path.parts) # __module__ is not reliable
 
 
89
  result = await rfc.call_rfc(
90
  url=url,
91
  password=password,
@@ -99,7 +116,7 @@ async def call_development_function(func: Union[Callable[..., T], Callable[...,
99
  if inspect.iscoroutinefunction(func):
100
  return await func(*args, **kwargs)
101
  else:
102
- return func(*args, **kwargs) # type: ignore
103
 
104
 
105
  async def handle_rfc(rfc_call: rfc.RFCCall):
@@ -117,45 +134,61 @@ def _get_rfc_url() -> str:
117
  set = settings.get_settings()
118
  url = set["rfc_url"]
119
  if not "://" in url:
120
- url = "http://"+url
121
  if url.endswith("/"):
122
  url = url[:-1]
123
- url = url+":"+str(set["rfc_port_http"])
124
  url += "/rfc"
125
  return url
126
 
127
 
128
- def call_development_function_sync(func: Union[Callable[..., T], Callable[..., Awaitable[T]]], *args, **kwargs) -> T:
 
 
129
  # run async function in sync manner
130
  result_queue = queue.Queue()
131
-
132
  def run_in_thread():
133
  result = asyncio.run(call_development_function(func, *args, **kwargs))
134
  result_queue.put(result)
135
-
136
  thread = threading.Thread(target=run_in_thread)
137
  thread.start()
138
  thread.join(timeout=30) # wait for thread with timeout
139
-
140
  if thread.is_alive():
141
  raise TimeoutError("Function call timed out after 30 seconds")
142
-
143
  result = result_queue.get_nowait()
144
  return cast(T, result)
145
 
146
 
147
  def get_web_ui_port():
148
  web_ui_port = (
149
- get_arg("port")
150
- or int(dotenv.get_dotenv_value("WEB_UI_PORT", 0))
151
- or 5000
152
  )
153
  return web_ui_port
154
 
 
155
  def get_tunnel_api_port():
156
  tunnel_api_port = (
157
  get_arg("tunnel_api_port")
158
  or int(dotenv.get_dotenv_value("TUNNEL_API_PORT", 0))
159
  or 55520
160
  )
161
- return tunnel_api_port
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  import asyncio
8
  import threading
9
  import queue
10
+ import sys
11
 
12
+ T = TypeVar("T")
13
+ R = TypeVar("R")
14
 
15
  parser = argparse.ArgumentParser()
16
  args = {}
 
42
  key = key.lstrip("-")
43
  args[key] = value
44
 
45
+
46
  def get_arg(name: str):
47
  global args
48
  return args.get(name, None)
49
 
50
+
51
  def has_arg(name: str):
52
  global args
53
  return name in args
54
 
55
+
56
  def is_dockerized() -> bool:
57
  return bool(get_arg("dockerized"))
58
 
59
+
60
  def is_development() -> bool:
61
  return not is_dockerized()
62
 
63
+
64
  def get_local_url():
65
  if is_dockerized():
66
  return "host.docker.internal"
67
  return "127.0.0.1"
68
 
69
+
70
  def get_runtime_id() -> str:
71
  global runtime_id
72
  if not runtime_id:
73
+ runtime_id = secrets.token_hex(8)
74
  return runtime_id
75
 
76
+
77
  def get_persistent_id() -> str:
78
  id = dotenv.get_dotenv_value("A0_PERSISTENT_RUNTIME_ID")
79
  if not id:
 
81
  dotenv.save_dotenv_value("A0_PERSISTENT_RUNTIME_ID", id)
82
  return id
83
 
84
+
85
  @overload
86
+ async def call_development_function(
87
+ func: Callable[..., Awaitable[T]], *args, **kwargs
88
+ ) -> T: ...
89
+
90
 
91
  @overload
92
  async def call_development_function(func: Callable[..., T], *args, **kwargs) -> T: ...
93
 
94
+
95
+ async def call_development_function(
96
+ func: Union[Callable[..., T], Callable[..., Awaitable[T]]], *args, **kwargs
97
+ ) -> T:
98
  if is_development():
99
  url = _get_rfc_url()
100
  password = _get_rfc_password()
101
  # Normalize path components to build a valid Python module path across OSes
102
+ module_path = Path(
103
+ files.deabsolute_path(func.__code__.co_filename)
104
+ ).with_suffix("")
105
+ module = ".".join(module_path.parts) # __module__ is not reliable
106
  result = await rfc.call_rfc(
107
  url=url,
108
  password=password,
 
116
  if inspect.iscoroutinefunction(func):
117
  return await func(*args, **kwargs)
118
  else:
119
+ return func(*args, **kwargs) # type: ignore
120
 
121
 
122
  async def handle_rfc(rfc_call: rfc.RFCCall):
 
134
  set = settings.get_settings()
135
  url = set["rfc_url"]
136
  if not "://" in url:
137
+ url = "http://" + url
138
  if url.endswith("/"):
139
  url = url[:-1]
140
+ url = url + ":" + str(set["rfc_port_http"])
141
  url += "/rfc"
142
  return url
143
 
144
 
145
+ def call_development_function_sync(
146
+ func: Union[Callable[..., T], Callable[..., Awaitable[T]]], *args, **kwargs
147
+ ) -> T:
148
  # run async function in sync manner
149
  result_queue = queue.Queue()
150
+
151
  def run_in_thread():
152
  result = asyncio.run(call_development_function(func, *args, **kwargs))
153
  result_queue.put(result)
154
+
155
  thread = threading.Thread(target=run_in_thread)
156
  thread.start()
157
  thread.join(timeout=30) # wait for thread with timeout
158
+
159
  if thread.is_alive():
160
  raise TimeoutError("Function call timed out after 30 seconds")
161
+
162
  result = result_queue.get_nowait()
163
  return cast(T, result)
164
 
165
 
166
  def get_web_ui_port():
167
  web_ui_port = (
168
+ get_arg("port") or int(dotenv.get_dotenv_value("WEB_UI_PORT", 0)) or 5000
 
 
169
  )
170
  return web_ui_port
171
 
172
+
173
  def get_tunnel_api_port():
174
  tunnel_api_port = (
175
  get_arg("tunnel_api_port")
176
  or int(dotenv.get_dotenv_value("TUNNEL_API_PORT", 0))
177
  or 55520
178
  )
179
+ return tunnel_api_port
180
+
181
+
182
+ def get_platform():
183
+ return sys.platform
184
+
185
+
186
+ def is_windows():
187
+ return get_platform() == "win32"
188
+
189
+
190
+ def get_terminal_executable():
191
+ if is_windows():
192
+ return "powershell.exe"
193
+ else:
194
+ return "/bin/bash"
python/helpers/shell_local.py CHANGED
@@ -1,9 +1,10 @@
 
1
  import select
2
  import subprocess
3
  import time
4
  import sys
5
  from typing import Optional, Tuple
6
- from python.helpers import tty_session
7
  from python.helpers.shell_ssh import clean_string
8
 
9
  class LocalInteractiveSession:
@@ -13,7 +14,7 @@ class LocalInteractiveSession:
13
  self.cwd = cwd
14
 
15
  async def connect(self):
16
- self.session = tty_session.TTYSession("/bin/bash", cwd=self.cwd)
17
  await self.session.start()
18
  await self.session.read_full_until_idle(idle_timeout=1, total_timeout=1)
19
 
 
1
+ import platform
2
  import select
3
  import subprocess
4
  import time
5
  import sys
6
  from typing import Optional, Tuple
7
+ from python.helpers import tty_session, runtime
8
  from python.helpers.shell_ssh import clean_string
9
 
10
  class LocalInteractiveSession:
 
14
  self.cwd = cwd
15
 
16
  async def connect(self):
17
+ self.session = tty_session.TTYSession(runtime.get_terminal_executable(), cwd=self.cwd)
18
  await self.session.start()
19
  await self.session.read_full_until_idle(idle_timeout=1, total_timeout=1)
20
 
python/helpers/tty_session.py CHANGED
@@ -204,55 +204,66 @@ async def _spawn_posix_pty(cmd, cwd, env, echo):
204
 
205
 
206
  async def _spawn_winpty(cmd, cwd, env, echo):
207
- # A quick way to silence command echo in cmd.exe is /Q (quiet)
208
- if not echo and cmd.strip().lower().startswith("cmd") and "/q" not in cmd.lower():
209
- cmd = cmd.replace("cmd.exe", "cmd.exe /Q")
 
210
 
211
  cols, rows = 80, 25
212
- pty = winpty.PTY(cols, rows) # type: ignore
213
- # winpty expects env as None or list of "KEY=VALUE" strings, not a dict
214
- winpty_env = None if env is None else [f"{k}={v}" for k, v in env.items()]
215
- child = pty.spawn(cmd, cwd=cwd or os.getcwd(), env=winpty_env)
216
-
217
- master_r_fd = msvcrt.open_osfhandle(child.conout_pipe, os.O_RDONLY) # type: ignore
218
- master_w_fd = msvcrt.open_osfhandle(child.conin_pipe, 0) # type: ignore
219
 
220
  loop = asyncio.get_running_loop()
221
  reader = asyncio.StreamReader()
222
 
223
- def _on_data():
224
- try:
225
- data = os.read(master_r_fd, 1 << 16)
226
- except OSError:
227
- data = b""
228
- if data:
229
- reader.feed_data(data)
230
- else:
231
- reader.feed_eof()
232
- loop.remove_reader(master_r_fd)
 
 
233
 
234
- loop.add_reader(master_r_fd, _on_data)
 
235
 
236
  class _Stdin:
237
  def write(self, d):
238
- os.write(master_w_fd, d)
 
 
 
 
 
 
239
 
240
  async def drain(self):
241
- await asyncio.sleep(0)
242
 
243
- class _Proc(asyncio.subprocess.Process):
244
  def __init__(self):
245
  self.stdin = _Stdin() # type: ignore
246
  self.stdout = reader
247
  self.pid = child.pid
 
248
 
249
  async def wait(self):
250
  while child.isalive():
251
  await asyncio.sleep(0.2)
 
252
  return 0
253
 
 
 
 
 
254
  def kill(self):
255
- child.kill()
 
256
 
257
  return _Proc()
258
 
@@ -261,7 +272,7 @@ async def _spawn_winpty(cmd, cwd, env, echo):
261
  if __name__ == "__main__":
262
 
263
  async def interactive_shell():
264
- shell_cmd, prompt_hint = ("cmd.exe", "$") if _IS_WIN else ("/bin/bash", "$")
265
 
266
  # echo=False → suppress the shell’s own echo of commands
267
  term = TTYSession(shell_cmd)
 
204
 
205
 
206
  async def _spawn_winpty(cmd, cwd, env, echo):
207
+ # Clean PowerShell startup: no logo, no profile, bypass execution policy for deterministic behavior
208
+ if cmd.strip().lower().startswith("powershell"):
209
+ if "-nolog" not in cmd.lower():
210
+ cmd = cmd.replace("powershell.exe", "powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass", 1)
211
 
212
  cols, rows = 80, 25
213
+ child = winpty.PtyProcess.spawn(cmd, dimensions=(rows, cols), cwd=cwd or os.getcwd(), env=env) # type: ignore
 
 
 
 
 
 
214
 
215
  loop = asyncio.get_running_loop()
216
  reader = asyncio.StreamReader()
217
 
218
+ async def _on_data():
219
+ while child.isalive():
220
+ try:
221
+ # Run blocking read in executor to not block event loop
222
+ data = await loop.run_in_executor(None, child.read, 1 << 16)
223
+ if data:
224
+ reader.feed_data(data.encode('utf-8') if isinstance(data, str) else data)
225
+ except EOFError:
226
+ break
227
+ except Exception:
228
+ await asyncio.sleep(0.01)
229
+ reader.feed_eof()
230
 
231
+ # Start pumping output in background
232
+ asyncio.create_task(_on_data())
233
 
234
  class _Stdin:
235
  def write(self, d):
236
+ # Use winpty's write method, not os.write
237
+ if isinstance(d, bytes):
238
+ d = d.decode('utf-8', errors='replace')
239
+ # Windows needs \r\n for proper line endings
240
+ if _IS_WIN:
241
+ d = d.replace('\n', '\r\n')
242
+ child.write(d)
243
 
244
  async def drain(self):
245
+ await asyncio.sleep(0.01) # Give write time to complete
246
 
247
+ class _Proc:
248
  def __init__(self):
249
  self.stdin = _Stdin() # type: ignore
250
  self.stdout = reader
251
  self.pid = child.pid
252
+ self.returncode = None
253
 
254
  async def wait(self):
255
  while child.isalive():
256
  await asyncio.sleep(0.2)
257
+ self.returncode = 0
258
  return 0
259
 
260
+ def terminate(self):
261
+ if child.isalive():
262
+ child.terminate()
263
+
264
  def kill(self):
265
+ if child.isalive():
266
+ child.kill()
267
 
268
  return _Proc()
269
 
 
272
  if __name__ == "__main__":
273
 
274
  async def interactive_shell():
275
+ shell_cmd, prompt_hint = ("powershell.exe", ">") if _IS_WIN else ("/bin/bash", "$")
276
 
277
  # echo=False → suppress the shell’s own echo of commands
278
  term = TTYSession(shell_cmd)
python/tools/code_execution_tool.py CHANGED
@@ -3,7 +3,7 @@ from dataclasses import dataclass
3
  import shlex
4
  import time
5
  from python.helpers.tool import Tool, Response
6
- from python.helpers import files, rfc_exchange, projects
7
  from python.helpers.print_style import PrintStyle
8
  from python.helpers.shell_local import LocalInteractiveSession
9
  from python.helpers.shell_ssh import SSHInteractiveSession
@@ -12,7 +12,6 @@ from python.helpers.strings import truncate_text as truncate_text_string
12
  from python.helpers.messages import truncate_text as truncate_text_agent
13
  import re
14
 
15
-
16
  # Timeouts for python, nodejs, and terminal runtimes.
17
  CODE_EXEC_TIMEOUTS: dict[str, int] = {
18
  "first_output_timeout": 30,
@@ -48,6 +47,7 @@ class CodeExecution(Tool):
48
  re.compile(r"\\(venv\\).+[$#] ?$"), # (venv) ...$ or (venv) ...#
49
  re.compile(r"root@[^:]+:[^#]+# ?$"), # root@container:~#
50
  re.compile(r"[a-zA-Z0-9_.-]+@[^:]+:[^$#]+[$#] ?$"), # user@host:~$
 
51
  ]
52
  # potential dialog detection
53
  dialog_patterns = [
@@ -173,7 +173,7 @@ class CodeExecution(Tool):
173
  async def execute_terminal_command(
174
  self, session: int, command: str, reset: bool = False
175
  ):
176
- prefix = "bash> " + self.format_command_for_output(command) + "\n\n"
177
  return await self.terminal_session(session, command, reset, prefix)
178
 
179
  async def terminal_session(
@@ -467,7 +467,7 @@ class CodeExecution(Tool):
467
  # remove any single byte \xXX escapes
468
  output = re.sub(r"(?<!\\)\\x[0-9A-Fa-f]{2}", "", output)
469
  # Strip every line of output before truncation
470
- output = "\n".join(line.strip() for line in output.splitlines())
471
  output = truncate_text_agent(agent=self.agent, output=output, threshold=1000000) # ~1MB, larger outputs should be dumped to file, not read from terminal
472
  return output
473
 
 
3
  import shlex
4
  import time
5
  from python.helpers.tool import Tool, Response
6
+ from python.helpers import files, rfc_exchange, projects, runtime
7
  from python.helpers.print_style import PrintStyle
8
  from python.helpers.shell_local import LocalInteractiveSession
9
  from python.helpers.shell_ssh import SSHInteractiveSession
 
12
  from python.helpers.messages import truncate_text as truncate_text_agent
13
  import re
14
 
 
15
  # Timeouts for python, nodejs, and terminal runtimes.
16
  CODE_EXEC_TIMEOUTS: dict[str, int] = {
17
  "first_output_timeout": 30,
 
47
  re.compile(r"\\(venv\\).+[$#] ?$"), # (venv) ...$ or (venv) ...#
48
  re.compile(r"root@[^:]+:[^#]+# ?$"), # root@container:~#
49
  re.compile(r"[a-zA-Z0-9_.-]+@[^:]+:[^$#]+[$#] ?$"), # user@host:~$
50
+ re.compile(r"\(?.*\)?\s*PS\s+[^>]+> ?$"), # PowerShell prompt like (base) PS C:\...>
51
  ]
52
  # potential dialog detection
53
  dialog_patterns = [
 
173
  async def execute_terminal_command(
174
  self, session: int, command: str, reset: bool = False
175
  ):
176
+ prefix = ("bash>" if not runtime.is_windows() or self.agent.config.code_exec_ssh_enabled else "PS>") + self.format_command_for_output(command) + "\n\n"
177
  return await self.terminal_session(session, command, reset, prefix)
178
 
179
  async def terminal_session(
 
467
  # remove any single byte \xXX escapes
468
  output = re.sub(r"(?<!\\)\\x[0-9A-Fa-f]{2}", "", output)
469
  # Strip every line of output before truncation
470
+ # output = "\n".join(line.strip() for line in output.splitlines())
471
  output = truncate_text_agent(agent=self.agent, output=output, threshold=1000000) # ~1MB, larger outputs should be dumped to file, not read from terminal
472
  return output
473
 
requirements.txt CHANGED
@@ -48,3 +48,4 @@ exchangelib>=5.4.3
48
  pytest>=8.4.2
49
  pytest-asyncio>=1.2.0
50
  pytest-mock>=3.15.1
 
 
48
  pytest>=8.4.2
49
  pytest-asyncio>=1.2.0
50
  pytest-mock>=3.15.1
51
+ pywinpty==3.0.2; sys_platform == "win32"