@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,506 @@
1
+ """HostBridge service operations exposed over multiplexed RPC streams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ from collections.abc import AsyncGenerator, Awaitable, Callable
8
+ from dataclasses import asdict
9
+ from typing import Any
10
+
11
+ from . import __version__
12
+ from .async_lifecycle import finish_task_before_cancellation
13
+ from .mux_protocol import MUX_PROTOCOL_VERSION
14
+ from .mux_records import MAX_RECORD_PAYLOAD_BYTES, MuxRecord, MuxRecordChannel, encode_record
15
+ from .mux_rpc import MuxRpcFailure
16
+ from .mux_stream import MuxRpcStream
17
+ from .runtime_services import AsyncHostBridgeServices
18
+ from .services import ServiceError
19
+ from .tunnel_manager import HostTunnelManager, TunnelError
20
+
21
+
22
+ class HostBridgeMuxService:
23
+ def __init__(self, services: AsyncHostBridgeServices, *, tunnel_manager: HostTunnelManager | None = None) -> None:
24
+ self.services = services
25
+ self.tunnel_manager = tunnel_manager
26
+ self._unary_methods = {
27
+ "system.hello",
28
+ "system.health",
29
+ "tunnel.health",
30
+ "system.shutdown",
31
+ "host.list",
32
+ "session.open",
33
+ "session.list",
34
+ "session.close",
35
+ "session.release_mock_ssh",
36
+ "session.close_all",
37
+ "task.start",
38
+ "task.status",
39
+ "task.cancel",
40
+ "file.write_text",
41
+ "file.read_text",
42
+ }
43
+ self._stream_handlers: dict[
44
+ str,
45
+ Callable[[dict[str, object], MuxRpcStream], Awaitable[dict[str, object]]],
46
+ ] = {
47
+ "exec.start": self._exec,
48
+ "transfer.upload": self._upload,
49
+ "transfer.download": self._download,
50
+ "shell.write": self._shell_write,
51
+ "shell.read": self._shell_read,
52
+ "shell.attach": self._shell_attach,
53
+ }
54
+
55
+ async def handle_unary_async(
56
+ self,
57
+ method: str,
58
+ params: dict[str, object],
59
+ ) -> dict[str, object]:
60
+ try:
61
+ return await self._dispatch_unary(method, params)
62
+ except asyncio.CancelledError:
63
+ raise
64
+ except MuxRpcFailure:
65
+ raise
66
+ except ServiceError as exc:
67
+ raise _service_failure(exc) from exc
68
+ except (TypeError, ValueError) as exc:
69
+ raise MuxRpcFailure("invalid_request", str(exc)) from exc
70
+
71
+ async def handle_stream(
72
+ self,
73
+ method: str,
74
+ params: dict[str, object],
75
+ stream: MuxRpcStream,
76
+ client_connection_id: str,
77
+ ) -> dict[str, object]:
78
+ handler = None if method == "tcp.connect" else self._stream_handlers.get(method)
79
+ if handler is None:
80
+ if method == "tcp.connect":
81
+ try:
82
+ return await self._tcp_connect(params, stream, client_connection_id)
83
+ except asyncio.CancelledError:
84
+ raise
85
+ except MuxRpcFailure:
86
+ raise
87
+ except TunnelError as exc:
88
+ raise MuxRpcFailure(exc.code, str(exc), retryable=exc.retryable) from exc
89
+ except (TypeError, ValueError) as exc:
90
+ raise MuxRpcFailure("invalid_request", str(exc)) from exc
91
+ if method in self._unary_methods:
92
+ raise MuxRpcFailure("invalid_request", f"method {method} does not accept a binary stream")
93
+ raise MuxRpcFailure("not_found", f"unknown method {method}")
94
+ try:
95
+ return await handler(params, stream)
96
+ except asyncio.CancelledError:
97
+ raise
98
+ except MuxRpcFailure:
99
+ raise
100
+ except ServiceError as exc:
101
+ raise _service_failure(exc) from exc
102
+ except TunnelError as exc:
103
+ raise MuxRpcFailure(exc.code, str(exc), retryable=exc.retryable) from exc
104
+ except (TypeError, ValueError) as exc:
105
+ raise MuxRpcFailure("invalid_request", str(exc)) from exc
106
+
107
+ async def _dispatch_unary(self, method: str, params: dict[str, object]) -> dict[str, object]:
108
+ if method in self._stream_handlers or method == "tcp.connect":
109
+ raise MuxRpcFailure("invalid_request", f"method {method} requires a binary stream")
110
+ if method not in self._unary_methods:
111
+ raise MuxRpcFailure("not_found", f"unknown method {method}")
112
+ if method == "system.hello":
113
+ return {
114
+ "service": "hostbridge",
115
+ "protocol": MUX_PROTOCOL_VERSION,
116
+ "version": __version__,
117
+ "capabilities": ["sessions", "exec", "binary_streams", "persistent_mux"],
118
+ }
119
+ if method == "system.health":
120
+ return {"status": "ok", "protocol": MUX_PROTOCOL_VERSION}
121
+ if method == "host.list":
122
+ return {"hosts": self.services.hosts.describe()}
123
+ if method == "tunnel.health":
124
+ return self._tunnel_health(params)
125
+ if method == "system.shutdown":
126
+ return await self.services.close_all(force=True)
127
+ if method == "session.open":
128
+ return await self._session_open(params)
129
+ if method == "session.list":
130
+ return {"sessions": await self.services.list_sessions()}
131
+ if method == "session.close":
132
+ return await self._session_close(params)
133
+ if method == "session.release_mock_ssh":
134
+ return await self._session_release_mock_ssh(params)
135
+ if method == "session.close_all":
136
+ return await self.services.close_all(owner=_optional_owner(params))
137
+ if method == "task.start":
138
+ task = await self.services.start_task(
139
+ _required_string(params, "session_id"),
140
+ _required_string(params, "command"),
141
+ owner=_optional_owner(params),
142
+ )
143
+ return asdict(task)
144
+ if method == "task.status":
145
+ return await self.services.task_status(
146
+ _required_string(params, "session_id"),
147
+ _required_string(params, "task_id"),
148
+ owner=_optional_owner(params),
149
+ tail_lines=_integer(params.get("tail_lines", 80), "tail_lines"),
150
+ )
151
+ if method == "task.cancel":
152
+ return await self.services.cancel_task(
153
+ _required_string(params, "session_id"),
154
+ _required_string(params, "task_id"),
155
+ owner=_optional_owner(params),
156
+ )
157
+ if method == "file.write_text":
158
+ return await self._file_write_text(params)
159
+ return await self._file_read_text(params)
160
+
161
+ def _tunnel_health(self, params: dict[str, object]) -> dict[str, object]:
162
+ if params:
163
+ raise ValueError("tunnel.health does not accept parameters")
164
+ manager = self.tunnel_manager
165
+ if manager is None:
166
+ raise MuxRpcFailure("not_found", "TCP tunnel service is unavailable")
167
+ hosts = []
168
+ for description in self.services.hosts.describe():
169
+ host_id = description.get("id")
170
+ if not isinstance(host_id, str):
171
+ raise MuxRpcFailure("internal", "host registry returned an invalid host id")
172
+ hosts.append(manager.health(host_id))
173
+ return {"hosts": hosts}
174
+
175
+ async def _session_open(self, params: dict[str, object]) -> dict[str, object]:
176
+ connect_timeout = params.get("connect_timeout")
177
+ if connect_timeout is not None and (
178
+ not isinstance(connect_timeout, int | float)
179
+ or isinstance(connect_timeout, bool)
180
+ or not 0 < connect_timeout <= 180
181
+ ):
182
+ raise ValueError("connect_timeout must be between 0 and 180 seconds")
183
+ session = await self.services.open_session(
184
+ _required_string(params, "host_id"),
185
+ owner=_optional_owner(params),
186
+ connect_timeout=float(connect_timeout) if connect_timeout is not None else None,
187
+ )
188
+ return session.describe()
189
+
190
+ async def _session_close(self, params: dict[str, object]) -> dict[str, object]:
191
+ if "force" in params:
192
+ raise ValueError("force close is not available over the public protocol")
193
+ session_id = _required_string(params, "session_id")
194
+ await self.services.close_session(session_id, owner=_optional_owner(params))
195
+ return {"session_id": session_id, "closed": True}
196
+
197
+ async def _session_release_mock_ssh(self, params: dict[str, object]) -> dict[str, object]:
198
+ unknown = sorted(set(params) - {"session_id"})
199
+ if unknown:
200
+ raise ValueError(f"unknown session.release_mock_ssh fields: {', '.join(unknown)}")
201
+ session_id = _required_string(params, "session_id")
202
+ await self.services.close_mock_ssh_session(session_id)
203
+ return {"session_id": session_id, "closed": True}
204
+
205
+ async def _file_write_text(self, params: dict[str, object]) -> dict[str, object]:
206
+ content = params.get("content")
207
+ if not isinstance(content, str):
208
+ raise ValueError("content must be a string")
209
+ result = await self.services.write_text(
210
+ _required_string(params, "session_id"),
211
+ _required_string(params, "remote_path"),
212
+ content,
213
+ mode=_integer(params.get("mode", 0o644), "mode"),
214
+ max_bytes=_positive_integer(params.get("max_bytes", 4 * 1024 * 1024), "max_bytes"),
215
+ owner=_optional_owner(params),
216
+ )
217
+ return _transfer_result(result.bytes_transferred, result.sha256)
218
+
219
+ async def _file_read_text(self, params: dict[str, object]) -> dict[str, object]:
220
+ content = await self.services.read_text(
221
+ _required_string(params, "session_id"),
222
+ _required_string(params, "remote_path"),
223
+ max_bytes=_positive_integer(params.get("max_bytes", 4 * 1024 * 1024), "max_bytes"),
224
+ owner=_optional_owner(params),
225
+ )
226
+ return {"content": content, "bytes_transferred": len(content.encode("utf-8"))}
227
+
228
+ async def _exec(
229
+ self,
230
+ params: dict[str, object],
231
+ stream: MuxRpcStream,
232
+ ) -> dict[str, object]:
233
+ timeout = _positive_number(params.get("timeout", 60), "timeout")
234
+ output_limit = _positive_integer(params.get("output_limit", 1_000_000), "output_limit")
235
+ session_id = _required_string(params, "session_id")
236
+ command = _required_string(params, "command")
237
+ owner = _optional_owner(params)
238
+ await stream.accept()
239
+ result = await self.services.exec(
240
+ session_id,
241
+ command,
242
+ stdin=_receive_chunks(stream),
243
+ timeout=timeout,
244
+ output_limit=output_limit,
245
+ owner=owner,
246
+ )
247
+ await _send_records(stream, MuxRecordChannel.STDOUT, result.stdout)
248
+ await _send_records(stream, MuxRecordChannel.STDERR, result.stderr)
249
+ return {
250
+ "session_id": result.session_id,
251
+ "exit_code": result.exit_code,
252
+ "timed_out": result.timed_out,
253
+ }
254
+
255
+ async def _upload(
256
+ self,
257
+ params: dict[str, object],
258
+ stream: MuxRpcStream,
259
+ ) -> dict[str, object]:
260
+ expected_size_value = params.get("expected_size")
261
+ expected_size = (
262
+ None if expected_size_value is None else _non_negative_integer(expected_size_value, "expected_size")
263
+ )
264
+ expected_sha256 = params.get("expected_sha256")
265
+ if expected_sha256 is not None and not isinstance(expected_sha256, str):
266
+ raise ValueError("expected_sha256 must be a string or null")
267
+ session_id = _required_string(params, "session_id")
268
+ remote_path = _required_string(params, "remote_path")
269
+ mode = _integer(params.get("mode", 0o644), "mode")
270
+ owner = _optional_owner(params)
271
+ await stream.accept()
272
+ result = await self.services.upload(
273
+ session_id,
274
+ _receive_chunks(stream),
275
+ remote_path,
276
+ mode=mode,
277
+ expected_size=expected_size,
278
+ expected_sha256=expected_sha256,
279
+ owner=owner,
280
+ )
281
+ return _transfer_result(result.bytes_transferred, result.sha256)
282
+
283
+ async def _download(
284
+ self,
285
+ params: dict[str, object],
286
+ stream: MuxRpcStream,
287
+ ) -> dict[str, object]:
288
+ chunk_size = _positive_integer(params.get("chunk_size", 256 * 1024), "chunk_size")
289
+ if not 4096 <= chunk_size <= 1024 * 1024:
290
+ raise ValueError("chunk_size must be between 4096 and 1048576 bytes")
291
+ session_id = _required_string(params, "session_id")
292
+ remote_path = _required_string(params, "remote_path")
293
+ owner = _optional_owner(params)
294
+ await stream.accept()
295
+ digest = hashlib.sha256()
296
+ transferred = 0
297
+ async for chunk in self.services.download(
298
+ session_id,
299
+ remote_path,
300
+ chunk_size=chunk_size,
301
+ owner=owner,
302
+ ):
303
+ await stream.send(chunk)
304
+ digest.update(chunk)
305
+ transferred += len(chunk)
306
+ return _transfer_result(transferred, digest.hexdigest())
307
+
308
+ async def _shell_write(
309
+ self,
310
+ params: dict[str, object],
311
+ stream: MuxRpcStream,
312
+ ) -> dict[str, object]:
313
+ max_bytes = _positive_integer(params.get("max_bytes", 1024 * 1024), "max_bytes")
314
+ session_id = _required_string(params, "session_id")
315
+ owner = _optional_owner(params)
316
+ await stream.accept()
317
+ data = bytearray()
318
+ async for chunk in _receive_chunks(stream):
319
+ data.extend(chunk)
320
+ if len(data) > max_bytes:
321
+ raise ServiceError("resource_limit", f"shell write exceeds {max_bytes} bytes")
322
+ written = await self.services.shell_write(session_id, bytes(data), owner=owner)
323
+ return {"written": written}
324
+
325
+ async def _shell_read(
326
+ self,
327
+ params: dict[str, object],
328
+ stream: MuxRpcStream,
329
+ ) -> dict[str, object]:
330
+ timeout = _non_negative_number(params.get("timeout", 0.2), "timeout")
331
+ max_bytes = _positive_integer(params.get("max_bytes", 4096), "max_bytes")
332
+ session_id = _required_string(params, "session_id")
333
+ owner = _optional_owner(params)
334
+ await stream.accept()
335
+ result = await self.services.shell_read(
336
+ session_id,
337
+ timeout=timeout,
338
+ max_bytes=max_bytes,
339
+ owner=owner,
340
+ )
341
+ await stream.send(result.data)
342
+ return {"alive": result.alive, "bytes": len(result.data)}
343
+
344
+ async def _shell_attach(
345
+ self,
346
+ params: dict[str, object],
347
+ stream: MuxRpcStream,
348
+ ) -> dict[str, object]:
349
+ max_bytes = _positive_integer(params.get("max_bytes", 64 * 1024), "max_bytes")
350
+ session_id = _required_string(params, "session_id")
351
+ owner = _optional_owner(params)
352
+ await stream.accept()
353
+ transferred = 0
354
+ attachment = await self.services.attach_shell(
355
+ session_id,
356
+ _receive_chunks(stream),
357
+ max_bytes=max_bytes,
358
+ owner=owner,
359
+ )
360
+ async for chunk in attachment:
361
+ await stream.send(chunk)
362
+ transferred += len(chunk)
363
+ terminal = await attachment.finish()
364
+ return {**terminal.to_mapping(), "bytes": transferred}
365
+
366
+ async def _tcp_connect(
367
+ self,
368
+ params: dict[str, object],
369
+ stream: MuxRpcStream,
370
+ client_connection_id: str,
371
+ ) -> dict[str, object]:
372
+ manager = self.tunnel_manager
373
+ if manager is None:
374
+ raise MuxRpcFailure("not_found", "TCP tunnel service is unavailable")
375
+ allowed = {"host_id", "destination_host", "destination_port"}
376
+ unknown = sorted(set(params) - allowed)
377
+ if unknown:
378
+ raise ValueError(f"unknown tcp.connect fields: {', '.join(unknown)}")
379
+ host_id = _required_string(params, "host_id")
380
+ destination_host = _required_string(params, "destination_host")
381
+ destination_port = _positive_integer(params.get("destination_port"), "destination_port")
382
+ handle = await manager.open_stream(
383
+ host_id,
384
+ client_connection_id,
385
+ stream.stream_id,
386
+ destination_host,
387
+ destination_port,
388
+ )
389
+ await stream.accept()
390
+ bytes_sent = 0
391
+ bytes_received = 0
392
+ tasks: list[asyncio.Future[Any]] = []
393
+ clean = False
394
+
395
+ async def upstream() -> None:
396
+ nonlocal bytes_sent
397
+ while chunk := await stream.receive():
398
+ await handle.send(chunk)
399
+ bytes_sent += len(chunk)
400
+ await handle.send_eof()
401
+
402
+ async def downstream() -> None:
403
+ nonlocal bytes_received
404
+ while chunk := await handle.receive():
405
+ await stream.send(chunk)
406
+ bytes_received += len(chunk)
407
+
408
+ try:
409
+ bridge = asyncio.gather(
410
+ asyncio.create_task(upstream(), name=f"hostbridge-tcp-upstream-{stream.stream_id}"),
411
+ asyncio.create_task(downstream(), name=f"hostbridge-tcp-downstream-{stream.stream_id}"),
412
+ )
413
+ terminal = asyncio.create_task(
414
+ handle.wait_closed(),
415
+ name=f"hostbridge-tcp-terminal-{stream.stream_id}",
416
+ )
417
+ tasks = [bridge, terminal]
418
+ done, _pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
419
+ if terminal in done:
420
+ await terminal
421
+ raise TunnelError("connection_lost", "destination stream closed before both directions drained")
422
+ await bridge
423
+ clean = True
424
+ return {"bytes_sent": bytes_sent, "bytes_received": bytes_received}
425
+ finally:
426
+
427
+ async def finish_bridge() -> None:
428
+ for task in tasks:
429
+ if not task.done():
430
+ task.cancel()
431
+ if tasks:
432
+ await asyncio.gather(*tasks, return_exceptions=True)
433
+ if clean:
434
+ await handle.close()
435
+ else:
436
+ await handle.reset("daemon client tunnel stream closed")
437
+
438
+ cleanup = asyncio.create_task(
439
+ finish_bridge(),
440
+ name=f"hostbridge-tcp-cleanup-{stream.stream_id}",
441
+ )
442
+ await finish_task_before_cancellation(cleanup)
443
+
444
+
445
+ async def _send_records(stream: MuxRpcStream, channel: MuxRecordChannel, data: bytes) -> None:
446
+ for offset in range(0, len(data), MAX_RECORD_PAYLOAD_BYTES):
447
+ await stream.send(encode_record(MuxRecord(channel, data[offset : offset + MAX_RECORD_PAYLOAD_BYTES])))
448
+
449
+
450
+ async def _receive_chunks(stream: MuxRpcStream) -> AsyncGenerator[bytes, None]:
451
+ while chunk := await stream.receive():
452
+ yield chunk
453
+
454
+
455
+ def _service_failure(error: ServiceError) -> MuxRpcFailure:
456
+ return MuxRpcFailure(error.code, str(error), retryable=error.retryable, details=error.details)
457
+
458
+
459
+ def _required_string(params: dict[str, object], name: str) -> str:
460
+ value = params.get(name)
461
+ if not isinstance(value, str) or not value:
462
+ raise ValueError(f"{name} must be a non-empty string")
463
+ return value
464
+
465
+
466
+ def _optional_owner(params: dict[str, object]) -> str | None:
467
+ owner = params.get("owner")
468
+ if owner is not None and (not isinstance(owner, str) or not owner):
469
+ raise ValueError("owner must be a non-empty string or null")
470
+ return owner
471
+
472
+
473
+ def _integer(value: object, name: str) -> int:
474
+ if not isinstance(value, int) or isinstance(value, bool):
475
+ raise ValueError(f"{name} must be an integer")
476
+ return value
477
+
478
+
479
+ def _positive_integer(value: object, name: str) -> int:
480
+ parsed = _integer(value, name)
481
+ if parsed <= 0:
482
+ raise ValueError(f"{name} must be positive")
483
+ return parsed
484
+
485
+
486
+ def _non_negative_integer(value: object, name: str) -> int:
487
+ parsed = _integer(value, name)
488
+ if parsed < 0:
489
+ raise ValueError(f"{name} must be non-negative")
490
+ return parsed
491
+
492
+
493
+ def _positive_number(value: object, name: str) -> float:
494
+ if not isinstance(value, int | float) or isinstance(value, bool) or value <= 0:
495
+ raise ValueError(f"{name} must be positive")
496
+ return float(value)
497
+
498
+
499
+ def _non_negative_number(value: object, name: str) -> float:
500
+ if not isinstance(value, int | float) or isinstance(value, bool) or value < 0:
501
+ raise ValueError(f"{name} must be non-negative")
502
+ return float(value)
503
+
504
+
505
+ def _transfer_result(bytes_transferred: int, sha256: str) -> dict[str, object]:
506
+ return {"bytes_transferred": bytes_transferred, "sha256": sha256.lower()}