@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.
Files changed (40) hide show
  1. package/README.md +11 -9
  2. package/docs/ARCHITECTURE.md +62 -0
  3. package/package.json +2 -1
  4. package/pyproject.toml +1 -1
  5. package/src/server_control_mcp/__init__.py +4 -3
  6. package/src/server_control_mcp/async_lifecycle.py +41 -0
  7. package/src/server_control_mcp/cli.py +1 -1
  8. package/src/server_control_mcp/client.py +302 -287
  9. package/src/server_control_mcp/config.py +2 -1
  10. package/src/server_control_mcp/daemon.py +233 -526
  11. package/src/server_control_mcp/doctor.py +34 -2
  12. package/src/server_control_mcp/hosts.py +136 -2
  13. package/src/server_control_mcp/mock_ssh.py +52 -14
  14. package/src/server_control_mcp/mock_ssh_exec.py +147 -58
  15. package/src/server_control_mcp/mock_ssh_session.py +3 -2
  16. package/src/server_control_mcp/mock_ssh_sftp.py +126 -28
  17. package/src/server_control_mcp/mock_ssh_tunnel.py +141 -444
  18. package/src/server_control_mcp/mux_connection.py +326 -0
  19. package/src/server_control_mcp/mux_daemon.py +186 -0
  20. package/src/server_control_mcp/mux_protocol.py +279 -0
  21. package/src/server_control_mcp/mux_records.py +71 -0
  22. package/src/server_control_mcp/mux_rpc.py +1779 -0
  23. package/src/server_control_mcp/mux_service.py +506 -0
  24. package/src/server_control_mcp/mux_stream.py +283 -0
  25. package/src/server_control_mcp/mux_sync_client.py +224 -0
  26. package/src/server_control_mcp/remote_agent_bundle.py +56 -0
  27. package/src/server_control_mcp/remote_mux_agent.py +769 -0
  28. package/src/server_control_mcp/runtime_services.py +862 -0
  29. package/src/server_control_mcp/server.py +33 -18
  30. package/src/server_control_mcp/services.py +2 -666
  31. package/src/server_control_mcp/transports/__init__.py +4 -8
  32. package/src/server_control_mcp/transports/base.py +60 -38
  33. package/src/server_control_mcp/transports/ssh.py +206 -259
  34. package/src/server_control_mcp/tunnel_manager.py +1245 -0
  35. package/src/server_control_mcp/tunnel_native_ssh.py +454 -0
  36. package/src/server_control_mcp/tunnel_providers.py +20 -0
  37. package/src/server_control_mcp/tunnel_pty_agent.py +909 -0
  38. package/src/server_control_mcp/protocol.py +0 -363
  39. package/src/server_control_mcp/remote_tunnel_agent.py +0 -143
  40. package/src/server_control_mcp/transports/pty.py +0 -515
@@ -0,0 +1,769 @@
1
+ """Event-driven TCP forwarding service used by the remote PTY agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ import ipaddress
8
+ import os
9
+ import signal
10
+ import socket
11
+ import sys
12
+ import tempfile
13
+ from collections import deque
14
+ from collections.abc import Sequence
15
+ from contextlib import suppress
16
+
17
+ from .async_lifecycle import finish_task_before_cancellation, stop_input_after_peer_eof
18
+ from .mux_records import MAX_RECORD_PAYLOAD_BYTES, MuxRecord, MuxRecordChannel, encode_record
19
+ from .mux_rpc import MuxRpcFailure, serve_mux_rpc
20
+ from .mux_stream import MuxRpcStream
21
+
22
+ DEFAULT_MAX_AGENT_STREAMS = 128
23
+ DEFAULT_CONNECT_TIMEOUT = 30.0
24
+ DEFAULT_CLEANUP_TIMEOUT = 5.0
25
+ MAX_CONNECT_TIMEOUT = 180.0
26
+ TCP_CHUNK_BYTES = 64 * 1024
27
+ MAX_EXEC_OUTPUT_BYTES = 16 * 1024 * 1024
28
+ MAX_EXEC_TIMEOUT = 24 * 60 * 60
29
+ MAX_TRANSFER_BYTES = 1024 * 1024 * 1024
30
+ MAX_TRANSFER_CHUNK_BYTES = 1024 * 1024
31
+
32
+
33
+ class RemoteMuxAgent:
34
+ def __init__(
35
+ self,
36
+ *,
37
+ max_streams: int = DEFAULT_MAX_AGENT_STREAMS,
38
+ cleanup_timeout_seconds: float = DEFAULT_CLEANUP_TIMEOUT,
39
+ ) -> None:
40
+ if not isinstance(max_streams, int) or isinstance(max_streams, bool) or max_streams <= 0:
41
+ raise ValueError("max_streams must be a positive integer")
42
+ if (
43
+ not isinstance(cleanup_timeout_seconds, (int, float))
44
+ or isinstance(cleanup_timeout_seconds, bool)
45
+ or cleanup_timeout_seconds <= 0
46
+ ):
47
+ raise ValueError("cleanup_timeout_seconds must be positive")
48
+ self.max_streams = max_streams
49
+ self.cleanup_timeout_seconds = float(cleanup_timeout_seconds)
50
+ self.active_streams = 0
51
+ self.peak_streams = 0
52
+ self._shells: dict[str, asyncio.subprocess.Process] = {}
53
+ self._shell_read_locks: dict[str, asyncio.Lock] = {}
54
+ self._shell_write_locks: dict[str, asyncio.Lock] = {}
55
+ self._shell_creation_lock = asyncio.Lock()
56
+
57
+ async def serve(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
58
+ try:
59
+ await serve_mux_rpc(
60
+ reader,
61
+ writer,
62
+ self._handle_unary,
63
+ max_handlers=self.max_streams,
64
+ stream_handler=self._handle_stream,
65
+ )
66
+ finally:
67
+ await asyncio.gather(
68
+ *(self._close_shell(shell_id) for shell_id in tuple(self._shells)), return_exceptions=True
69
+ )
70
+
71
+ async def _handle_unary(self, method: str, params: dict[str, object]) -> dict[str, object]:
72
+ raise MuxRpcFailure("not_found", f"remote agent does not support unary method {method!r}")
73
+
74
+ async def _handle_stream(
75
+ self,
76
+ method: str,
77
+ params: dict[str, object],
78
+ stream: MuxRpcStream,
79
+ ) -> dict[str, object]:
80
+ if method not in {
81
+ "tcp.connect",
82
+ "exec.start",
83
+ "transfer.upload",
84
+ "transfer.download",
85
+ "shell.write",
86
+ "shell.read",
87
+ "shell.attach",
88
+ "shell.close",
89
+ }:
90
+ raise MuxRpcFailure("not_found", f"remote agent does not support stream method {method!r}")
91
+ if self.active_streams >= self.max_streams:
92
+ raise MuxRpcFailure("busy", "remote agent stream capacity is exhausted", retryable=True)
93
+ self.active_streams += 1
94
+ self.peak_streams = max(self.peak_streams, self.active_streams)
95
+ if method == "exec.start":
96
+ try:
97
+ return await _run_exec(stream, params, self.cleanup_timeout_seconds)
98
+ finally:
99
+ self.active_streams -= 1
100
+ if method == "transfer.upload":
101
+ try:
102
+ return await _receive_upload(stream, params)
103
+ finally:
104
+ self.active_streams -= 1
105
+ if method == "transfer.download":
106
+ try:
107
+ return await _send_download(stream, params)
108
+ finally:
109
+ self.active_streams -= 1
110
+ if method == "shell.write":
111
+ try:
112
+ return await self._shell_write(stream, params)
113
+ finally:
114
+ self.active_streams -= 1
115
+ if method == "shell.read":
116
+ try:
117
+ return await self._shell_read(stream, params)
118
+ finally:
119
+ self.active_streams -= 1
120
+ if method == "shell.attach":
121
+ try:
122
+ return await self._shell_attach(stream, params)
123
+ finally:
124
+ self.active_streams -= 1
125
+ if method == "shell.close":
126
+ try:
127
+ shell_id = _shell_id(params)
128
+ await stream.accept()
129
+ while await stream.receive(TCP_CHUNK_BYTES):
130
+ raise MuxRpcFailure("invalid_request", "shell.close does not accept data")
131
+ await self._close_shell(shell_id)
132
+ return {"closed": True}
133
+ finally:
134
+ self.active_streams -= 1
135
+ writer: asyncio.StreamWriter | None = None
136
+ try:
137
+ destination = await _resolve_destination(params)
138
+ try:
139
+ async with asyncio.timeout(destination.timeout):
140
+ reader, writer = await asyncio.open_connection(
141
+ destination.address,
142
+ destination.port,
143
+ family=destination.family,
144
+ )
145
+ except TimeoutError as exc:
146
+ raise MuxRpcFailure("timeout", "destination connection timed out", retryable=True) from exc
147
+ except OSError as exc:
148
+ raise MuxRpcFailure(
149
+ "connection_lost",
150
+ f"destination connection failed: {exc}",
151
+ retryable=True,
152
+ ) from exc
153
+ await stream.accept()
154
+ return await _bridge_tcp(stream, reader, writer)
155
+ finally:
156
+ if writer is not None:
157
+ writer.close()
158
+ with suppress(Exception):
159
+ async with asyncio.timeout(self.cleanup_timeout_seconds):
160
+ await writer.wait_closed()
161
+ self.active_streams -= 1
162
+
163
+ async def _shell_write(self, stream: MuxRpcStream, params: dict[str, object]) -> dict[str, object]:
164
+ allowed = {"shell_id", "max_bytes"}
165
+ unknown = sorted(set(params) - allowed)
166
+ if unknown:
167
+ raise MuxRpcFailure("invalid_request", f"unknown shell.write fields: {', '.join(unknown)}")
168
+ shell_id = _shell_id(params)
169
+ max_bytes = _bounded_integer(params.get("max_bytes", 1024 * 1024), "max_bytes", maximum=MAX_TRANSFER_BYTES)
170
+ process = await self._ensure_shell(shell_id)
171
+ if process.stdin is None:
172
+ raise MuxRpcFailure("connection_lost", "shell stdin is unavailable", retryable=True)
173
+ await stream.accept()
174
+ written = 0
175
+ async with self._shell_write_locks[shell_id]:
176
+ while chunk := await stream.receive(TCP_CHUNK_BYTES):
177
+ written += len(chunk)
178
+ if written > max_bytes:
179
+ raise MuxRpcFailure("resource_limit", f"shell write exceeds {max_bytes} bytes")
180
+ process.stdin.write(chunk)
181
+ await process.stdin.drain()
182
+ return {"written": written}
183
+
184
+ async def _shell_read(self, stream: MuxRpcStream, params: dict[str, object]) -> dict[str, object]:
185
+ allowed = {"shell_id", "timeout", "max_bytes"}
186
+ unknown = sorted(set(params) - allowed)
187
+ if unknown:
188
+ raise MuxRpcFailure("invalid_request", f"unknown shell.read fields: {', '.join(unknown)}")
189
+ shell_id = _shell_id(params)
190
+ timeout_value = params.get("timeout")
191
+ timeout = (
192
+ None
193
+ if timeout_value is None
194
+ else _bounded_non_negative_number(timeout_value, "timeout", maximum=MAX_EXEC_TIMEOUT)
195
+ )
196
+ max_bytes = _bounded_integer(params.get("max_bytes", 4096), "max_bytes", maximum=MAX_TRANSFER_BYTES)
197
+ process = await self._ensure_shell(shell_id)
198
+ if process.stdout is None:
199
+ raise MuxRpcFailure("connection_lost", "shell stdout is unavailable", retryable=True)
200
+ await stream.accept()
201
+ while await stream.receive(TCP_CHUNK_BYTES):
202
+ raise MuxRpcFailure("invalid_request", "shell.read does not accept data")
203
+ async with self._shell_read_locks[shell_id]:
204
+ if timeout is None:
205
+ data = await process.stdout.read(max_bytes)
206
+ else:
207
+ try:
208
+ async with asyncio.timeout(timeout):
209
+ data = await process.stdout.read(max_bytes)
210
+ except TimeoutError:
211
+ data = b""
212
+ await stream.send(bytes(data))
213
+ return {"alive": process.returncode is None, "bytes": len(data)}
214
+
215
+ async def _shell_attach(self, stream: MuxRpcStream, params: dict[str, object]) -> dict[str, object]:
216
+ allowed = {"shell_id", "max_bytes"}
217
+ unknown = sorted(set(params) - allowed)
218
+ if unknown:
219
+ raise MuxRpcFailure("invalid_request", f"unknown shell.attach fields: {', '.join(unknown)}")
220
+ shell_id = _shell_id(params)
221
+ max_bytes = _bounded_integer(params.get("max_bytes", 64 * 1024), "max_bytes", maximum=MAX_TRANSFER_BYTES)
222
+ process = await self._ensure_shell(shell_id)
223
+ if process.stdin is None or process.stdout is None:
224
+ raise MuxRpcFailure("connection_lost", "shell pipes are unavailable", retryable=True)
225
+ shell_stdin = process.stdin
226
+ shell_stdout = process.stdout
227
+ await stream.accept()
228
+
229
+ async def send_input() -> None:
230
+ async with self._shell_write_locks[shell_id]:
231
+ while chunk := await stream.receive(TCP_CHUNK_BYTES):
232
+ if len(chunk) > max_bytes:
233
+ raise MuxRpcFailure("resource_limit", f"shell input chunk exceeds {max_bytes} bytes")
234
+ shell_stdin.write(chunk)
235
+ await shell_stdin.drain()
236
+ shell_stdin.write_eof()
237
+ with suppress(BrokenPipeError, ConnectionResetError):
238
+ await shell_stdin.drain()
239
+
240
+ input_task = asyncio.create_task(send_input(), name=f"hostbridge-remote-shell-input-{shell_id}")
241
+ transferred = 0
242
+ try:
243
+ async with self._shell_read_locks[shell_id]:
244
+ while data := await shell_stdout.read(max_bytes):
245
+ await stream.send(bytes(data))
246
+ transferred += len(data)
247
+ await stop_input_after_peer_eof(input_task)
248
+ returncode = await process.wait()
249
+ if returncode < 0:
250
+ try:
251
+ signal_name = signal.Signals(-returncode).name
252
+ except ValueError:
253
+ signal_name = f"SIG{-returncode}"
254
+ return {"exit_code": None, "signal": signal_name, "bytes": transferred}
255
+ return {"exit_code": returncode, "signal": None, "bytes": transferred}
256
+ finally:
257
+ if not input_task.done():
258
+ input_task.cancel()
259
+ cleanup = asyncio.gather(input_task, return_exceptions=True)
260
+ await finish_task_before_cancellation(cleanup)
261
+
262
+ async def _ensure_shell(self, shell_id: str) -> asyncio.subprocess.Process:
263
+ async with self._shell_creation_lock:
264
+ process = self._shells.get(shell_id)
265
+ if process is not None and process.returncode is None:
266
+ return process
267
+ process = await asyncio.create_subprocess_exec(
268
+ "/bin/sh",
269
+ stdin=asyncio.subprocess.PIPE,
270
+ stdout=asyncio.subprocess.PIPE,
271
+ stderr=asyncio.subprocess.STDOUT,
272
+ start_new_session=True,
273
+ )
274
+ self._shells[shell_id] = process
275
+ self._shell_read_locks[shell_id] = asyncio.Lock()
276
+ self._shell_write_locks[shell_id] = asyncio.Lock()
277
+ return process
278
+
279
+ async def _close_shell(self, shell_id: str) -> None:
280
+ async with self._shell_creation_lock:
281
+ process = self._shells.pop(shell_id, None)
282
+ self._shell_read_locks.pop(shell_id, None)
283
+ self._shell_write_locks.pop(shell_id, None)
284
+ if process is not None:
285
+ await _terminate_process(process, self.cleanup_timeout_seconds)
286
+
287
+
288
+ class _CombinedOutput:
289
+ def __init__(self, limit: int) -> None:
290
+ self._limit = limit
291
+ self._segments: deque[tuple[MuxRecordChannel, bytes]] = deque()
292
+ self._size = 0
293
+
294
+ def append(self, channel: MuxRecordChannel, chunk: bytes) -> None:
295
+ if not chunk:
296
+ return
297
+ self._segments.append((channel, bytes(chunk)))
298
+ self._size += len(chunk)
299
+ excess = self._size - self._limit
300
+ while excess > 0:
301
+ segment_channel, segment = self._segments[0]
302
+ if len(segment) <= excess:
303
+ self._segments.popleft()
304
+ self._size -= len(segment)
305
+ excess -= len(segment)
306
+ else:
307
+ self._segments[0] = (segment_channel, segment[excess:])
308
+ self._size -= excess
309
+ excess = 0
310
+
311
+ async def send(self, stream: MuxRpcStream) -> None:
312
+ for channel, segment in self._segments:
313
+ for offset in range(0, len(segment), MAX_RECORD_PAYLOAD_BYTES):
314
+ payload = segment[offset : offset + MAX_RECORD_PAYLOAD_BYTES]
315
+ await stream.send(encode_record(MuxRecord(channel, payload)))
316
+
317
+
318
+ async def _run_exec(
319
+ stream: MuxRpcStream,
320
+ params: dict[str, object],
321
+ cleanup_timeout_seconds: float,
322
+ ) -> dict[str, object]:
323
+ allowed = {"command", "timeout", "output_limit"}
324
+ unknown = sorted(set(params) - allowed)
325
+ if unknown:
326
+ raise MuxRpcFailure("invalid_request", f"unknown exec.start fields: {', '.join(unknown)}")
327
+ command = _required_string(params, "command")
328
+ timeout = _bounded_number(params.get("timeout", 60), "timeout", maximum=MAX_EXEC_TIMEOUT)
329
+ output_limit = _bounded_integer(
330
+ params.get("output_limit", 1_000_000),
331
+ "output_limit",
332
+ maximum=MAX_EXEC_OUTPUT_BYTES,
333
+ )
334
+ try:
335
+ process = await asyncio.create_subprocess_exec(
336
+ "/bin/sh",
337
+ "-lc",
338
+ command,
339
+ stdin=asyncio.subprocess.PIPE,
340
+ stdout=asyncio.subprocess.PIPE,
341
+ stderr=asyncio.subprocess.PIPE,
342
+ start_new_session=True,
343
+ )
344
+ except OSError as exc:
345
+ raise MuxRpcFailure("connection_lost", f"failed to start command: {exc}", retryable=True) from exc
346
+ stdin_writer = process.stdin
347
+ stdout_reader = process.stdout
348
+ stderr_reader = process.stderr
349
+ if stdin_writer is None or stdout_reader is None or stderr_reader is None:
350
+ await _terminate_process(process, cleanup_timeout_seconds)
351
+ raise MuxRpcFailure("internal", "command subprocess pipes are unavailable")
352
+ await stream.accept()
353
+ output = _CombinedOutput(output_limit)
354
+
355
+ async def write_stdin() -> None:
356
+ try:
357
+ while chunk := await stream.receive(TCP_CHUNK_BYTES):
358
+ stdin_writer.write(chunk)
359
+ await stdin_writer.drain()
360
+ if stdin_writer.can_write_eof():
361
+ stdin_writer.write_eof()
362
+ await stdin_writer.drain()
363
+ else:
364
+ stdin_writer.close()
365
+ except (BrokenPipeError, ConnectionResetError):
366
+ return
367
+
368
+ async def read_output(reader: asyncio.StreamReader, channel: MuxRecordChannel) -> None:
369
+ while chunk := await reader.read(TCP_CHUNK_BYTES):
370
+ output.append(channel, chunk)
371
+
372
+ tasks = [
373
+ asyncio.create_task(write_stdin(), name=f"remote-agent-exec-stdin-{stream.stream_id}"),
374
+ asyncio.create_task(
375
+ read_output(stdout_reader, MuxRecordChannel.STDOUT),
376
+ name=f"remote-agent-exec-stdout-{stream.stream_id}",
377
+ ),
378
+ asyncio.create_task(
379
+ read_output(stderr_reader, MuxRecordChannel.STDERR),
380
+ name=f"remote-agent-exec-stderr-{stream.stream_id}",
381
+ ),
382
+ ]
383
+ timed_out = False
384
+ try:
385
+ async with asyncio.timeout(timeout):
386
+ await asyncio.gather(process.wait(), *tasks)
387
+ except TimeoutError:
388
+ timed_out = True
389
+ await _terminate_process(process, cleanup_timeout_seconds)
390
+ except BaseException:
391
+ await _terminate_process(process, cleanup_timeout_seconds)
392
+ raise
393
+ finally:
394
+ for task in tasks:
395
+ if not task.done():
396
+ task.cancel()
397
+ await asyncio.gather(*tasks, return_exceptions=True)
398
+ await output.send(stream)
399
+ return {
400
+ "exit_code": None if timed_out else process.returncode,
401
+ "timed_out": timed_out,
402
+ }
403
+
404
+
405
+ async def _terminate_process(process: asyncio.subprocess.Process, timeout: float) -> None:
406
+ cleanup = asyncio.create_task(
407
+ _terminate_process_owned(process, timeout),
408
+ name=f"remote-agent-process-reap-{process.pid}",
409
+ )
410
+ cancellation: asyncio.CancelledError | None = None
411
+ while not cleanup.done():
412
+ try:
413
+ await asyncio.shield(cleanup)
414
+ except asyncio.CancelledError as exc:
415
+ cancellation = exc
416
+ cleanup.result()
417
+ if cancellation is not None:
418
+ raise cancellation
419
+
420
+
421
+ async def _terminate_process_owned(process: asyncio.subprocess.Process, timeout: float) -> None:
422
+ if process.returncode is not None:
423
+ await process.wait()
424
+ return
425
+ with suppress(ProcessLookupError):
426
+ os.killpg(process.pid, signal.SIGTERM)
427
+ try:
428
+ async with asyncio.timeout(timeout):
429
+ await process.wait()
430
+ except TimeoutError:
431
+ with suppress(ProcessLookupError):
432
+ os.killpg(process.pid, signal.SIGKILL)
433
+ await process.wait()
434
+
435
+
436
+ async def _receive_upload(stream: MuxRpcStream, params: dict[str, object]) -> dict[str, object]:
437
+ allowed = {"remote_path", "mode", "expected_size", "expected_sha256", "max_bytes"}
438
+ unknown = sorted(set(params) - allowed)
439
+ if unknown:
440
+ raise MuxRpcFailure("invalid_request", f"unknown transfer.upload fields: {', '.join(unknown)}")
441
+ remote_path = _remote_path(params, "remote_path")
442
+ mode = _file_mode(params.get("mode", 0o644))
443
+ max_bytes = _bounded_integer(
444
+ params.get("max_bytes", MAX_TRANSFER_BYTES),
445
+ "max_bytes",
446
+ maximum=MAX_TRANSFER_BYTES,
447
+ )
448
+ expected_size_value = params.get("expected_size")
449
+ expected_size = (
450
+ None
451
+ if expected_size_value is None
452
+ else _non_negative_integer(expected_size_value, "expected_size", maximum=max_bytes)
453
+ )
454
+ expected_sha256 = _optional_sha256(params.get("expected_sha256"))
455
+ parent = os.path.dirname(remote_path) or "."
456
+ prefix = f".{os.path.basename(remote_path)}.hostbridge-"
457
+ try:
458
+ descriptor, temporary = tempfile.mkstemp(prefix=prefix, dir=parent)
459
+ except OSError as exc:
460
+ raise MuxRpcFailure("remote_error", f"failed to create upload temporary file: {exc}") from exc
461
+ transferred = 0
462
+ digest = hashlib.sha256()
463
+ file = os.fdopen(descriptor, "wb", buffering=0)
464
+ try:
465
+ os.fchmod(file.fileno(), 0o600)
466
+ await stream.accept()
467
+ while chunk := await stream.receive(TCP_CHUNK_BYTES):
468
+ transferred += len(chunk)
469
+ if transferred > max_bytes:
470
+ raise MuxRpcFailure("resource_limit", f"upload exceeds {max_bytes} bytes")
471
+ await asyncio.to_thread(file.write, chunk)
472
+ digest.update(chunk)
473
+ if expected_size is not None and transferred != expected_size:
474
+ raise MuxRpcFailure(
475
+ "integrity_error",
476
+ f"upload size mismatch: expected {expected_size}, received {transferred}",
477
+ )
478
+ actual_sha256 = digest.hexdigest()
479
+ if expected_sha256 is not None and actual_sha256 != expected_sha256:
480
+ raise MuxRpcFailure(
481
+ "integrity_error",
482
+ f"upload SHA-256 mismatch: expected {expected_sha256}, received {actual_sha256}",
483
+ )
484
+ await asyncio.to_thread(os.fsync, file.fileno())
485
+ file.close()
486
+ os.chmod(temporary, mode)
487
+ os.replace(temporary, remote_path)
488
+ except BaseException:
489
+ file.close()
490
+ with suppress(FileNotFoundError):
491
+ os.unlink(temporary)
492
+ raise
493
+ return {"bytes_transferred": transferred, "sha256": digest.hexdigest()}
494
+
495
+
496
+ async def _send_download(stream: MuxRpcStream, params: dict[str, object]) -> dict[str, object]:
497
+ allowed = {"remote_path", "chunk_size", "max_bytes"}
498
+ unknown = sorted(set(params) - allowed)
499
+ if unknown:
500
+ raise MuxRpcFailure("invalid_request", f"unknown transfer.download fields: {', '.join(unknown)}")
501
+ remote_path = _remote_path(params, "remote_path")
502
+ chunk_size = _bounded_integer(
503
+ params.get("chunk_size", 256 * 1024),
504
+ "chunk_size",
505
+ maximum=MAX_TRANSFER_CHUNK_BYTES,
506
+ )
507
+ if chunk_size < 4096:
508
+ raise MuxRpcFailure("invalid_request", "chunk_size must be at least 4096")
509
+ max_bytes = _bounded_integer(
510
+ params.get("max_bytes", MAX_TRANSFER_BYTES),
511
+ "max_bytes",
512
+ maximum=MAX_TRANSFER_BYTES,
513
+ )
514
+ transferred = 0
515
+ digest = hashlib.sha256()
516
+ try:
517
+ with open(remote_path, "rb", buffering=0) as file: # noqa: PTH123
518
+ await stream.accept()
519
+ while chunk := await asyncio.to_thread(file.read, chunk_size):
520
+ transferred += len(chunk)
521
+ if transferred > max_bytes:
522
+ raise MuxRpcFailure("resource_limit", f"download exceeds {max_bytes} bytes")
523
+ digest.update(chunk)
524
+ await stream.send(chunk)
525
+ except OSError as exc:
526
+ raise MuxRpcFailure("remote_error", f"failed to read download source: {exc}") from exc
527
+ return {"bytes_transferred": transferred, "sha256": digest.hexdigest()}
528
+
529
+
530
+ class _PipeWriterProtocol(asyncio.streams.FlowControlMixin):
531
+ def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
532
+ super().__init__(loop=loop)
533
+ self._closed = loop.create_future()
534
+
535
+ def connection_lost(self, exc: Exception | None) -> None:
536
+ super().connection_lost(exc)
537
+ if self._closed.done():
538
+ return
539
+ if exc is None:
540
+ self._closed.set_result(None)
541
+ else:
542
+ self._closed.set_exception(exc)
543
+
544
+ def _get_close_waiter(self, stream: object) -> asyncio.Future[None]: # noqa: ARG002
545
+ return self._closed
546
+
547
+
548
+ async def run_stdio_agent() -> int:
549
+ loop = asyncio.get_running_loop()
550
+ reader = asyncio.StreamReader()
551
+ reader_protocol = asyncio.StreamReaderProtocol(reader)
552
+ read_transport, _ = await loop.connect_read_pipe(lambda: reader_protocol, sys.stdin.buffer)
553
+ write_protocol = _PipeWriterProtocol(loop)
554
+ write_transport, _ = await loop.connect_write_pipe(lambda: write_protocol, sys.stdout.buffer)
555
+ writer = asyncio.StreamWriter(write_transport, write_protocol, reader, loop)
556
+ try:
557
+ await RemoteMuxAgent().serve(reader, writer)
558
+ finally:
559
+ read_transport.close()
560
+ return 0
561
+
562
+
563
+ class _Destination:
564
+ __slots__ = ("address", "family", "port", "timeout")
565
+
566
+ def __init__(self, address: str, family: socket.AddressFamily, port: int, timeout: float) -> None:
567
+ self.address = address
568
+ self.family = family
569
+ self.port = port
570
+ self.timeout = timeout
571
+
572
+
573
+ async def _resolve_destination(params: dict[str, object]) -> _Destination:
574
+ allowed = {
575
+ "host",
576
+ "port",
577
+ "allow_hostnames",
578
+ "allow_cidrs",
579
+ "allow_ports",
580
+ "connect_timeout",
581
+ }
582
+ unknown = sorted(set(params) - allowed)
583
+ if unknown:
584
+ raise MuxRpcFailure("invalid_request", f"unknown tcp.connect fields: {', '.join(unknown)}")
585
+
586
+ host = _required_string(params, "host")
587
+ port = _port(params.get("port"), "port")
588
+ allow_ports = _ports(params.get("allow_ports"), "allow_ports")
589
+ if port not in allow_ports:
590
+ raise MuxRpcFailure("denied", f"destination port {port} is not allowed")
591
+ networks = _networks(params.get("allow_cidrs"))
592
+ hostnames = _strings(params.get("allow_hostnames"), "allow_hostnames")
593
+ timeout = _timeout(params.get("connect_timeout", DEFAULT_CONNECT_TIMEOUT))
594
+
595
+ try:
596
+ literal = ipaddress.ip_address(host)
597
+ except ValueError:
598
+ literal = None
599
+
600
+ if literal is not None:
601
+ _require_allowed_address(literal, networks)
602
+ family = socket.AF_INET6 if literal.version == 6 else socket.AF_INET
603
+ return _Destination(str(literal), family, port, timeout)
604
+
605
+ normalized_host = host.rstrip(".").casefold()
606
+ if normalized_host not in {value.rstrip(".").casefold() for value in hostnames}:
607
+ raise MuxRpcFailure("denied", f"destination hostname {host!r} is not allowed")
608
+ try:
609
+ records = await asyncio.get_running_loop().getaddrinfo(
610
+ host,
611
+ port,
612
+ family=socket.AF_UNSPEC,
613
+ type=socket.SOCK_STREAM,
614
+ proto=socket.IPPROTO_TCP,
615
+ )
616
+ except OSError as exc:
617
+ raise MuxRpcFailure("connection_lost", f"destination resolution failed: {exc}", retryable=True) from exc
618
+ if not records:
619
+ raise MuxRpcFailure("connection_lost", "destination resolution returned no addresses", retryable=True)
620
+
621
+ resolved: list[tuple[socket.AddressFamily, ipaddress.IPv4Address | ipaddress.IPv6Address]] = []
622
+ for family, _socktype, _protocol, _canonical_name, sockaddr in records:
623
+ if family not in {socket.AF_INET, socket.AF_INET6}:
624
+ continue
625
+ address = ipaddress.ip_address(sockaddr[0])
626
+ _require_allowed_address(address, networks)
627
+ resolved.append((family, address))
628
+ if not resolved:
629
+ raise MuxRpcFailure("connection_lost", "destination resolution returned no TCP addresses", retryable=True)
630
+ family, address = resolved[0]
631
+ return _Destination(str(address), family, port, timeout)
632
+
633
+
634
+ async def _bridge_tcp(
635
+ stream: MuxRpcStream,
636
+ reader: asyncio.StreamReader,
637
+ writer: asyncio.StreamWriter,
638
+ ) -> dict[str, object]:
639
+ bytes_sent = 0
640
+ bytes_received = 0
641
+
642
+ async def upstream() -> None:
643
+ nonlocal bytes_sent
644
+ while chunk := await stream.receive(TCP_CHUNK_BYTES):
645
+ writer.write(chunk)
646
+ await writer.drain()
647
+ bytes_sent += len(chunk)
648
+ if writer.can_write_eof():
649
+ writer.write_eof()
650
+ await writer.drain()
651
+
652
+ async def downstream() -> None:
653
+ nonlocal bytes_received
654
+ while chunk := await reader.read(TCP_CHUNK_BYTES):
655
+ await stream.send(chunk)
656
+ bytes_received += len(chunk)
657
+
658
+ try:
659
+ async with asyncio.TaskGroup() as group:
660
+ group.create_task(upstream(), name=f"remote-agent-upstream-{stream.stream_id}")
661
+ group.create_task(downstream(), name=f"remote-agent-downstream-{stream.stream_id}")
662
+ except* (ConnectionError, OSError) as group:
663
+ error = group.exceptions[0]
664
+ raise MuxRpcFailure("connection_lost", f"destination stream failed: {error}", retryable=True) from error
665
+ return {"bytes_sent": bytes_sent, "bytes_received": bytes_received}
666
+
667
+
668
+ def _required_string(params: dict[str, object], name: str) -> str:
669
+ value = params.get(name)
670
+ if not isinstance(value, str) or not value:
671
+ raise MuxRpcFailure("invalid_request", f"{name} must be a non-empty string")
672
+ return value
673
+
674
+
675
+ def _strings(value: object, name: str) -> tuple[str, ...]:
676
+ if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value):
677
+ raise MuxRpcFailure("invalid_request", f"{name} must be a list of non-empty strings")
678
+ return tuple(value)
679
+
680
+
681
+ def _ports(value: object, name: str) -> tuple[int, ...]:
682
+ if not isinstance(value, list):
683
+ raise MuxRpcFailure("invalid_request", f"{name} must be a list of ports")
684
+ return tuple(_port(item, name) for item in value)
685
+
686
+
687
+ def _port(value: object, name: str) -> int:
688
+ if not isinstance(value, int) or isinstance(value, bool) or not 1 <= value <= 65535:
689
+ raise MuxRpcFailure("invalid_request", f"{name} must contain ports between 1 and 65535")
690
+ return value
691
+
692
+
693
+ def _networks(value: object) -> tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...]:
694
+ values = _strings(value, "allow_cidrs")
695
+ try:
696
+ return tuple(ipaddress.ip_network(item, strict=True) for item in values)
697
+ except ValueError as exc:
698
+ raise MuxRpcFailure("invalid_request", f"allow_cidrs contains an invalid network: {exc}") from exc
699
+
700
+
701
+ def _require_allowed_address(
702
+ address: ipaddress.IPv4Address | ipaddress.IPv6Address,
703
+ networks: Sequence[ipaddress.IPv4Network | ipaddress.IPv6Network],
704
+ ) -> None:
705
+ if not any(address.version == network.version and address in network for network in networks):
706
+ raise MuxRpcFailure("denied", f"resolved destination address {address} is not allowed")
707
+
708
+
709
+ def _timeout(value: object) -> float:
710
+ if not isinstance(value, int | float) or isinstance(value, bool) or not 0 < float(value) <= MAX_CONNECT_TIMEOUT:
711
+ raise MuxRpcFailure("invalid_request", f"connect_timeout must be between 0 and {MAX_CONNECT_TIMEOUT:g}")
712
+ return float(value)
713
+
714
+
715
+ def _bounded_number(value: object, name: str, *, maximum: float) -> float:
716
+ if not isinstance(value, int | float) or isinstance(value, bool) or not 0 < float(value) <= maximum:
717
+ raise MuxRpcFailure("invalid_request", f"{name} must be between 0 and {maximum:g}")
718
+ return float(value)
719
+
720
+
721
+ def _bounded_non_negative_number(value: object, name: str, *, maximum: float) -> float:
722
+ if not isinstance(value, int | float) or isinstance(value, bool) or not 0 <= float(value) <= maximum:
723
+ raise MuxRpcFailure("invalid_request", f"{name} must be between 0 and {maximum:g}")
724
+ return float(value)
725
+
726
+
727
+ def _bounded_integer(value: object, name: str, *, maximum: int) -> int:
728
+ if not isinstance(value, int) or isinstance(value, bool) or not 0 < value <= maximum:
729
+ raise MuxRpcFailure("invalid_request", f"{name} must be between 1 and {maximum}")
730
+ return value
731
+
732
+
733
+ def _non_negative_integer(value: object, name: str, *, maximum: int) -> int:
734
+ if not isinstance(value, int) or isinstance(value, bool) or not 0 <= value <= maximum:
735
+ raise MuxRpcFailure("invalid_request", f"{name} must be between 0 and {maximum}")
736
+ return value
737
+
738
+
739
+ def _file_mode(value: object) -> int:
740
+ if not isinstance(value, int) or isinstance(value, bool) or not 0 <= value <= 0o7777:
741
+ raise MuxRpcFailure("invalid_request", "mode must be an integer between 0 and 07777")
742
+ return value
743
+
744
+
745
+ def _shell_id(params: dict[str, object]) -> str:
746
+ shell_id = _required_string(params, "shell_id")
747
+ if len(shell_id) > 128 or any(
748
+ character not in "0123456789abcdefghijklmnopqrstuvwxyz-_" for character in shell_id.lower()
749
+ ):
750
+ raise MuxRpcFailure("invalid_request", "shell_id contains unsupported characters")
751
+ return shell_id
752
+
753
+
754
+ def _optional_sha256(value: object) -> str | None:
755
+ if value is None:
756
+ return None
757
+ if not isinstance(value, str) or len(value) != 64:
758
+ raise MuxRpcFailure("invalid_request", "expected_sha256 must be 64 hexadecimal characters or null")
759
+ normalized = value.lower()
760
+ if any(character not in "0123456789abcdef" for character in normalized):
761
+ raise MuxRpcFailure("invalid_request", "expected_sha256 must be 64 hexadecimal characters or null")
762
+ return normalized
763
+
764
+
765
+ def _remote_path(params: dict[str, object], name: str) -> str:
766
+ value = _required_string(params, name)
767
+ if "\x00" in value:
768
+ raise MuxRpcFailure("invalid_request", f"{name} must not contain NUL bytes")
769
+ return value