tty session prototype

frdel committed Aug 1, 2025 at 12:28 UTC 2565497da030a3e80601fac1cfa82577c8cdd4a3
4 files changed +544 -3
python/helpers/tty_session.py new
+273
@@ -0,0 +1,273 @@
1 +import asyncio, os, sys, platform, errno
2 +
3 +_IS_WIN = platform.system() == "Windows"
4 +if _IS_WIN:
5 + import winpty # pip install pywinpty # type: ignore
6 + import msvcrt
7 +
8 +
9 +# Make stdin / stdout tolerant to broken UTF-8 so input() never aborts
10 +sys.stdin.reconfigure(errors="replace") # type: ignore
11 +sys.stdout.reconfigure(errors="replace") # type: ignore
12 +
13 +
14 +# ──────────────────────────── PUBLIC CLASS ────────────────────────────
15 +
16 +
17 +class TTYSession:
18 + def __init__(
19 + self, cmd, *, cwd=None, env=None, encoding="utf-8", echo=False
20 + ): # ← NEW kw-arg `echo`
21 + self.cmd = cmd if isinstance(cmd, str) else " ".join(cmd)
22 + self.cwd = cwd
23 + self.env = env or os.environ.copy()
24 + self.encoding = encoding
25 + self.echo = echo # ← store preference
26 + self._proc = None
27 + self._buf = asyncio.Queue()
28 +
29 + # ── user-facing coroutines ────────────────────────────────────────
30 + async def start(self):
31 + if _IS_WIN:
32 + self._proc = await _spawn_winpty(
33 + self.cmd, self.cwd, self.env, self.echo
34 + ) # ← pass echo
35 + else:
36 + self._proc = await _spawn_posix_pty(
37 + self.cmd, self.cwd, self.env, self.echo
38 + ) # ← pass echo
39 + asyncio.create_task(self._pump_stdout())
40 +
41 + async def send(self, data: str | bytes):
42 + if self._proc is None:
43 + raise RuntimeError("TTYSpawn is not started")
44 + if isinstance(data, str):
45 + data = data.encode(self.encoding)
46 + self._proc.stdin.write(data) # type: ignore
47 + await self._proc.stdin.drain() # type: ignore
48 +
49 + async def sendline(self, line: str):
50 + await self.send(line + "\n")
51 +
52 + async def wait(self):
53 + if self._proc is None:
54 + raise RuntimeError("TTYSpawn is not started")
55 + return await self._proc.wait()
56 +
57 + def kill(self):
58 + if self._proc is None:
59 + raise RuntimeError("TTYSpawn is not started")
60 + self._proc.kill()
61 +
62 + async def read(self, timeout=None):
63 + # Return any decoded text the child produced, or None on timeout
64 + try:
65 + return await asyncio.wait_for(self._buf.get(), timeout)
66 + except asyncio.TimeoutError:
67 + return None
68 +
69 + # backward-compat alias:
70 + readline = read
71 +
72 + async def read_full_until_idle(self, idle_timeout, total_timeout):
73 + # Collect child output using iter_until_idle to avoid duplicate logic
74 + return "".join(
75 + [
76 + chunk
77 + async for chunk in self.read_chunks_until_idle(
78 + idle_timeout, total_timeout
79 + )
80 + ]
81 + )
82 +
83 + async def read_chunks_until_idle(self, idle_timeout, total_timeout):
84 + # Yield each chunk as soon as it arrives until idle or total timeout
85 + import time
86 +
87 + start = time.monotonic()
88 + while True:
89 + if time.monotonic() - start > total_timeout:
90 + break
91 + chunk = await self.read(timeout=idle_timeout)
92 + if chunk is None:
93 + break
94 + yield chunk
95 +
96 + # ── internal: stream raw output into the queue ────────────────────
97 + async def _pump_stdout(self):
98 + if self._proc is None:
99 + raise RuntimeError("TTYSpawn is not started")
100 + reader = self._proc.stdout
101 + while True:
102 + chunk = await reader.read(4096) # grab whatever is ready # type: ignore
103 + if not chunk:
104 + break
105 + self._buf.put_nowait(chunk.decode(self.encoding, "replace"))
106 +
107 +
108 +# ──────────────────────────── POSIX IMPLEMENTATION ────────────────────
109 +
110 +
111 +async def _spawn_posix_pty(cmd, cwd, env, echo):
112 + import pty, asyncio, os, termios
113 +
114 + master, slave = pty.openpty()
115 +
116 + # ── Disable ECHO on the slave side if requested ──
117 + if not echo:
118 + attrs = termios.tcgetattr(slave)
119 + attrs[3] &= ~termios.ECHO # lflag
120 + termios.tcsetattr(slave, termios.TCSANOW, attrs)
121 +
122 + proc = await asyncio.create_subprocess_shell(
123 + cmd,
124 + stdin=slave,
125 + stdout=slave,
126 + stderr=slave,
127 + cwd=cwd,
128 + env=env,
129 + close_fds=True,
130 + )
131 + os.close(slave)
132 +
133 + loop = asyncio.get_running_loop()
134 + reader = asyncio.StreamReader()
135 +
136 + def _on_data():
137 + try:
138 + data = os.read(master, 1 << 16)
139 + except OSError as e:
140 + if e.errno != errno.EIO: # EIO == EOF on some systems
141 + raise
142 + data = b""
143 + if data:
144 + reader.feed_data(data)
145 + else:
146 + reader.feed_eof()
147 + loop.remove_reader(master)
148 +
149 + loop.add_reader(master, _on_data)
150 +
151 + class _Stdin:
152 + def write(self, d):
153 + os.write(master, d)
154 +
155 + async def drain(self):
156 + await asyncio.sleep(0)
157 +
158 + proc.stdin = _Stdin() # type: ignore
159 + proc.stdout = reader
160 + return proc
161 +
162 +
163 +# ──────────────────────────── WINDOWS IMPLEMENTATION ──────────────────
164 +
165 +
166 +async def _spawn_winpty(cmd, cwd, env, echo):
167 + # A quick way to silence command echo in cmd.exe is /Q (quiet)
168 + if not echo and cmd.strip().lower().startswith("cmd") and "/q" not in cmd.lower():
169 + cmd = cmd.replace("cmd.exe", "cmd.exe /Q")
170 +
171 + cols, rows = 80, 25
172 + pty = winpty.PTY(cols, rows) # type: ignore
173 + child = pty.spawn(cmd, cwd=cwd or os.getcwd(), env=env)
174 +
175 + master_r_fd = msvcrt.open_osfhandle(child.conout_pipe, os.O_RDONLY) # type: ignore
176 + master_w_fd = msvcrt.open_osfhandle(child.conin_pipe, 0) # type: ignore
177 +
178 + loop = asyncio.get_running_loop()
179 + reader = asyncio.StreamReader()
180 +
181 + def _on_data():
182 + try:
183 + data = os.read(master_r_fd, 1 << 16)
184 + except OSError:
185 + data = b""
186 + if data:
187 + reader.feed_data(data)
188 + else:
189 + reader.feed_eof()
190 + loop.remove_reader(master_r_fd)
191 +
192 + loop.add_reader(master_r_fd, _on_data)
193 +
194 + class _Stdin:
195 + def write(self, d):
196 + os.write(master_w_fd, d)
197 +
198 + async def drain(self):
199 + await asyncio.sleep(0)
200 +
201 + class _Proc(asyncio.subprocess.Process):
202 + def __init__(self):
203 + self.stdin = _Stdin() # type: ignore
204 + self.stdout = reader
205 + self.pid = child.pid
206 +
207 + async def wait(self):
208 + while child.isalive():
209 + await asyncio.sleep(0.2)
210 + return 0
211 +
212 + def kill(self):
213 + child.kill()
214 +
215 + return _Proc()
216 +
217 +
218 +# ───────────────────────── INTERACTIVE DRIVER ─────────────────────────
219 +if __name__ == "__main__":
220 +
221 + async def interactive_shell():
222 + shell_cmd, prompt_hint = ("cmd.exe", "$") if _IS_WIN else ("/bin/bash", "$")
223 +
224 + # echo=False → suppress the shell’s own echo of commands
225 + term = TTYSession(shell_cmd)
226 + await term.start()
227 +
228 + timeout = 1.0
229 +
230 + print(f"Connected to {shell_cmd}.")
231 + print("Type commands for the shell.")
232 + print("• /t=<seconds> → change idle timeout")
233 + print("• /exit → quit helper\n")
234 +
235 + await term.sendline(" ")
236 + print(await term.read_full_until_idle(timeout, timeout), end="", flush=True)
237 +
238 + while True:
239 + try:
240 + user = input(f"(timeout={timeout}) {prompt_hint} ")
241 + except (EOFError, KeyboardInterrupt):
242 + print("\nLeaving…")
243 + break
244 +
245 + if user.lower() == "/exit":
246 + break
247 + if user.startswith("/t="):
248 + try:
249 + timeout = float(user.split("=", 1)[1])
250 + print(f"[helper] idle timeout set to {timeout}s")
251 + except ValueError:
252 + print("[helper] invalid number")
253 + continue
254 +
255 + idle_timeout = timeout
256 + total_timeout = 10 * idle_timeout
257 + if user == "":
258 + # Just read output, do not send empty line
259 + async for chunk in term.read_chunks_until_idle(
260 + idle_timeout, total_timeout
261 + ):
262 + print(chunk, end="", flush=True)
263 + else:
264 + await term.sendline(user)
265 + async for chunk in term.read_chunks_until_idle(
266 + idle_timeout, total_timeout
267 + ):
268 + print(chunk, end="", flush=True)
269 +
270 + await term.sendline("exit")
271 + await term.wait()
272 +
273 + asyncio.run(interactive_shell())
tests/rate_limiter_test.py
+1 -2
@@ -1,6 +1,5 @@
1
2 -import sys
3 -import os
2 +import sys, os
3 sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
4 import models
5
tests/test_fasta2a_client.py renamed
+4 -1
@@ -3,8 +3,11 @@
3 Test script to verify FastA2A agent card routing and authentication.
4 """
5
6 +import sys, os
7 +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
8 +
9 +
10 import asyncio
7 -import sys
11 from python.helpers import settings
12
13
tests/tty_test.py new
+266
@@ -0,0 +1,266 @@
1 +"""
2 +tty_spawn.py – drive any console program through a real TTY
3 +cross-platform (Windows via pywinpty, POSIX via pty).
4 +
5 +API (async):
6 + term = TTYSpawn(cmd, *, cwd=None, env=None, encoding="utf-8")
7 + await term.start() # launch child
8 + chunk = await term.read(timeout=1.0) # None on timeout
9 + await term.send("data") # raw bytes
10 + await term.sendline("text") # adds '\n'
11 + await term.wait() # exit code
12 + term.kill() # abort
13 +"""
14 +# ────────────────── NO ORIGINAL LINES REMOVED – ONLY APPENDED CODE ──────────────────
15 +
16 +import asyncio, os, sys, platform, errno
17 +
18 +_IS_WIN = platform.system() == "Windows"
19 +if _IS_WIN:
20 + import winpty # pip install pywinpty
21 + import msvcrt
22 +
23 +
24 +# Make stdin / stdout tolerant to broken UTF-8 so input() never aborts
25 +try:
26 + # Python 3.7+ has reconfigure()
27 + sys.stdin .reconfigure(errors="replace")
28 + sys.stdout.reconfigure(errors="replace")
29 +except AttributeError:
30 + # Older Pythons: wrap them manually
31 + import io
32 + sys.stdin = io.TextIOWrapper(sys.stdin.buffer,
33 + encoding=sys.stdin.encoding or "utf-8",
34 + errors="replace",
35 + line_buffering=True)
36 + sys.stdout = io.TextIOWrapper(sys.stdout.buffer,
37 + encoding=sys.stdout.encoding or "utf-8",
38 + errors="replace",
39 + line_buffering=True)
40 +# ─────────────────────────────────────────────────────────────────────
41 +
42 +
43 +# ──────────────────────────── PUBLIC CLASS ────────────────────────────
44 +
45 +class TTYSpawn:
46 + def __init__(self, cmd, *, cwd=None, env=None,
47 + encoding="utf-8", echo=True): # ← NEW kw-arg `echo`
48 + self.cmd = cmd if isinstance(cmd, str) else " ".join(cmd)
49 + self.cwd = cwd
50 + self.env = env or os.environ.copy()
51 + self.encoding = encoding
52 + self.echo = echo # ← store preference
53 + self._proc = None
54 + self._buf = asyncio.Queue()
55 +
56 + # ── user-facing coroutines ────────────────────────────────────────
57 + async def start(self):
58 + if _IS_WIN:
59 + self._proc = await _spawn_winpty(
60 + self.cmd, self.cwd, self.env, self.echo) # ← pass echo
61 + else:
62 + self._proc = await _spawn_posix_pty(
63 + self.cmd, self.cwd, self.env, self.echo) # ← pass echo
64 + asyncio.create_task(self._pump_stdout())
65 +
66 + async def read(self, timeout=None):
67 + # Return any decoded text the child produced, or None on timeout
68 + try:
69 + return await asyncio.wait_for(self._buf.get(), timeout)
70 + except asyncio.TimeoutError:
71 + return None
72 +
73 + # backward-compat alias:
74 + readline = read
75 +
76 + async def send(self, data: str | bytes):
77 + if isinstance(data, str):
78 + data = data.encode(self.encoding)
79 + self._proc.stdin.write(data)
80 + await self._proc.stdin.drain()
81 +
82 + async def sendline(self, line: str):
83 + await self.send(line + "\n")
84 +
85 + async def wait(self):
86 + return await self._proc.wait()
87 +
88 + def kill(self):
89 + self._proc.kill()
90 +
91 + async def read_until_idle(self, idle_timeout, total_timeout):
92 + # Collect child output using iter_until_idle to avoid duplicate logic
93 + return "".join([chunk async for chunk in self.iter_until_idle(idle_timeout, total_timeout)])
94 +
95 + async def iter_until_idle(self, idle_timeout, total_timeout):
96 + # Yield each chunk as soon as it arrives until idle or total timeout
97 + import time
98 + start = time.monotonic()
99 + while True:
100 + if time.monotonic() - start > total_timeout:
101 + break
102 + chunk = await self.read(timeout=idle_timeout)
103 + if chunk is None:
104 + break
105 + yield chunk
106 +
107 + # ── internal: stream raw output into the queue ────────────────────
108 + async def _pump_stdout(self):
109 + reader = self._proc.stdout
110 + while True:
111 + chunk = await reader.read(4096) # grab whatever is ready
112 + if not chunk:
113 + break
114 + self._buf.put_nowait(chunk.decode(self.encoding, "replace"))
115 +
116 +# ──────────────────────────── POSIX IMPLEMENTATION ────────────────────
117 +
118 +async def _spawn_posix_pty(cmd, cwd, env, echo):
119 + import pty, asyncio, os, termios
120 + master, slave = pty.openpty()
121 +
122 + # ── Disable ECHO on the slave side if requested ──
123 + if not echo:
124 + attrs = termios.tcgetattr(slave)
125 + attrs[3] &= ~termios.ECHO # lflag
126 + termios.tcsetattr(slave, termios.TCSANOW, attrs)
127 +
128 + proc = await asyncio.create_subprocess_shell(
129 + cmd,
130 + stdin=slave,
131 + stdout=slave,
132 + stderr=slave,
133 + cwd=cwd,
134 + env=env,
135 + close_fds=True,
136 + )
137 + os.close(slave)
138 +
139 + loop = asyncio.get_running_loop()
140 + reader = asyncio.StreamReader()
141 +
142 + def _on_data():
143 + try:
144 + data = os.read(master, 1 << 16)
145 + except OSError as e:
146 + if e.errno != errno.EIO: # EIO == EOF on some systems
147 + raise
148 + data = b""
149 + if data:
150 + reader.feed_data(data)
151 + else:
152 + reader.feed_eof()
153 + loop.remove_reader(master)
154 +
155 + loop.add_reader(master, _on_data)
156 +
157 + class _Stdin:
158 + def write(self, d): os.write(master, d)
159 + async def drain(self): await asyncio.sleep(0)
160 +
161 + proc.stdin = _Stdin()
162 + proc.stdout = reader
163 + return proc
164 +
165 +# ──────────────────────────── WINDOWS IMPLEMENTATION ──────────────────
166 +
167 +async def _spawn_winpty(cmd, cwd, env, echo):
168 + # A quick way to silence command echo in cmd.exe is /Q (quiet)
169 + if not echo and cmd.strip().lower().startswith("cmd") and "/q" not in cmd.lower():
170 + cmd = cmd.replace("cmd.exe", "cmd.exe /Q")
171 +
172 + cols, rows = 80, 25
173 + pty = winpty.PTY(cols, rows)
174 + child = pty.spawn(cmd, cwd=cwd or os.getcwd(), env=env)
175 +
176 + master_r_fd = msvcrt.open_osfhandle(child.conout_pipe, os.O_RDONLY)
177 + master_w_fd = msvcrt.open_osfhandle(child.conin_pipe, 0)
178 +
179 + loop = asyncio.get_running_loop()
180 + reader = asyncio.StreamReader()
181 +
182 + def _on_data():
183 + try:
184 + data = os.read(master_r_fd, 1 << 16)
185 + except OSError:
186 + data = b""
187 + if data:
188 + reader.feed_data(data)
189 + else:
190 + reader.feed_eof()
191 + loop.remove_reader(master_r_fd)
192 +
193 + loop.add_reader(master_r_fd, _on_data)
194 +
195 + class _Stdin:
196 + def write(self, d): os.write(master_w_fd, d)
197 + async def drain(self): await asyncio.sleep(0)
198 +
199 + class _Proc(asyncio.subprocess.Process):
200 + def __init__(self):
201 + self.stdin = _Stdin()
202 + self.stdout = reader
203 + self.pid = child.pid
204 + async def wait(self):
205 + while child.isalive():
206 + await asyncio.sleep(0.2)
207 + return 0
208 + def kill(self):
209 + child.kill()
210 +
211 + return _Proc()
212 +
213 +# ───────────────────────── INTERACTIVE DRIVER ─────────────────────────
214 +
215 +async def interactive_shell():
216 + shell_cmd, prompt_hint = (
217 + ("cmd.exe", "$") if _IS_WIN else ("/bin/bash", "$")
218 + )
219 +
220 + # echo=False → suppress the shell’s own echo of commands
221 + term = TTYSpawn(shell_cmd, echo=False)
222 + await term.start()
223 +
224 + timeout = 1.0
225 +
226 + print(f"Connected to {shell_cmd}.")
227 + print("Type commands for the shell.")
228 + print("• t=<seconds> → change idle timeout")
229 + print("• exit → quit helper\n")
230 +
231 + await term.sendline(" ")
232 + print(await term.read_until_idle(timeout, timeout), end="", flush=True)
233 +
234 + while True:
235 + try:
236 + user = input(f"(timeout={timeout}) {prompt_hint} ")
237 + except (EOFError, KeyboardInterrupt):
238 + print("\nLeaving…")
239 + break
240 +
241 + if user.lower() == "/exit":
242 + break
243 + if user.startswith("/t="):
244 + try:
245 + timeout = float(user.split("=", 1)[1])
246 + print(f"[helper] idle timeout set to {timeout}s")
247 + except ValueError:
248 + print("[helper] invalid number")
249 + continue
250 +
251 + idle_timeout = timeout
252 + total_timeout = 10 * idle_timeout
253 + if user == "":
254 + # Just read output, do not send empty line
255 + async for chunk in term.iter_until_idle(idle_timeout, total_timeout):
256 + print(chunk, end="", flush=True)
257 + else:
258 + await term.sendline(user)
259 + async for chunk in term.iter_until_idle(idle_timeout, total_timeout):
260 + print(chunk, end="", flush=True)
261 +
262 + await term.sendline("exit")
263 + await term.wait()
264 +
265 +if __name__ == "__main__":
266 + asyncio.run(interactive_shell())