@neoline/hostbridge 2.0.3 → 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.
Files changed (48) hide show
  1. package/README.md +24 -15
  2. package/bin/hostbridge.js +80 -9
  3. package/docs/ARCHITECTURE.md +62 -0
  4. package/examples/claude_desktop_config.json +1 -1
  5. package/examples/codex.config.toml +1 -1
  6. package/examples/hosts.example.json +1 -0
  7. package/package.json +2 -1
  8. package/pyproject.toml +5 -4
  9. package/src/server_control_mcp/__init__.py +60 -24
  10. package/src/server_control_mcp/async_lifecycle.py +41 -0
  11. package/src/server_control_mcp/cli.py +2 -2
  12. package/src/server_control_mcp/client.py +345 -238
  13. package/src/server_control_mcp/config.py +18 -6
  14. package/src/server_control_mcp/daemon.py +309 -412
  15. package/src/server_control_mcp/doctor.py +41 -5
  16. package/src/server_control_mcp/hosts.py +252 -24
  17. package/src/server_control_mcp/mock_ssh.py +97 -960
  18. package/src/server_control_mcp/mock_ssh_exec.py +299 -0
  19. package/src/server_control_mcp/mock_ssh_session.py +69 -0
  20. package/src/server_control_mcp/mock_ssh_sftp.py +604 -0
  21. package/src/server_control_mcp/mock_ssh_tunnel.py +289 -0
  22. package/src/server_control_mcp/mux_connection.py +326 -0
  23. package/src/server_control_mcp/mux_daemon.py +186 -0
  24. package/src/server_control_mcp/mux_protocol.py +279 -0
  25. package/src/server_control_mcp/mux_records.py +71 -0
  26. package/src/server_control_mcp/mux_rpc.py +1779 -0
  27. package/src/server_control_mcp/mux_service.py +506 -0
  28. package/src/server_control_mcp/mux_stream.py +283 -0
  29. package/src/server_control_mcp/mux_sync_client.py +224 -0
  30. package/src/server_control_mcp/policy.py +86 -14
  31. package/src/server_control_mcp/remote_agent_bundle.py +56 -0
  32. package/src/server_control_mcp/remote_mux_agent.py +769 -0
  33. package/src/server_control_mcp/remote_task_agent.py +102 -0
  34. package/src/server_control_mcp/runtime.py +3 -2
  35. package/src/server_control_mcp/runtime_services.py +862 -0
  36. package/src/server_control_mcp/secrets.py +6 -6
  37. package/src/server_control_mcp/secure_files.py +121 -0
  38. package/src/server_control_mcp/server.py +53 -20
  39. package/src/server_control_mcp/services.py +3 -469
  40. package/src/server_control_mcp/transports/__init__.py +4 -8
  41. package/src/server_control_mcp/transports/base.py +60 -38
  42. package/src/server_control_mcp/transports/ssh.py +315 -84
  43. package/src/server_control_mcp/tunnel_manager.py +1245 -0
  44. package/src/server_control_mcp/tunnel_native_ssh.py +454 -0
  45. package/src/server_control_mcp/tunnel_providers.py +20 -0
  46. package/src/server_control_mcp/tunnel_pty_agent.py +909 -0
  47. package/src/server_control_mcp/protocol.py +0 -362
  48. package/src/server_control_mcp/transports/pty.py +0 -434
@@ -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)