@neoline/hostbridge 2.0.4 → 2.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -9
- package/docs/ARCHITECTURE.md +62 -0
- package/package.json +2 -1
- package/pyproject.toml +1 -1
- package/src/server_control_mcp/__init__.py +4 -3
- package/src/server_control_mcp/async_lifecycle.py +41 -0
- package/src/server_control_mcp/cli.py +1 -1
- package/src/server_control_mcp/client.py +302 -287
- package/src/server_control_mcp/config.py +2 -1
- package/src/server_control_mcp/daemon.py +233 -526
- package/src/server_control_mcp/doctor.py +34 -2
- package/src/server_control_mcp/hosts.py +136 -2
- package/src/server_control_mcp/mock_ssh.py +52 -14
- package/src/server_control_mcp/mock_ssh_exec.py +147 -58
- package/src/server_control_mcp/mock_ssh_session.py +3 -2
- package/src/server_control_mcp/mock_ssh_sftp.py +126 -28
- package/src/server_control_mcp/mock_ssh_tunnel.py +141 -444
- package/src/server_control_mcp/mux_connection.py +326 -0
- package/src/server_control_mcp/mux_daemon.py +186 -0
- package/src/server_control_mcp/mux_protocol.py +279 -0
- package/src/server_control_mcp/mux_records.py +71 -0
- package/src/server_control_mcp/mux_rpc.py +1779 -0
- package/src/server_control_mcp/mux_service.py +506 -0
- package/src/server_control_mcp/mux_stream.py +283 -0
- package/src/server_control_mcp/mux_sync_client.py +224 -0
- package/src/server_control_mcp/remote_agent_bundle.py +56 -0
- package/src/server_control_mcp/remote_mux_agent.py +769 -0
- package/src/server_control_mcp/runtime_services.py +862 -0
- package/src/server_control_mcp/server.py +33 -18
- package/src/server_control_mcp/services.py +2 -666
- package/src/server_control_mcp/transports/__init__.py +4 -8
- package/src/server_control_mcp/transports/base.py +60 -38
- package/src/server_control_mcp/transports/ssh.py +206 -259
- package/src/server_control_mcp/tunnel_manager.py +1245 -0
- package/src/server_control_mcp/tunnel_native_ssh.py +454 -0
- package/src/server_control_mcp/tunnel_providers.py +20 -0
- package/src/server_control_mcp/tunnel_pty_agent.py +909 -0
- package/src/server_control_mcp/protocol.py +0 -363
- package/src/server_control_mcp/remote_tunnel_agent.py +0 -143
- package/src/server_control_mcp/transports/pty.py +0 -515
|
@@ -0,0 +1,909 @@
|
|
|
1
|
+
"""Persistent raw-binary PTY tunnel provider and bootstrap transport."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import errno
|
|
7
|
+
import hashlib
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import secrets
|
|
11
|
+
import shlex
|
|
12
|
+
import signal
|
|
13
|
+
from collections.abc import AsyncGenerator, Callable
|
|
14
|
+
from contextlib import suppress
|
|
15
|
+
from re import Pattern
|
|
16
|
+
|
|
17
|
+
from .async_lifecycle import finish_task_before_cancellation, stop_input_after_peer_eof
|
|
18
|
+
from .hosts import ForwardingPolicy, HostConfig
|
|
19
|
+
from .mux_records import MuxRecordChannel, MuxRecordDecoder
|
|
20
|
+
from .mux_rpc import (
|
|
21
|
+
DEFAULT_CONNECTION_WINDOW_BYTES,
|
|
22
|
+
DEFAULT_STREAM_WINDOW_BYTES,
|
|
23
|
+
MuxRpcClient,
|
|
24
|
+
)
|
|
25
|
+
from .mux_stream import MuxRpcStream
|
|
26
|
+
from .remote_agent_bundle import build_remote_agent_bundle
|
|
27
|
+
from .transports.base import (
|
|
28
|
+
ByteStream,
|
|
29
|
+
ExecResult,
|
|
30
|
+
ShellAttachment,
|
|
31
|
+
ShellReadResult,
|
|
32
|
+
ShellTerminalResult,
|
|
33
|
+
TransferResult,
|
|
34
|
+
TransportCapabilities,
|
|
35
|
+
iterate_byte_stream,
|
|
36
|
+
)
|
|
37
|
+
from .tunnel_manager import TunnelBackendError
|
|
38
|
+
|
|
39
|
+
MAX_BOOTSTRAP_BUFFER_BYTES = 1024 * 1024
|
|
40
|
+
MAX_PTY_WRITE_BUFFER_BYTES = 2 * 1024 * 1024
|
|
41
|
+
PTY_IO_CHUNK_BYTES = 64 * 1024
|
|
42
|
+
PROCESS_TERM_TIMEOUT_SECONDS = 1.0
|
|
43
|
+
MAX_BOOTSTRAP_ERROR_BYTES = 64
|
|
44
|
+
|
|
45
|
+
_BOOTSTRAP = r"""import hashlib,os,runpy,sys,tempfile,termios,tty
|
|
46
|
+
q=sys.argv[1].encode();n=int(sys.argv[2]);h=sys.argv[3];t=termios.tcgetattr(0);p=None;f=-1
|
|
47
|
+
try:
|
|
48
|
+
tty.setraw(0);e=os.open(os.devnull,os.O_WRONLY);os.dup2(e,2);os.close(e);os.write(1,b"\0HBRAW2:"+q+b"\0");f,p=tempfile.mkstemp(prefix="hostbridge-agent-",suffix=".pyz");d=hashlib.sha256()
|
|
49
|
+
while n:
|
|
50
|
+
b=os.read(0,min(n,65536))
|
|
51
|
+
if not b:raise EOFError()
|
|
52
|
+
os.write(f,b);d.update(b);n-=len(b)
|
|
53
|
+
os.close(f);f=-1
|
|
54
|
+
if d.hexdigest()!=h:raise ValueError("hash mismatch")
|
|
55
|
+
os.write(1,b"\0HBOK2:"+q+b"\0");runpy.run_path(p,run_name="__main__")
|
|
56
|
+
finally:
|
|
57
|
+
if f>=0:os.close(f)
|
|
58
|
+
if p:
|
|
59
|
+
try:os.unlink(p)
|
|
60
|
+
except FileNotFoundError:pass
|
|
61
|
+
termios.tcsetattr(0,termios.TCSANOW,t)
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class _AsyncPtyWriter:
|
|
66
|
+
def __init__(self, owner: AsyncPtyByteStream) -> None:
|
|
67
|
+
self._owner = owner
|
|
68
|
+
|
|
69
|
+
def write(self, data: bytes) -> None:
|
|
70
|
+
self._owner.write(data)
|
|
71
|
+
|
|
72
|
+
async def drain(self) -> None:
|
|
73
|
+
await self._owner.drain()
|
|
74
|
+
|
|
75
|
+
def close(self) -> None:
|
|
76
|
+
self._owner.close_nowait()
|
|
77
|
+
|
|
78
|
+
async def wait_closed(self) -> None:
|
|
79
|
+
await asyncio.shield(self._owner.closed_future)
|
|
80
|
+
|
|
81
|
+
def is_closing(self) -> bool:
|
|
82
|
+
return self._owner.closed
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class _PtyHandshakeReader(asyncio.StreamReader):
|
|
86
|
+
def __init__(self, reader: asyncio.StreamReader, nonce: bytes) -> None:
|
|
87
|
+
super().__init__()
|
|
88
|
+
self._reader = reader
|
|
89
|
+
self._failure_prefix = b"\x00HBFAIL2:" + nonce + b":"
|
|
90
|
+
self._pending = bytearray()
|
|
91
|
+
self._handshake_complete = False
|
|
92
|
+
|
|
93
|
+
async def read(self, n: int = -1) -> bytes:
|
|
94
|
+
if self._handshake_complete:
|
|
95
|
+
if self._pending:
|
|
96
|
+
size = len(self._pending) if n < 0 else min(n, len(self._pending))
|
|
97
|
+
data = bytes(self._pending[:size])
|
|
98
|
+
del self._pending[:size]
|
|
99
|
+
return data
|
|
100
|
+
return await self._reader.read(n)
|
|
101
|
+
while True:
|
|
102
|
+
if self._pending.startswith(b"HBM2"):
|
|
103
|
+
self._handshake_complete = True
|
|
104
|
+
size = len(self._pending) if n < 0 else min(n, len(self._pending))
|
|
105
|
+
data = bytes(self._pending[:size])
|
|
106
|
+
del self._pending[:size]
|
|
107
|
+
return data
|
|
108
|
+
if self._pending.startswith(self._failure_prefix):
|
|
109
|
+
terminator = self._pending.find(b"\x00", len(self._failure_prefix))
|
|
110
|
+
if terminator >= 0:
|
|
111
|
+
raw_error = bytes(self._pending[len(self._failure_prefix) : terminator])
|
|
112
|
+
error = raw_error.decode("ascii", "replace") or "unknown error"
|
|
113
|
+
raise TunnelBackendError(f"remote agent bootstrap failed: {error}")
|
|
114
|
+
if len(self._pending) > len(self._failure_prefix) + MAX_BOOTSTRAP_ERROR_BYTES:
|
|
115
|
+
raise TunnelBackendError("remote agent returned an oversized bootstrap failure")
|
|
116
|
+
elif not (b"HBM2".startswith(self._pending) or self._failure_prefix.startswith(self._pending)):
|
|
117
|
+
raise TunnelBackendError("remote agent returned invalid bytes before the mux handshake")
|
|
118
|
+
|
|
119
|
+
data = await self._reader.read(64 * 1024)
|
|
120
|
+
if not data:
|
|
121
|
+
raise TunnelBackendError("remote agent closed before the mux handshake")
|
|
122
|
+
self._pending.extend(data)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class AsyncPtyByteStream:
|
|
126
|
+
def __init__(
|
|
127
|
+
self,
|
|
128
|
+
master_fd: int,
|
|
129
|
+
*,
|
|
130
|
+
max_bootstrap_buffer: int = MAX_BOOTSTRAP_BUFFER_BYTES,
|
|
131
|
+
max_write_buffer: int = MAX_PTY_WRITE_BUFFER_BYTES,
|
|
132
|
+
) -> None:
|
|
133
|
+
if master_fd < 0:
|
|
134
|
+
raise ValueError("master_fd must be non-negative")
|
|
135
|
+
if max_bootstrap_buffer <= 0 or max_write_buffer <= 0:
|
|
136
|
+
raise ValueError("PTY buffer limits must be positive")
|
|
137
|
+
self._loop = asyncio.get_running_loop()
|
|
138
|
+
self._fd = master_fd
|
|
139
|
+
self._max_bootstrap_buffer = max_bootstrap_buffer
|
|
140
|
+
self._max_write_buffer = max_write_buffer
|
|
141
|
+
self._bootstrap_buffer = bytearray()
|
|
142
|
+
self._write_buffer = bytearray()
|
|
143
|
+
self._data_ready = asyncio.Event()
|
|
144
|
+
self._write_drained = asyncio.Event()
|
|
145
|
+
self._write_drained.set()
|
|
146
|
+
self._reader: asyncio.StreamReader | None = None
|
|
147
|
+
self._handed_off = False
|
|
148
|
+
self._eof = False
|
|
149
|
+
self._closed = False
|
|
150
|
+
self.closed_future: asyncio.Future[None] = self._loop.create_future()
|
|
151
|
+
os.set_blocking(master_fd, False)
|
|
152
|
+
self._loop.add_reader(master_fd, self._read_ready)
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def closed(self) -> bool:
|
|
156
|
+
return self._closed
|
|
157
|
+
|
|
158
|
+
async def read_until(self, pattern: bytes | Pattern[bytes], *, timeout: float) -> bytes:
|
|
159
|
+
if self._handed_off:
|
|
160
|
+
raise RuntimeError("PTY byte stream has already been handed off")
|
|
161
|
+
if timeout <= 0:
|
|
162
|
+
raise ValueError("timeout must be positive")
|
|
163
|
+
compiled = re.compile(re.escape(pattern)) if isinstance(pattern, bytes) else pattern
|
|
164
|
+
try:
|
|
165
|
+
async with asyncio.timeout(timeout):
|
|
166
|
+
while True:
|
|
167
|
+
match = compiled.search(self._bootstrap_buffer)
|
|
168
|
+
if match is not None:
|
|
169
|
+
consumed = bytes(self._bootstrap_buffer[: match.end()])
|
|
170
|
+
del self._bootstrap_buffer[: match.end()]
|
|
171
|
+
return consumed
|
|
172
|
+
if self._eof or self._closed:
|
|
173
|
+
raise EOFError("PTY closed before bootstrap marker")
|
|
174
|
+
if len(self._bootstrap_buffer) > self._max_bootstrap_buffer:
|
|
175
|
+
raise TunnelBackendError("PTY bootstrap output exceeded its buffer limit")
|
|
176
|
+
self._data_ready.clear()
|
|
177
|
+
await self._data_ready.wait()
|
|
178
|
+
except TimeoutError as exc:
|
|
179
|
+
raise TunnelBackendError("PTY bootstrap timed out") from exc
|
|
180
|
+
|
|
181
|
+
def handoff(self) -> tuple[asyncio.StreamReader, _AsyncPtyWriter]:
|
|
182
|
+
if self._handed_off:
|
|
183
|
+
raise RuntimeError("PTY byte stream has already been handed off")
|
|
184
|
+
self._handed_off = True
|
|
185
|
+
self._reader = asyncio.StreamReader(limit=self._max_bootstrap_buffer)
|
|
186
|
+
if self._bootstrap_buffer:
|
|
187
|
+
self._reader.feed_data(bytes(self._bootstrap_buffer))
|
|
188
|
+
self._bootstrap_buffer.clear()
|
|
189
|
+
if self._eof or self._closed:
|
|
190
|
+
self._reader.feed_eof()
|
|
191
|
+
return self._reader, _AsyncPtyWriter(self)
|
|
192
|
+
|
|
193
|
+
def write(self, data: bytes) -> None:
|
|
194
|
+
if not isinstance(data, bytes):
|
|
195
|
+
raise TypeError("PTY writes must be bytes")
|
|
196
|
+
if not data:
|
|
197
|
+
return
|
|
198
|
+
if self._closed:
|
|
199
|
+
raise ConnectionResetError("PTY byte stream is closed")
|
|
200
|
+
if len(self._write_buffer) + len(data) > self._max_write_buffer:
|
|
201
|
+
raise BufferError("PTY write buffer limit exceeded")
|
|
202
|
+
self._write_drained.clear()
|
|
203
|
+
self._write_buffer.extend(data)
|
|
204
|
+
self._flush_write()
|
|
205
|
+
|
|
206
|
+
async def drain(self) -> None:
|
|
207
|
+
while self._write_buffer and not self._closed:
|
|
208
|
+
await self._write_drained.wait()
|
|
209
|
+
if self._closed and self._write_buffer:
|
|
210
|
+
raise ConnectionResetError("PTY closed before buffered data was written")
|
|
211
|
+
|
|
212
|
+
async def close(self) -> None:
|
|
213
|
+
self.close_nowait()
|
|
214
|
+
await asyncio.shield(self.closed_future)
|
|
215
|
+
|
|
216
|
+
def close_nowait(self) -> None:
|
|
217
|
+
if self._closed:
|
|
218
|
+
return
|
|
219
|
+
self._closed = True
|
|
220
|
+
self._remove_watchers()
|
|
221
|
+
with suppress(OSError):
|
|
222
|
+
os.close(self._fd)
|
|
223
|
+
self._eof = True
|
|
224
|
+
self._data_ready.set()
|
|
225
|
+
self._write_drained.set()
|
|
226
|
+
if self._reader is not None:
|
|
227
|
+
self._reader.feed_eof()
|
|
228
|
+
if not self.closed_future.done():
|
|
229
|
+
self.closed_future.set_result(None)
|
|
230
|
+
|
|
231
|
+
def _read_ready(self) -> None:
|
|
232
|
+
while not self._closed:
|
|
233
|
+
try:
|
|
234
|
+
data = os.read(self._fd, PTY_IO_CHUNK_BYTES)
|
|
235
|
+
except BlockingIOError:
|
|
236
|
+
return
|
|
237
|
+
except OSError as exc:
|
|
238
|
+
if exc.errno == errno.EIO:
|
|
239
|
+
self._mark_eof()
|
|
240
|
+
return
|
|
241
|
+
else:
|
|
242
|
+
self.close_nowait()
|
|
243
|
+
return
|
|
244
|
+
if not data:
|
|
245
|
+
self._mark_eof()
|
|
246
|
+
return
|
|
247
|
+
if self._reader is None:
|
|
248
|
+
self._bootstrap_buffer.extend(data)
|
|
249
|
+
else:
|
|
250
|
+
self._reader.feed_data(data)
|
|
251
|
+
self._data_ready.set()
|
|
252
|
+
|
|
253
|
+
def _flush_write(self) -> None:
|
|
254
|
+
while self._write_buffer and not self._closed:
|
|
255
|
+
try:
|
|
256
|
+
written = os.write(self._fd, self._write_buffer)
|
|
257
|
+
except BlockingIOError:
|
|
258
|
+
self._loop.add_writer(self._fd, self._write_ready)
|
|
259
|
+
return
|
|
260
|
+
except OSError:
|
|
261
|
+
self.close_nowait()
|
|
262
|
+
return
|
|
263
|
+
del self._write_buffer[:written]
|
|
264
|
+
if not self._write_buffer:
|
|
265
|
+
with suppress(Exception):
|
|
266
|
+
self._loop.remove_writer(self._fd)
|
|
267
|
+
self._write_drained.set()
|
|
268
|
+
|
|
269
|
+
def _write_ready(self) -> None:
|
|
270
|
+
self._flush_write()
|
|
271
|
+
|
|
272
|
+
def _remove_watchers(self) -> None:
|
|
273
|
+
with suppress(Exception):
|
|
274
|
+
self._loop.remove_reader(self._fd)
|
|
275
|
+
with suppress(Exception):
|
|
276
|
+
self._loop.remove_writer(self._fd)
|
|
277
|
+
|
|
278
|
+
def _mark_eof(self) -> None:
|
|
279
|
+
if self._eof:
|
|
280
|
+
return
|
|
281
|
+
self._eof = True
|
|
282
|
+
with suppress(Exception):
|
|
283
|
+
self._loop.remove_reader(self._fd)
|
|
284
|
+
self._data_ready.set()
|
|
285
|
+
if self._reader is not None:
|
|
286
|
+
self._reader.feed_eof()
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class PtyAgentTunnelEndpoint:
|
|
290
|
+
def __init__(self, stream: MuxRpcStream, closed_handler: Callable[[int], None]) -> None:
|
|
291
|
+
self._stream = stream
|
|
292
|
+
self.provider_stream_id = stream.stream_id
|
|
293
|
+
self._closed_handler = closed_handler
|
|
294
|
+
self._watch_task = asyncio.create_task(
|
|
295
|
+
self._watch_terminal(),
|
|
296
|
+
name=f"hostbridge-pty-agent-stream-{stream.stream_id}",
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
@property
|
|
300
|
+
def closed(self) -> bool:
|
|
301
|
+
return self._stream.closed
|
|
302
|
+
|
|
303
|
+
@property
|
|
304
|
+
def queued_bytes(self) -> int:
|
|
305
|
+
return self._stream.buffered_bytes
|
|
306
|
+
|
|
307
|
+
async def send(self, data: bytes) -> None:
|
|
308
|
+
await self._stream.send(data)
|
|
309
|
+
|
|
310
|
+
async def send_eof(self) -> None:
|
|
311
|
+
await self._stream.send_eof()
|
|
312
|
+
|
|
313
|
+
async def receive(self, max_bytes: int = PTY_IO_CHUNK_BYTES) -> bytes | None:
|
|
314
|
+
return await self._stream.receive(max_bytes)
|
|
315
|
+
|
|
316
|
+
async def close(self) -> None:
|
|
317
|
+
if not self._stream.closed:
|
|
318
|
+
await self._stream.reset("PTY tunnel stream closed")
|
|
319
|
+
await self._wait_watcher()
|
|
320
|
+
|
|
321
|
+
async def reset(self, reason: str) -> None:
|
|
322
|
+
if not self._stream.closed:
|
|
323
|
+
await self._stream.reset(reason)
|
|
324
|
+
await self._wait_watcher()
|
|
325
|
+
|
|
326
|
+
async def _watch_terminal(self) -> None:
|
|
327
|
+
try:
|
|
328
|
+
await self._stream.finish()
|
|
329
|
+
except BaseException:
|
|
330
|
+
pass
|
|
331
|
+
finally:
|
|
332
|
+
self._closed_handler(self.provider_stream_id)
|
|
333
|
+
|
|
334
|
+
async def _wait_watcher(self) -> None:
|
|
335
|
+
if self._watch_task is asyncio.current_task():
|
|
336
|
+
return
|
|
337
|
+
await asyncio.gather(self._watch_task, return_exceptions=True)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class PtyAgentHostRuntime:
|
|
341
|
+
provider_name = "pty_agent"
|
|
342
|
+
|
|
343
|
+
@property
|
|
344
|
+
def capabilities(self) -> TransportCapabilities:
|
|
345
|
+
return TransportCapabilities(
|
|
346
|
+
separate_stderr=True,
|
|
347
|
+
native_binary_transfer=True,
|
|
348
|
+
parallel_channels=self.host.forwarding.max_streams,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
def __init__(
|
|
352
|
+
self,
|
|
353
|
+
host: HostConfig,
|
|
354
|
+
*,
|
|
355
|
+
generation: int,
|
|
356
|
+
connect_timeout: float = 30,
|
|
357
|
+
bundle_builder: Callable[[], bytes] = build_remote_agent_bundle,
|
|
358
|
+
) -> None:
|
|
359
|
+
if generation <= 0:
|
|
360
|
+
raise ValueError("generation must be positive")
|
|
361
|
+
if connect_timeout <= 0:
|
|
362
|
+
raise ValueError("connect_timeout must be positive")
|
|
363
|
+
self.host = host
|
|
364
|
+
self.generation = generation
|
|
365
|
+
self.connect_timeout = float(connect_timeout)
|
|
366
|
+
self._bundle_builder = bundle_builder
|
|
367
|
+
self._process: asyncio.subprocess.Process | None = None
|
|
368
|
+
self._byte_stream: AsyncPtyByteStream | None = None
|
|
369
|
+
self._client: MuxRpcClient | None = None
|
|
370
|
+
self._monitor_task: asyncio.Task[None] | None = None
|
|
371
|
+
self._closed_future: asyncio.Future[BaseException | None] | None = None
|
|
372
|
+
self._endpoints: dict[int, PtyAgentTunnelEndpoint] = {}
|
|
373
|
+
self._closing = False
|
|
374
|
+
self._close_lock = asyncio.Lock()
|
|
375
|
+
self._agent_pid: int | None = None
|
|
376
|
+
self._process_returncode: int | None = None
|
|
377
|
+
|
|
378
|
+
@property
|
|
379
|
+
def agent_pid(self) -> int | None:
|
|
380
|
+
return self._agent_pid
|
|
381
|
+
|
|
382
|
+
@property
|
|
383
|
+
def process_returncode(self) -> int | None:
|
|
384
|
+
process = self._process
|
|
385
|
+
return self._process_returncode if process is None else process.returncode
|
|
386
|
+
|
|
387
|
+
@property
|
|
388
|
+
def queued_bytes(self) -> int:
|
|
389
|
+
return sum(endpoint.queued_bytes for endpoint in self._endpoints.values())
|
|
390
|
+
|
|
391
|
+
@property
|
|
392
|
+
def window_stalls(self) -> int:
|
|
393
|
+
client = self._client
|
|
394
|
+
return 0 if client is None else client.window_stalls
|
|
395
|
+
|
|
396
|
+
@property
|
|
397
|
+
def protocol_failures(self) -> int:
|
|
398
|
+
client = self._client
|
|
399
|
+
return 0 if client is None else client.protocol_failures
|
|
400
|
+
|
|
401
|
+
async def start(self) -> None:
|
|
402
|
+
if self._process is not None:
|
|
403
|
+
return
|
|
404
|
+
self._closed_future = asyncio.get_running_loop().create_future()
|
|
405
|
+
master_fd, slave_fd = os.openpty()
|
|
406
|
+
try:
|
|
407
|
+
process = await asyncio.create_subprocess_exec(
|
|
408
|
+
self.host.command,
|
|
409
|
+
*self.host.args,
|
|
410
|
+
stdin=slave_fd,
|
|
411
|
+
stdout=slave_fd,
|
|
412
|
+
stderr=slave_fd,
|
|
413
|
+
start_new_session=True,
|
|
414
|
+
)
|
|
415
|
+
except BaseException:
|
|
416
|
+
os.close(master_fd)
|
|
417
|
+
raise
|
|
418
|
+
finally:
|
|
419
|
+
os.close(slave_fd)
|
|
420
|
+
self._process = process
|
|
421
|
+
self._agent_pid = process.pid
|
|
422
|
+
self._byte_stream = AsyncPtyByteStream(master_fd)
|
|
423
|
+
try:
|
|
424
|
+
nonce = await self._bootstrap(self._byte_stream)
|
|
425
|
+
reader, writer = self._byte_stream.handoff()
|
|
426
|
+
client = MuxRpcClient(
|
|
427
|
+
_PtyHandshakeReader(reader, nonce),
|
|
428
|
+
writer, # type: ignore[arg-type]
|
|
429
|
+
max_pending=self.host.forwarding.max_streams,
|
|
430
|
+
connection_window=DEFAULT_CONNECTION_WINDOW_BYTES,
|
|
431
|
+
stream_window=DEFAULT_STREAM_WINDOW_BYTES,
|
|
432
|
+
)
|
|
433
|
+
self._client = client
|
|
434
|
+
async with asyncio.timeout(self.connect_timeout):
|
|
435
|
+
await client._start()
|
|
436
|
+
self._monitor_task = asyncio.create_task(
|
|
437
|
+
self._monitor_process(),
|
|
438
|
+
name=f"hostbridge-pty-agent-monitor-{self.host.id}-{self.generation}",
|
|
439
|
+
)
|
|
440
|
+
except BaseException as exc:
|
|
441
|
+
await self.close()
|
|
442
|
+
if isinstance(exc, asyncio.CancelledError):
|
|
443
|
+
raise
|
|
444
|
+
if isinstance(exc, TunnelBackendError):
|
|
445
|
+
raise
|
|
446
|
+
raise TunnelBackendError(f"PTY agent bootstrap failed: {exc}") from exc
|
|
447
|
+
|
|
448
|
+
async def open_stream(
|
|
449
|
+
self,
|
|
450
|
+
host: str,
|
|
451
|
+
port: int,
|
|
452
|
+
policy: ForwardingPolicy,
|
|
453
|
+
) -> PtyAgentTunnelEndpoint:
|
|
454
|
+
client = self._client
|
|
455
|
+
if self._closing or client is None or client.closed:
|
|
456
|
+
raise TunnelBackendError("PTY agent runtime is not connected")
|
|
457
|
+
if policy != self.host.forwarding:
|
|
458
|
+
raise TunnelBackendError("PTY agent stream policy does not match the host runtime")
|
|
459
|
+
try:
|
|
460
|
+
stream = await client.open_stream(
|
|
461
|
+
"tcp.connect",
|
|
462
|
+
{
|
|
463
|
+
"host": host,
|
|
464
|
+
"port": port,
|
|
465
|
+
"allow_hostnames": list(policy.allow_hostnames),
|
|
466
|
+
"allow_cidrs": list(policy.allow_cidrs),
|
|
467
|
+
"allow_ports": list(policy.allow_ports),
|
|
468
|
+
"connect_timeout": min(self.connect_timeout, 180),
|
|
469
|
+
},
|
|
470
|
+
)
|
|
471
|
+
except asyncio.CancelledError:
|
|
472
|
+
raise
|
|
473
|
+
except Exception as exc:
|
|
474
|
+
raise TunnelBackendError(f"PTY agent destination open failed: {exc}") from exc
|
|
475
|
+
endpoint = PtyAgentTunnelEndpoint(stream, self._endpoint_closed)
|
|
476
|
+
self._endpoints[stream.stream_id] = endpoint
|
|
477
|
+
return endpoint
|
|
478
|
+
|
|
479
|
+
async def exec(
|
|
480
|
+
self,
|
|
481
|
+
command: str,
|
|
482
|
+
*,
|
|
483
|
+
stdin: ByteStream | None,
|
|
484
|
+
timeout: float,
|
|
485
|
+
output_limit: int,
|
|
486
|
+
) -> ExecResult:
|
|
487
|
+
client = self._client
|
|
488
|
+
if self._closing or client is None or client.closed:
|
|
489
|
+
raise TunnelBackendError("PTY agent runtime is not connected")
|
|
490
|
+
try:
|
|
491
|
+
stream = await client.open_stream(
|
|
492
|
+
"exec.start",
|
|
493
|
+
{
|
|
494
|
+
"command": command,
|
|
495
|
+
"timeout": timeout,
|
|
496
|
+
"output_limit": output_limit,
|
|
497
|
+
},
|
|
498
|
+
)
|
|
499
|
+
except asyncio.CancelledError:
|
|
500
|
+
raise
|
|
501
|
+
except Exception as exc:
|
|
502
|
+
raise TunnelBackendError(f"PTY agent command open failed: {exc}") from exc
|
|
503
|
+
|
|
504
|
+
stdout = bytearray()
|
|
505
|
+
stderr = bytearray()
|
|
506
|
+
decoder = MuxRecordDecoder()
|
|
507
|
+
|
|
508
|
+
async def send_input() -> None:
|
|
509
|
+
if stdin is not None:
|
|
510
|
+
async for chunk in iterate_byte_stream(stdin, label="stdin"):
|
|
511
|
+
await stream.send(chunk)
|
|
512
|
+
await stream.send_eof()
|
|
513
|
+
|
|
514
|
+
async def receive_output() -> None:
|
|
515
|
+
while chunk := await stream.receive():
|
|
516
|
+
for record in decoder.feed(chunk):
|
|
517
|
+
target = stdout if record.channel is MuxRecordChannel.STDOUT else stderr
|
|
518
|
+
target.extend(record.payload)
|
|
519
|
+
if decoder.buffered_bytes:
|
|
520
|
+
raise TunnelBackendError("PTY agent command returned an incomplete output record")
|
|
521
|
+
|
|
522
|
+
tasks = [
|
|
523
|
+
asyncio.create_task(send_input(), name=f"hostbridge-pty-exec-input-{stream.stream_id}"),
|
|
524
|
+
asyncio.create_task(receive_output(), name=f"hostbridge-pty-exec-output-{stream.stream_id}"),
|
|
525
|
+
]
|
|
526
|
+
try:
|
|
527
|
+
await asyncio.gather(*tasks)
|
|
528
|
+
result = await stream.finish()
|
|
529
|
+
except BaseException as exc:
|
|
530
|
+
for task in tasks:
|
|
531
|
+
if not task.done():
|
|
532
|
+
task.cancel()
|
|
533
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
534
|
+
await _reset_mux_stream(stream, str(exc) or "PTY agent command failed")
|
|
535
|
+
raise
|
|
536
|
+
exit_code = result.get("exit_code")
|
|
537
|
+
if exit_code is not None and (not isinstance(exit_code, int) or isinstance(exit_code, bool)):
|
|
538
|
+
raise TunnelBackendError("PTY agent command returned an invalid exit status")
|
|
539
|
+
timed_out = result.get("timed_out")
|
|
540
|
+
if not isinstance(timed_out, bool):
|
|
541
|
+
raise TunnelBackendError("PTY agent command returned an invalid timeout status")
|
|
542
|
+
return ExecResult(exit_code, bytes(stdout), bytes(stderr), timed_out=timed_out)
|
|
543
|
+
|
|
544
|
+
async def upload(
|
|
545
|
+
self,
|
|
546
|
+
chunks: ByteStream,
|
|
547
|
+
remote_path: str,
|
|
548
|
+
*,
|
|
549
|
+
mode: int,
|
|
550
|
+
expected_size: int | None,
|
|
551
|
+
expected_sha256: str | None,
|
|
552
|
+
) -> TransferResult:
|
|
553
|
+
client = self._require_client()
|
|
554
|
+
try:
|
|
555
|
+
stream = await client.open_stream(
|
|
556
|
+
"transfer.upload",
|
|
557
|
+
{
|
|
558
|
+
"remote_path": remote_path,
|
|
559
|
+
"mode": mode,
|
|
560
|
+
"expected_size": expected_size,
|
|
561
|
+
"expected_sha256": expected_sha256,
|
|
562
|
+
"max_bytes": self.host.limits.max_transfer_bytes,
|
|
563
|
+
},
|
|
564
|
+
)
|
|
565
|
+
async for chunk in iterate_byte_stream(chunks, label="upload"):
|
|
566
|
+
await stream.send(chunk)
|
|
567
|
+
await stream.send_eof()
|
|
568
|
+
while await stream.receive() is not None:
|
|
569
|
+
pass
|
|
570
|
+
result = await stream.finish()
|
|
571
|
+
except asyncio.CancelledError:
|
|
572
|
+
if "stream" in locals():
|
|
573
|
+
await _reset_mux_stream(stream, "PTY agent upload cancelled")
|
|
574
|
+
raise
|
|
575
|
+
except Exception as exc:
|
|
576
|
+
if "stream" in locals():
|
|
577
|
+
await _reset_mux_stream(stream, str(exc) or "PTY agent upload failed")
|
|
578
|
+
raise TunnelBackendError(f"PTY agent upload failed: {exc}") from exc
|
|
579
|
+
return _transfer_result(result)
|
|
580
|
+
|
|
581
|
+
async def download(self, remote_path: str, *, chunk_size: int) -> AsyncGenerator[bytes, None]:
|
|
582
|
+
client = self._require_client()
|
|
583
|
+
stream: MuxRpcStream | None = None
|
|
584
|
+
completed = False
|
|
585
|
+
try:
|
|
586
|
+
stream = await client.open_stream(
|
|
587
|
+
"transfer.download",
|
|
588
|
+
{
|
|
589
|
+
"remote_path": remote_path,
|
|
590
|
+
"chunk_size": chunk_size,
|
|
591
|
+
"max_bytes": self.host.limits.max_transfer_bytes,
|
|
592
|
+
},
|
|
593
|
+
)
|
|
594
|
+
await stream.send_eof()
|
|
595
|
+
while chunk := await stream.receive(chunk_size):
|
|
596
|
+
yield chunk
|
|
597
|
+
_transfer_result(await stream.finish())
|
|
598
|
+
completed = True
|
|
599
|
+
except asyncio.CancelledError:
|
|
600
|
+
raise
|
|
601
|
+
except Exception as exc:
|
|
602
|
+
raise TunnelBackendError(f"PTY agent download failed: {exc}") from exc
|
|
603
|
+
finally:
|
|
604
|
+
if stream is not None and not completed:
|
|
605
|
+
await _reset_mux_stream(stream, "PTY agent download closed before completion")
|
|
606
|
+
|
|
607
|
+
async def shell_write(self, shell_id: str, data: bytes) -> None:
|
|
608
|
+
stream: MuxRpcStream | None = None
|
|
609
|
+
try:
|
|
610
|
+
stream = await self._require_client().open_stream(
|
|
611
|
+
"shell.write",
|
|
612
|
+
{"shell_id": shell_id, "max_bytes": self.host.limits.max_shell_bytes},
|
|
613
|
+
)
|
|
614
|
+
await stream.send(data)
|
|
615
|
+
await stream.send_eof()
|
|
616
|
+
while await stream.receive() is not None:
|
|
617
|
+
pass
|
|
618
|
+
result = await stream.finish()
|
|
619
|
+
if result.get("written") != len(data):
|
|
620
|
+
raise TunnelBackendError("PTY agent shell write returned an invalid byte count")
|
|
621
|
+
except asyncio.CancelledError:
|
|
622
|
+
if stream is not None:
|
|
623
|
+
await _reset_mux_stream(stream, "PTY agent shell write cancelled")
|
|
624
|
+
raise
|
|
625
|
+
except Exception as exc:
|
|
626
|
+
if stream is not None:
|
|
627
|
+
await _reset_mux_stream(stream, str(exc) or "PTY agent shell write failed")
|
|
628
|
+
if isinstance(exc, TunnelBackendError):
|
|
629
|
+
raise
|
|
630
|
+
raise TunnelBackendError(f"PTY agent shell write failed: {exc}") from exc
|
|
631
|
+
|
|
632
|
+
async def attach_shell(self, shell_id: str, stdin: ByteStream, *, max_bytes: int) -> ShellAttachment:
|
|
633
|
+
stream = await self._require_client().open_stream(
|
|
634
|
+
"shell.attach",
|
|
635
|
+
{"shell_id": shell_id, "max_bytes": max_bytes},
|
|
636
|
+
)
|
|
637
|
+
completed = False
|
|
638
|
+
terminal = asyncio.get_running_loop().create_future()
|
|
639
|
+
|
|
640
|
+
async def send_input() -> None:
|
|
641
|
+
async for chunk in iterate_byte_stream(stdin, label="shell input"):
|
|
642
|
+
await stream.send(chunk)
|
|
643
|
+
await stream.send_eof()
|
|
644
|
+
|
|
645
|
+
async def output() -> AsyncGenerator[bytes, None]:
|
|
646
|
+
nonlocal completed
|
|
647
|
+
input_task = asyncio.create_task(send_input(), name=f"hostbridge-pty-shell-input-{stream.stream_id}")
|
|
648
|
+
transferred = 0
|
|
649
|
+
try:
|
|
650
|
+
while chunk := await stream.receive(max_bytes):
|
|
651
|
+
transferred += len(chunk)
|
|
652
|
+
yield chunk
|
|
653
|
+
result = await stream.finish()
|
|
654
|
+
await stop_input_after_peer_eof(input_task)
|
|
655
|
+
if result.get("bytes") != transferred:
|
|
656
|
+
raise TunnelBackendError("PTY agent attached shell returned an invalid byte count")
|
|
657
|
+
try:
|
|
658
|
+
shell_terminal = ShellTerminalResult.from_mapping(result)
|
|
659
|
+
except ValueError as exc:
|
|
660
|
+
raise TunnelBackendError("PTY agent attached shell returned invalid terminal metadata") from exc
|
|
661
|
+
terminal.set_result(shell_terminal)
|
|
662
|
+
completed = True
|
|
663
|
+
except BaseException as exc:
|
|
664
|
+
if not terminal.done():
|
|
665
|
+
terminal.set_exception(exc)
|
|
666
|
+
terminal.exception()
|
|
667
|
+
raise
|
|
668
|
+
finally:
|
|
669
|
+
|
|
670
|
+
async def finish_attachment() -> None:
|
|
671
|
+
if not input_task.done():
|
|
672
|
+
input_task.cancel()
|
|
673
|
+
await asyncio.gather(input_task, return_exceptions=True)
|
|
674
|
+
if not completed:
|
|
675
|
+
await _reset_mux_stream(stream, "PTY agent attached shell closed before completion")
|
|
676
|
+
|
|
677
|
+
cleanup = asyncio.create_task(
|
|
678
|
+
finish_attachment(),
|
|
679
|
+
name=f"hostbridge-pty-shell-cleanup-{stream.stream_id}",
|
|
680
|
+
)
|
|
681
|
+
await finish_task_before_cancellation(cleanup)
|
|
682
|
+
|
|
683
|
+
return ShellAttachment(output(), terminal)
|
|
684
|
+
|
|
685
|
+
async def shell_read(self, shell_id: str, *, timeout: float | None, max_bytes: int) -> ShellReadResult:
|
|
686
|
+
stream: MuxRpcStream | None = None
|
|
687
|
+
data = bytearray()
|
|
688
|
+
try:
|
|
689
|
+
params: dict[str, object] = {"shell_id": shell_id, "max_bytes": max_bytes}
|
|
690
|
+
if timeout is not None:
|
|
691
|
+
params["timeout"] = timeout
|
|
692
|
+
stream = await self._require_client().open_stream(
|
|
693
|
+
"shell.read",
|
|
694
|
+
params,
|
|
695
|
+
)
|
|
696
|
+
await stream.send_eof()
|
|
697
|
+
while chunk := await stream.receive():
|
|
698
|
+
data.extend(chunk)
|
|
699
|
+
result = await stream.finish()
|
|
700
|
+
except asyncio.CancelledError:
|
|
701
|
+
if stream is not None:
|
|
702
|
+
await _reset_mux_stream(stream, "PTY agent shell read cancelled")
|
|
703
|
+
raise
|
|
704
|
+
except Exception as exc:
|
|
705
|
+
if stream is not None:
|
|
706
|
+
await _reset_mux_stream(stream, str(exc) or "PTY agent shell read failed")
|
|
707
|
+
raise TunnelBackendError(f"PTY agent shell read failed: {exc}") from exc
|
|
708
|
+
alive = result.get("alive")
|
|
709
|
+
if not isinstance(alive, bool) or result.get("bytes") != len(data):
|
|
710
|
+
raise TunnelBackendError("PTY agent shell read returned invalid metadata")
|
|
711
|
+
return ShellReadResult(bytes(data), alive)
|
|
712
|
+
|
|
713
|
+
async def shell_close(self, shell_id: str) -> None:
|
|
714
|
+
stream = await self._require_client().open_stream("shell.close", {"shell_id": shell_id})
|
|
715
|
+
try:
|
|
716
|
+
await stream.send_eof()
|
|
717
|
+
while await stream.receive() is not None:
|
|
718
|
+
pass
|
|
719
|
+
result = await stream.finish()
|
|
720
|
+
if result.get("closed") is not True:
|
|
721
|
+
raise TunnelBackendError("PTY agent shell close returned invalid metadata")
|
|
722
|
+
except BaseException:
|
|
723
|
+
await _reset_mux_stream(stream, "PTY agent shell close failed")
|
|
724
|
+
raise
|
|
725
|
+
|
|
726
|
+
def _require_client(self) -> MuxRpcClient:
|
|
727
|
+
client = self._client
|
|
728
|
+
if self._closing or client is None or client.closed:
|
|
729
|
+
raise TunnelBackendError("PTY agent runtime is not connected")
|
|
730
|
+
return client
|
|
731
|
+
|
|
732
|
+
async def wait_closed(self) -> BaseException | None:
|
|
733
|
+
if self._closed_future is None:
|
|
734
|
+
raise TunnelBackendError("PTY agent runtime has not started")
|
|
735
|
+
return await asyncio.shield(self._closed_future)
|
|
736
|
+
|
|
737
|
+
async def close(self) -> None:
|
|
738
|
+
async with self._close_lock:
|
|
739
|
+
self._closing = True
|
|
740
|
+
monitor_is_caller = self._monitor_task is asyncio.current_task()
|
|
741
|
+
cleanup = asyncio.create_task(self._finish_close(monitor_is_caller))
|
|
742
|
+
await finish_task_before_cancellation(cleanup)
|
|
743
|
+
|
|
744
|
+
def abort(self) -> None:
|
|
745
|
+
self._closing = True
|
|
746
|
+
error = TunnelBackendError("PTY agent runtime cleanup timed out")
|
|
747
|
+
byte_stream = self._byte_stream
|
|
748
|
+
abort_error: Exception | None = None
|
|
749
|
+
if byte_stream is not None:
|
|
750
|
+
try:
|
|
751
|
+
byte_stream.close_nowait()
|
|
752
|
+
except Exception as exc:
|
|
753
|
+
abort_error = exc
|
|
754
|
+
process = self._process
|
|
755
|
+
if process is not None and process.returncode is None:
|
|
756
|
+
try:
|
|
757
|
+
_signal_process_tree(process, signal.SIGKILL)
|
|
758
|
+
abort_error = None
|
|
759
|
+
except Exception as exc:
|
|
760
|
+
try:
|
|
761
|
+
process.kill()
|
|
762
|
+
abort_error = None
|
|
763
|
+
except Exception as fallback_error:
|
|
764
|
+
fallback_error.add_note(f"process group kill failed: {exc}")
|
|
765
|
+
abort_error = fallback_error
|
|
766
|
+
if self._closed_future is not None and not self._closed_future.done():
|
|
767
|
+
with suppress(Exception):
|
|
768
|
+
self._closed_future.set_result(error)
|
|
769
|
+
if abort_error is not None:
|
|
770
|
+
raise abort_error
|
|
771
|
+
|
|
772
|
+
async def _finish_close(self, monitor_is_caller: bool) -> None:
|
|
773
|
+
endpoints = list(self._endpoints.values())
|
|
774
|
+
await asyncio.gather(
|
|
775
|
+
*(endpoint.reset("PTY agent runtime closed") for endpoint in endpoints),
|
|
776
|
+
return_exceptions=True,
|
|
777
|
+
)
|
|
778
|
+
for endpoint in endpoints:
|
|
779
|
+
self._endpoints.pop(endpoint.provider_stream_id, None)
|
|
780
|
+
client = self._client
|
|
781
|
+
if client is not None:
|
|
782
|
+
await client.close()
|
|
783
|
+
if self._client is client:
|
|
784
|
+
self._client = None
|
|
785
|
+
byte_stream = self._byte_stream
|
|
786
|
+
if byte_stream is not None:
|
|
787
|
+
await byte_stream.close()
|
|
788
|
+
if self._byte_stream is byte_stream:
|
|
789
|
+
self._byte_stream = None
|
|
790
|
+
await self._terminate_process()
|
|
791
|
+
monitor = self._monitor_task
|
|
792
|
+
if monitor is not None and not monitor_is_caller:
|
|
793
|
+
monitor.cancel()
|
|
794
|
+
await asyncio.gather(monitor, return_exceptions=True)
|
|
795
|
+
if self._monitor_task is monitor:
|
|
796
|
+
self._monitor_task = None
|
|
797
|
+
if self._closed_future is not None and not self._closed_future.done():
|
|
798
|
+
self._closed_future.set_result(None)
|
|
799
|
+
|
|
800
|
+
async def _bootstrap(self, byte_stream: AsyncPtyByteStream) -> bytes:
|
|
801
|
+
for step in self.host.login_steps:
|
|
802
|
+
await byte_stream.read_until(re.compile(step.expect.encode("utf-8")), timeout=self.connect_timeout)
|
|
803
|
+
if step.send_secret is not None:
|
|
804
|
+
value = self.host.secrets.get(step.send_secret)
|
|
805
|
+
if value is None:
|
|
806
|
+
raise TunnelBackendError(f"login step references missing secret {step.send_secret!r}")
|
|
807
|
+
else:
|
|
808
|
+
value = step.send or ""
|
|
809
|
+
byte_stream.write(value.encode("utf-8") + b"\r")
|
|
810
|
+
await byte_stream.drain()
|
|
811
|
+
if self.host.login_steps:
|
|
812
|
+
ready = self.host.ready_expect or r"(?m)[^\r\n]*[#$]\s*$"
|
|
813
|
+
await byte_stream.read_until(re.compile(ready.encode("utf-8")), timeout=self.connect_timeout)
|
|
814
|
+
|
|
815
|
+
bundle = self._bundle_builder()
|
|
816
|
+
nonce = secrets.token_hex(32)
|
|
817
|
+
digest = hashlib.sha256(bundle).hexdigest()
|
|
818
|
+
command = (f"python3 -u -c {shlex.quote(f'exec({_BOOTSTRAP!r})')} {nonce} {len(bundle)} {digest}\r").encode(
|
|
819
|
+
"ascii"
|
|
820
|
+
)
|
|
821
|
+
byte_stream.write(command)
|
|
822
|
+
await byte_stream.drain()
|
|
823
|
+
ready_marker = b"\x00HBRAW2:" + nonce.encode("ascii") + b"\x00"
|
|
824
|
+
await byte_stream.read_until(ready_marker, timeout=self.connect_timeout)
|
|
825
|
+
byte_stream.write(bundle)
|
|
826
|
+
await byte_stream.drain()
|
|
827
|
+
verified_marker = b"\x00HBOK2:" + nonce.encode("ascii") + b"\x00"
|
|
828
|
+
await byte_stream.read_until(verified_marker, timeout=self.connect_timeout)
|
|
829
|
+
return nonce.encode("ascii")
|
|
830
|
+
|
|
831
|
+
async def _monitor_process(self) -> None:
|
|
832
|
+
process = self._process
|
|
833
|
+
if process is None:
|
|
834
|
+
return
|
|
835
|
+
returncode = await process.wait()
|
|
836
|
+
self._process_returncode = returncode
|
|
837
|
+
if self._closing:
|
|
838
|
+
return
|
|
839
|
+
error = TunnelBackendError(f"PTY agent process exited with status {returncode}")
|
|
840
|
+
if self._closed_future is not None and not self._closed_future.done():
|
|
841
|
+
self._closed_future.set_result(error)
|
|
842
|
+
client = self._client
|
|
843
|
+
if client is not None:
|
|
844
|
+
await client.close()
|
|
845
|
+
|
|
846
|
+
async def _terminate_process(self) -> None:
|
|
847
|
+
process = self._process
|
|
848
|
+
if process is None:
|
|
849
|
+
return
|
|
850
|
+
if process.returncode is None:
|
|
851
|
+
_signal_process_tree(process, signal.SIGTERM)
|
|
852
|
+
try:
|
|
853
|
+
async with asyncio.timeout(PROCESS_TERM_TIMEOUT_SECONDS):
|
|
854
|
+
await process.wait()
|
|
855
|
+
except TimeoutError:
|
|
856
|
+
_signal_process_tree(process, signal.SIGKILL)
|
|
857
|
+
await process.wait()
|
|
858
|
+
else:
|
|
859
|
+
await process.wait()
|
|
860
|
+
self._process_returncode = process.returncode
|
|
861
|
+
|
|
862
|
+
def _endpoint_closed(self, stream_id: int) -> None:
|
|
863
|
+
self._endpoints.pop(stream_id, None)
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def pty_agent_provider_factory(
|
|
867
|
+
host: HostConfig,
|
|
868
|
+
provider_name: str,
|
|
869
|
+
generation: int,
|
|
870
|
+
) -> PtyAgentHostRuntime:
|
|
871
|
+
if provider_name != "pty_agent":
|
|
872
|
+
raise TunnelBackendError(f"PTY agent factory cannot create provider {provider_name!r}")
|
|
873
|
+
return PtyAgentHostRuntime(host, generation=generation)
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _signal_process_tree(process: asyncio.subprocess.Process, signal_number: signal.Signals) -> None:
|
|
877
|
+
try:
|
|
878
|
+
os.killpg(process.pid, signal_number)
|
|
879
|
+
except ProcessLookupError:
|
|
880
|
+
return
|
|
881
|
+
except PermissionError:
|
|
882
|
+
with suppress(ProcessLookupError):
|
|
883
|
+
process.send_signal(signal_number)
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
async def _reset_mux_stream(stream: MuxRpcStream, reason: str) -> None:
|
|
887
|
+
cleanup = asyncio.create_task(stream.reset(reason), name=f"hostbridge-pty-stream-reset-{stream.stream_id}")
|
|
888
|
+
while not cleanup.done():
|
|
889
|
+
try:
|
|
890
|
+
await asyncio.shield(cleanup)
|
|
891
|
+
except asyncio.CancelledError:
|
|
892
|
+
continue
|
|
893
|
+
except BaseException:
|
|
894
|
+
break
|
|
895
|
+
await asyncio.gather(cleanup, return_exceptions=True)
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def _transfer_result(result: dict[str, object]) -> TransferResult:
|
|
899
|
+
bytes_transferred = result.get("bytes_transferred")
|
|
900
|
+
sha256 = result.get("sha256")
|
|
901
|
+
if (
|
|
902
|
+
not isinstance(bytes_transferred, int)
|
|
903
|
+
or isinstance(bytes_transferred, bool)
|
|
904
|
+
or bytes_transferred < 0
|
|
905
|
+
or not isinstance(sha256, str)
|
|
906
|
+
or len(sha256) != 64
|
|
907
|
+
):
|
|
908
|
+
raise TunnelBackendError("PTY agent returned an invalid transfer result")
|
|
909
|
+
return TransferResult(bytes_transferred, sha256)
|