@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
@@ -1,530 +1,62 @@
1
1
  from __future__ import annotations
2
2
 
3
- import base64
4
- import binascii
5
- import hashlib
6
- import json
3
+ import asyncio
7
4
  import os
5
+ import shlex
6
+ import signal
8
7
  import socket
9
8
  import stat
10
- import subprocess
9
+ import subprocess # nosec B404
11
10
  import sys
12
- import threading
13
11
  import time
14
12
  from contextlib import suppress
15
- from dataclasses import asdict
13
+ from dataclasses import dataclass
16
14
  from pathlib import Path
17
- from socketserver import StreamRequestHandler, ThreadingMixIn, UnixStreamServer
18
- from typing import Any
19
15
 
20
16
  from . import __version__
21
17
  from .client import HostBridgeClient, HostBridgeError
22
18
  from .hosts import HostRegistry, configured_hosts_path, load_hosts
19
+ from .mux_daemon import MuxDaemonServer
20
+ from .mux_protocol import MUX_PROTOCOL_VERSION
21
+ from .mux_service import HostBridgeMuxService
23
22
  from .policy import build_audit_sink, load_policy
24
- from .protocol import (
25
- PROTOCOL_VERSION,
26
- BinaryFrame,
27
- ErrorInfo,
28
- FrameFlags,
29
- FrameSequenceValidator,
30
- ProtocolError,
31
- Request,
32
- Response,
33
- encode_frame,
34
- read_frame,
35
- )
36
23
  from .runtime import runtime_paths
24
+ from .runtime_services import AsyncHostBridgeServices
37
25
  from .secure_files import open_private_append, replace_private_file
38
- from .services import HostBridgeServices, ServiceError
39
- from .transports.pty import PtyTransport
40
- from .transports.ssh import SshConnectionConfig, SshTransport
26
+ from .tunnel_manager import HostTunnelManager
27
+ from .tunnel_providers import tunnel_provider_factory
41
28
 
42
- MAX_V1_CONTROL_LINE_BYTES = 4 * 1024 * 1024
43
29
  DEFAULT_CONNECT_TIMEOUT = 30
44
- DEFAULT_MAX_DAEMON_CONNECTIONS = 64
45
- DEFAULT_DAEMON_CONNECTION_IDLE_TIMEOUT = 30.0
46
-
47
-
48
- class ThreadingUnixRPCServer(ThreadingMixIn, UnixStreamServer):
49
- daemon_threads = True
50
- allow_reuse_address = True
51
-
52
- def __init__(
53
- self,
54
- server_address: str,
55
- request_handler_class: type[StreamRequestHandler],
56
- bind_and_activate: bool = True,
57
- *,
58
- max_connections: int = DEFAULT_MAX_DAEMON_CONNECTIONS,
59
- connection_idle_timeout: float = DEFAULT_DAEMON_CONNECTION_IDLE_TIMEOUT,
60
- ) -> None:
61
- if not isinstance(max_connections, int) or isinstance(max_connections, bool) or max_connections <= 0:
62
- raise ValueError("max_connections must be a positive integer")
63
- if connection_idle_timeout <= 0:
64
- raise ValueError("connection_idle_timeout must be positive")
65
- self._connection_slots = threading.BoundedSemaphore(max_connections)
66
- self._connection_idle_timeout = float(connection_idle_timeout)
67
- super().__init__(server_address, request_handler_class, bind_and_activate)
68
-
69
- def get_request(self) -> tuple[socket.socket, Any]:
70
- request, client_address = super().get_request()
71
- request.settimeout(self._connection_idle_timeout)
72
- return request, client_address
73
-
74
- def process_request(
75
- self,
76
- request: socket.socket | tuple[bytes, socket.socket],
77
- client_address: Any,
78
- ) -> None:
79
- self._connection_slots.acquire()
80
- try:
81
- super().process_request(request, client_address)
82
- except BaseException:
83
- self._connection_slots.release()
84
- raise
85
-
86
- def process_request_thread(
87
- self,
88
- request: socket.socket | tuple[bytes, socket.socket],
89
- client_address: Any,
90
- ) -> None:
91
- try:
92
- super().process_request_thread(request, client_address)
93
- finally:
94
- self._connection_slots.release()
95
-
96
-
97
- def _required_param(params: dict[str, object], name: str) -> str:
98
- value = params.get(name)
99
- if not isinstance(value, str) or not value.strip():
100
- raise ServiceError("invalid_request", f"{name} must be a non-empty string")
101
- return value
102
-
103
-
104
- def _optional_owner(params: dict[str, object]) -> str | None:
105
- owner = params.get("owner")
106
- if owner is not None and not isinstance(owner, str):
107
- raise ServiceError("invalid_request", "owner must be a string or null")
108
- return owner
109
-
110
-
111
- def _decode_inline_stdin(value: object) -> list[bytes] | None:
112
- if value is None:
113
- return None
114
- if not isinstance(value, dict) or value.get("encoding") != "base64" or not isinstance(value.get("data"), str):
115
- raise ServiceError("invalid_request", "stdin must contain base64 data")
116
- try:
117
- return [base64.b64decode(value["data"], validate=True)]
118
- except (binascii.Error, ValueError) as exc:
119
- raise ServiceError("invalid_request", "stdin contains invalid base64") from exc
120
-
121
-
122
- def _encoded_bytes(value: bytes) -> dict[str, str]:
123
- return {"encoding": "base64", "data": base64.b64encode(value).decode("ascii")}
124
-
125
-
126
- class HostBridgeV1Dispatcher:
127
- def __init__(self, services: HostBridgeServices) -> None:
128
- self.services = services
129
- self.handlers = {
130
- "system.hello": self.system_hello,
131
- "system.health": self.system_health,
132
- "system.shutdown": self.system_shutdown,
133
- "host.list": self.host_list,
134
- "session.open": self.session_open,
135
- "session.list": self.session_list,
136
- "session.close": self.session_close,
137
- "session.close_all": self.session_close_all,
138
- "exec.start": self.exec_start,
139
- "task.start": self.task_start,
140
- "task.status": self.task_status,
141
- "task.cancel": self.task_cancel,
142
- "file.write_text": self.file_write_text,
143
- "file.read_text": self.file_read_text,
144
- "shell.write": self.shell_write,
145
- "shell.read": self.shell_read,
146
- }
147
-
148
- def dispatch(self, request: Request) -> Response:
149
- handler = self.handlers.get(request.method)
150
- if handler is None:
151
- return Response.failure(
152
- request.request_id,
153
- ErrorInfo("not_found", f"unknown method {request.method}"),
154
- )
155
- try:
156
- return Response.success(request.request_id, handler(request.params))
157
- except ServiceError as exc:
158
- return Response.failure(
159
- request.request_id,
160
- ErrorInfo(exc.code, str(exc), retryable=exc.retryable, details=exc.details),
161
- )
162
- except (TypeError, ValueError) as exc:
163
- return Response.failure(request.request_id, ErrorInfo("invalid_request", str(exc)))
164
- except Exception:
165
- return Response.failure(
166
- request.request_id, ErrorInfo("internal", "internal HostBridge error", retryable=True)
167
- )
168
-
169
- def system_hello(self, params: dict[str, object]) -> dict[str, object]:
170
- return {
171
- "service": "hostbridge",
172
- "protocol": PROTOCOL_VERSION,
173
- "version": __version__,
174
- "capabilities": ["sessions", "exec", "explicit_stdin"],
175
- }
176
-
177
- def system_health(self, params: dict[str, object]) -> dict[str, object]:
178
- return {"status": "ok", "protocol": PROTOCOL_VERSION}
179
-
180
- def system_shutdown(self, params: dict[str, object]) -> dict[str, object]:
181
- self.services.stop_idle_reaper()
182
- return self.services.close_all(force=True)
183
-
184
- def host_list(self, params: dict[str, object]) -> dict[str, object]:
185
- return {"hosts": self.services.hosts.describe()}
186
-
187
- def session_open(self, params: dict[str, object]) -> dict[str, object]:
188
- connect_timeout = params.get("connect_timeout")
189
- if connect_timeout is not None and (
190
- not isinstance(connect_timeout, int | float)
191
- or isinstance(connect_timeout, bool)
192
- or not 0 < connect_timeout <= 180
193
- ):
194
- raise ServiceError("invalid_request", "connect_timeout must be between 0 and 180 seconds")
195
- session = self.services.open_session(
196
- _required_param(params, "host_id"),
197
- owner=_optional_owner(params),
198
- connect_timeout=float(connect_timeout) if connect_timeout is not None else None,
199
- )
200
- return session.describe()
201
-
202
- def session_list(self, params: dict[str, object]) -> dict[str, object]:
203
- return {"sessions": self.services.list_sessions()}
204
-
205
- def session_close(self, params: dict[str, object]) -> dict[str, object]:
206
- if "force" in params:
207
- raise ServiceError("invalid_request", "force close is not available over the public protocol")
208
- session_id = _required_param(params, "session_id")
209
- self.services.close_session(session_id, owner=_optional_owner(params))
210
- return {"session_id": session_id, "closed": True}
211
-
212
- def session_close_all(self, params: dict[str, object]) -> dict[str, object]:
213
- return self.services.close_all(owner=_optional_owner(params))
214
-
215
- def exec_start(self, params: dict[str, object]) -> dict[str, object]:
216
- timeout = params.get("timeout", 60)
217
- output_limit = params.get("output_limit", 1_000_000)
218
- if not isinstance(timeout, int | float) or isinstance(timeout, bool) or timeout <= 0:
219
- raise ServiceError("invalid_request", "timeout must be positive")
220
- if not isinstance(output_limit, int) or isinstance(output_limit, bool) or output_limit <= 0:
221
- raise ServiceError("invalid_request", "output_limit must be a positive integer")
222
- result = self.services.exec(
223
- _required_param(params, "session_id"),
224
- _required_param(params, "command"),
225
- stdin=_decode_inline_stdin(params.get("stdin")),
226
- timeout=float(timeout),
227
- output_limit=output_limit,
228
- owner=_optional_owner(params),
229
- )
230
- return {
231
- "session_id": result.session_id,
232
- "exit_code": result.exit_code,
233
- "stdout": _encoded_bytes(result.stdout),
234
- "stderr": _encoded_bytes(result.stderr),
235
- "timed_out": result.timed_out,
236
- }
237
-
238
- def task_start(self, params: dict[str, object]) -> dict[str, object]:
239
- task = self.services.start_task(
240
- _required_param(params, "session_id"),
241
- _required_param(params, "command"),
242
- owner=_optional_owner(params),
243
- )
244
- return asdict(task)
245
-
246
- def task_status(self, params: dict[str, object]) -> dict[str, object]:
247
- tail_lines = params.get("tail_lines", 80)
248
- if not isinstance(tail_lines, int) or isinstance(tail_lines, bool):
249
- raise ServiceError("invalid_request", "tail_lines must be an integer")
250
- return self.services.task_status(
251
- _required_param(params, "session_id"),
252
- _required_param(params, "task_id"),
253
- owner=_optional_owner(params),
254
- tail_lines=tail_lines,
255
- )
256
-
257
- def task_cancel(self, params: dict[str, object]) -> dict[str, object]:
258
- return self.services.cancel_task(
259
- _required_param(params, "session_id"),
260
- _required_param(params, "task_id"),
261
- owner=_optional_owner(params),
262
- )
263
-
264
- def file_write_text(self, params: dict[str, object]) -> dict[str, object]:
265
- content = params.get("content")
266
- mode = params.get("mode", 0o644)
267
- max_bytes = params.get("max_bytes", 4 * 1024 * 1024)
268
- if not isinstance(content, str):
269
- raise ServiceError("invalid_request", "content must be a string")
270
- if not isinstance(mode, int) or isinstance(mode, bool):
271
- raise ServiceError("invalid_request", "mode must be an integer")
272
- if not isinstance(max_bytes, int) or isinstance(max_bytes, bool) or max_bytes <= 0:
273
- raise ServiceError("invalid_request", "max_bytes must be a positive integer")
274
- result = self.services.write_text(
275
- _required_param(params, "session_id"),
276
- _required_param(params, "remote_path"),
277
- content,
278
- mode=mode,
279
- max_bytes=max_bytes,
280
- owner=_optional_owner(params),
281
- )
282
- return {"bytes_transferred": result.bytes_transferred, "sha256": result.sha256}
283
-
284
- def file_read_text(self, params: dict[str, object]) -> dict[str, object]:
285
- max_bytes = params.get("max_bytes", 4 * 1024 * 1024)
286
- if not isinstance(max_bytes, int) or isinstance(max_bytes, bool) or max_bytes <= 0:
287
- raise ServiceError("invalid_request", "max_bytes must be a positive integer")
288
- content = self.services.read_text(
289
- _required_param(params, "session_id"),
290
- _required_param(params, "remote_path"),
291
- max_bytes=max_bytes,
292
- owner=_optional_owner(params),
293
- )
294
- return {"content": content, "bytes_transferred": len(content.encode("utf-8"))}
295
-
296
- def shell_write(self, params: dict[str, object]) -> dict[str, object]:
297
- chunks = _decode_inline_stdin(params.get("data"))
298
- if chunks is None:
299
- raise ServiceError("invalid_request", "data must contain base64 bytes")
300
- written = self.services.shell_write(
301
- _required_param(params, "session_id"),
302
- b"".join(chunks),
303
- owner=_optional_owner(params),
304
- )
305
- return {"written": written}
306
-
307
- def shell_read(self, params: dict[str, object]) -> dict[str, object]:
308
- timeout = params.get("timeout", 0.2)
309
- max_bytes = params.get("max_bytes", 4096)
310
- if not isinstance(timeout, int | float) or isinstance(timeout, bool) or timeout < 0:
311
- raise ServiceError("invalid_request", "timeout must be non-negative")
312
- if not isinstance(max_bytes, int) or isinstance(max_bytes, bool) or max_bytes <= 0:
313
- raise ServiceError("invalid_request", "max_bytes must be a positive integer")
314
- result = self.services.shell_read(
315
- _required_param(params, "session_id"),
316
- timeout=float(timeout),
317
- max_bytes=max_bytes,
318
- owner=_optional_owner(params),
319
- )
320
- return {"data": _encoded_bytes(result.data), "alive": result.alive}
321
-
30
+ _DAEMON_STOP_GRACE_SECONDS = 5.0
31
+ _DAEMON_TERM_SECONDS = 2.0
32
+ _DAEMON_KILL_SECONDS = 2.0
33
+ _PROCESS_POLL_INTERVAL_SECONDS = 0.05
322
34
 
323
- class HostBridgeV1Handler(StreamRequestHandler):
324
- dispatcher: HostBridgeV1Dispatcher
325
35
 
326
- def handle(self) -> None:
327
- try:
328
- self._handle_requests()
329
- except OSError:
330
- return
331
-
332
- def _handle_requests(self) -> None:
333
- while raw_line := self.rfile.readline(MAX_V1_CONTROL_LINE_BYTES + 1):
334
- request_id = "invalid"
335
- try:
336
- if len(raw_line) > MAX_V1_CONTROL_LINE_BYTES:
337
- raise ProtocolError("request exceeds the control limit")
338
- request = Request.from_json(raw_line.decode("utf-8"))
339
- request_id = request.request_id
340
- if request.method == "transfer.upload":
341
- response = self._handle_upload(request)
342
- elif request.method == "exec.stream":
343
- response = self._handle_exec_stream(request)
344
- elif request.method == "transfer.download":
345
- self._handle_download(request)
346
- continue
347
- else:
348
- response = self.dispatcher.dispatch(request)
349
- except ServiceError as exc:
350
- response = Response.failure(
351
- request_id,
352
- ErrorInfo(exc.code, str(exc), retryable=exc.retryable, details=exc.details),
353
- )
354
- except (ProtocolError, UnicodeDecodeError) as exc:
355
- response = Response.failure(request_id, ErrorInfo("invalid_request", str(exc)))
356
- self.wfile.write(response.to_json().encode("utf-8") + b"\n")
357
- self.wfile.flush()
358
- if request_id != "invalid" and request.method == "system.shutdown":
359
- threading.Thread(target=self.server.shutdown, daemon=True).start()
360
-
361
- def _handle_upload(self, request: Request) -> Response:
362
- params = request.params
363
- stream_id = _required_param(params, "stream_id")
364
- expected_size = params.get("expected_size")
365
- mode = params.get("mode", 0o644)
366
- if not isinstance(expected_size, int) or isinstance(expected_size, bool) or expected_size < 0:
367
- raise ServiceError("invalid_request", "expected_size must be a non-negative integer")
368
- if not isinstance(mode, int) or isinstance(mode, bool):
369
- raise ServiceError("invalid_request", "mode must be an integer")
370
- validator = FrameSequenceValidator()
371
-
372
- def chunks():
373
- while True:
374
- frame = read_frame(self.rfile)
375
- validator.accept(frame)
376
- if frame.stream_id != stream_id:
377
- raise ServiceError("invalid_request", "upload frame stream_id does not match")
378
- if frame.flags & FrameFlags.CANCEL:
379
- raise ServiceError("cancelled", "upload cancelled")
380
- if frame.flags & FrameFlags.DATA:
381
- yield frame.payload
382
- if frame.flags & FrameFlags.EOF:
383
- return
384
-
385
- result = self.dispatcher.services.upload(
386
- _required_param(params, "session_id"),
387
- chunks(),
388
- _required_param(params, "remote_path"),
389
- mode=mode,
390
- expected_size=expected_size,
391
- expected_sha256=None,
392
- owner=_optional_owner(params),
393
- )
394
- return Response.success(
395
- request.request_id,
396
- {"bytes_transferred": result.bytes_transferred, "sha256": result.sha256},
397
- )
36
+ @dataclass(frozen=True, slots=True)
37
+ class DaemonRuntime:
38
+ services: AsyncHostBridgeServices
39
+ tunnel_manager: HostTunnelManager
398
40
 
399
- def _handle_exec_stream(self, request: Request) -> Response:
400
- params = request.params
401
- stream_id = _required_param(params, "stream_id")
402
- timeout = params.get("timeout", 60)
403
- output_limit = params.get("output_limit", 1_000_000)
404
- if not isinstance(timeout, int | float) or isinstance(timeout, bool) or timeout <= 0:
405
- raise ServiceError("invalid_request", "timeout must be positive")
406
- if not isinstance(output_limit, int) or isinstance(output_limit, bool) or output_limit <= 0:
407
- raise ServiceError("invalid_request", "output_limit must be a positive integer")
408
- validator = FrameSequenceValidator()
409
-
410
- def chunks():
411
- while True:
412
- frame = read_frame(self.rfile)
413
- validator.accept(frame)
414
- if frame.stream_id != stream_id:
415
- raise ServiceError("invalid_request", "exec frame stream_id does not match")
416
- if frame.flags & FrameFlags.CANCEL:
417
- raise ServiceError("cancelled", "exec stdin cancelled")
418
- if frame.flags & FrameFlags.DATA:
419
- yield frame.payload
420
- if frame.flags & FrameFlags.EOF:
421
- return
422
-
423
- result = self.dispatcher.services.exec(
424
- _required_param(params, "session_id"),
425
- _required_param(params, "command"),
426
- stdin=chunks(),
427
- timeout=float(timeout),
428
- output_limit=output_limit,
429
- owner=_optional_owner(params),
430
- )
431
- return Response.success(
432
- request.request_id,
433
- {
434
- "session_id": result.session_id,
435
- "exit_code": result.exit_code,
436
- "stdout": _encoded_bytes(result.stdout),
437
- "stderr": _encoded_bytes(result.stderr),
438
- "timed_out": result.timed_out,
439
- },
440
- )
441
41
 
442
- def _handle_download(self, request: Request) -> None:
443
- params = request.params
444
- stream_id = _required_param(params, "stream_id")
445
- chunk_size = params.get("chunk_size", 256 * 1024)
446
- if not isinstance(chunk_size, int) or isinstance(chunk_size, bool) or not 4096 <= chunk_size <= 1024 * 1024:
447
- raise ServiceError("invalid_request", "chunk_size must be between 4096 and 1048576 bytes")
448
- response = Response.success(request.request_id, {"stream_id": stream_id})
449
- self.wfile.write(response.to_json().encode("utf-8") + b"\n")
450
- self.wfile.flush()
451
- digest = hashlib.sha256()
452
- transferred = 0
453
- sequence = 0
454
- try:
455
- chunks = self.dispatcher.services.download(
456
- _required_param(params, "session_id"),
457
- _required_param(params, "remote_path"),
458
- chunk_size=chunk_size,
459
- owner=_optional_owner(params),
460
- )
461
- for chunk in chunks:
462
- digest.update(chunk)
463
- transferred += len(chunk)
464
- self.wfile.write(encode_frame(BinaryFrame(stream_id, sequence, FrameFlags.DATA, chunk)))
465
- self.wfile.flush()
466
- sequence += 1
467
- metadata = json.dumps(
468
- {"bytes_transferred": transferred, "sha256": digest.hexdigest()},
469
- separators=(",", ":"),
470
- ).encode("utf-8")
471
- self.wfile.write(encode_frame(BinaryFrame(stream_id, sequence, FrameFlags.EOF, metadata)))
472
- self.wfile.flush()
473
- except ServiceError as exc:
474
- metadata = json.dumps({"code": exc.code, "message": str(exc)}, separators=(",", ":")).encode("utf-8")
475
- self.wfile.write(encode_frame(BinaryFrame(stream_id, sequence, FrameFlags.CANCEL, metadata)))
476
- self.wfile.flush()
477
-
478
-
479
- def make_v1_handler(dispatcher: HostBridgeV1Dispatcher) -> type[HostBridgeV1Handler]:
480
- class Handler(HostBridgeV1Handler):
481
- pass
482
-
483
- Handler.dispatcher = dispatcher
484
- return Handler
485
-
486
-
487
- def build_v1_services(
42
+ def build_runtime(
488
43
  config_path: Path | str | None = None,
489
44
  *,
490
45
  connect_timeout: int = DEFAULT_CONNECT_TIMEOUT,
491
- ) -> HostBridgeServices:
46
+ ) -> DaemonRuntime:
492
47
  resolved_config = configured_hosts_path(config_path)
493
48
  registry = HostRegistry(load_hosts(resolved_config))
494
49
  policy = load_policy()
495
-
496
- def transport_factory(host, session_connect_timeout):
497
- native_ssh = (
498
- host.ssh is not None
499
- and not host.login_steps
500
- and not host.ssh.extra_args
501
- and host.transport in {"auto", "ssh"}
502
- )
503
- if native_ssh:
504
- assert host.ssh is not None
505
- return SshTransport.connect(
506
- SshConnectionConfig(
507
- host=host.ssh.host,
508
- port=host.ssh.port,
509
- username=host.ssh.username,
510
- client_keys=([str(host.ssh.identity_file)] if host.ssh.identity_file else ()),
511
- tunnel=host.ssh.proxy_jump,
512
- password=host.secrets.get("password"),
513
- ),
514
- connect_timeout=session_connect_timeout,
515
- )
516
- return PtyTransport.connect(host, connect_timeout=session_connect_timeout)
517
-
518
- services = HostBridgeServices(
50
+ tunnel_manager = HostTunnelManager(registry, tunnel_provider_factory)
51
+ services = AsyncHostBridgeServices(
519
52
  registry,
520
- transport_factory=transport_factory,
53
+ tunnel_manager,
521
54
  default_connect_timeout=connect_timeout,
522
55
  policy=policy,
56
+ idle_timeout_seconds=policy.idle_timeout_seconds,
523
57
  audit_sink=build_audit_sink(policy),
524
58
  )
525
- if policy.enabled and policy.idle_timeout_seconds > 0:
526
- services.start_idle_reaper(timeout=policy.idle_timeout_seconds)
527
- return services
59
+ return DaemonRuntime(services, tunnel_manager)
528
60
 
529
61
 
530
62
  def daemon_dir(path: Path | str | None = None) -> Path:
@@ -566,43 +98,111 @@ def _socket_accepts_connections(path: Path) -> bool:
566
98
  return False
567
99
 
568
100
 
569
- def run_daemon(
101
+ async def run_daemon_async(
570
102
  *,
571
103
  socket_file: Path | str | None = None,
572
104
  config_path: Path | str | None = None,
573
105
  ) -> int:
574
- services = build_v1_services(config_path)
106
+ runtime = build_runtime(config_path)
107
+ services = runtime.services
108
+ tunnel_manager = runtime.tunnel_manager
575
109
  paths = runtime_paths(socket_file)
576
110
  path = paths.socket
577
- server: ThreadingUnixRPCServer | None = None
111
+ shutdown = asyncio.Event()
112
+ server: MuxDaemonServer | None = None
578
113
  owns_runtime_paths = False
114
+
115
+ async def request_closed(method: str) -> None:
116
+ if method == "system.shutdown":
117
+ shutdown.set()
118
+
579
119
  try:
580
120
  _prepare_socket_path(path)
581
- server = ThreadingUnixRPCServer(
582
- str(path),
583
- make_v1_handler(HostBridgeV1Dispatcher(services)),
584
- bind_and_activate=False,
121
+ service = HostBridgeMuxService(services, tunnel_manager=tunnel_manager)
122
+ server = MuxDaemonServer(
123
+ path,
124
+ service.handle_unary_async,
125
+ stream_handler=service.handle_stream,
126
+ request_closed_handler=request_closed,
585
127
  )
586
- server.server_bind()
128
+ await server.start()
587
129
  owns_runtime_paths = True
588
- server.server_activate()
589
- path.chmod(0o600)
590
130
  replace_private_file(paths.pid, str(os.getpid()).encode("ascii"))
591
131
  print(f"hostbridge daemon listening on unix://{path}", flush=True)
592
- server.serve_forever()
132
+ await shutdown.wait()
593
133
  finally:
594
- services.close_all(force=True)
595
- services.stop_idle_reaper()
596
- if server is not None:
597
- server.server_close()
598
- if owns_runtime_paths:
599
- with suppress(FileNotFoundError):
600
- path.unlink()
601
- with suppress(FileNotFoundError):
602
- paths.pid.unlink()
134
+ primary_error = sys.exception()
135
+ cleanup = asyncio.create_task(
136
+ _cleanup_daemon_resources(
137
+ server,
138
+ services,
139
+ tunnel_manager,
140
+ path=path,
141
+ pid_path=paths.pid,
142
+ owns_runtime_paths=owns_runtime_paths,
143
+ ),
144
+ name="hostbridge-daemon-cleanup",
145
+ )
146
+ cancellation: asyncio.CancelledError | None = None
147
+ while not cleanup.done():
148
+ try:
149
+ await asyncio.shield(cleanup)
150
+ except asyncio.CancelledError as exc:
151
+ cancellation = exc
152
+ cleanup_errors = cleanup.result()
153
+ if cancellation is not None:
154
+ for error in cleanup_errors:
155
+ cancellation.add_note(f"shutdown cleanup failed: {error!r}")
156
+ if primary_error is None:
157
+ raise cancellation
158
+ primary_error.add_note(f"shutdown was cancelled during cleanup: {cancellation!r}")
159
+ if cleanup_errors:
160
+ if primary_error is None:
161
+ raise cleanup_errors[0]
162
+ for error in cleanup_errors:
163
+ primary_error.add_note(f"shutdown cleanup failed: {error!r}")
603
164
  return 0
604
165
 
605
166
 
167
+ async def _cleanup_daemon_resources(
168
+ server: MuxDaemonServer | None,
169
+ services: AsyncHostBridgeServices,
170
+ tunnel_manager: HostTunnelManager,
171
+ *,
172
+ path: Path,
173
+ pid_path: Path,
174
+ owns_runtime_paths: bool,
175
+ ) -> list[BaseException]:
176
+ cleanup_errors: list[BaseException] = []
177
+ if server is not None:
178
+ try:
179
+ await server.close()
180
+ except BaseException as exc:
181
+ cleanup_errors.append(exc)
182
+ try:
183
+ await services.close_all(force=True)
184
+ except BaseException as exc:
185
+ cleanup_errors.append(exc)
186
+ try:
187
+ await tunnel_manager.close()
188
+ except BaseException as exc:
189
+ cleanup_errors.append(exc)
190
+ if owns_runtime_paths:
191
+ with suppress(FileNotFoundError):
192
+ path.unlink()
193
+ with suppress(FileNotFoundError):
194
+ pid_path.unlink()
195
+ return cleanup_errors
196
+
197
+
198
+ def run_daemon(
199
+ *,
200
+ socket_file: Path | str | None = None,
201
+ config_path: Path | str | None = None,
202
+ ) -> int:
203
+ return asyncio.run(run_daemon_async(socket_file=socket_file, config_path=config_path))
204
+
205
+
606
206
  def daemon_endpoint() -> str:
607
207
  return f"unix://{socket_path()}"
608
208
 
@@ -613,7 +213,8 @@ def is_running(*, socket_file: Path | str | None = None) -> bool:
613
213
 
614
214
  def _probe_daemon(path: Path) -> dict[str, object] | None:
615
215
  try:
616
- return HostBridgeClient(path, default_timeout=1.0).request("system.hello", {})
216
+ with HostBridgeClient(path, default_timeout=1.0) as client:
217
+ return client.request("system.hello", {})
617
218
  except HostBridgeError:
618
219
  return None
619
220
 
@@ -622,7 +223,7 @@ def _daemon_is_compatible(hello: dict[str, object] | None) -> bool:
622
223
  return bool(
623
224
  hello is not None
624
225
  and hello.get("service") == "hostbridge"
625
- and hello.get("protocol") == PROTOCOL_VERSION
226
+ and hello.get("protocol") == MUX_PROTOCOL_VERSION
626
227
  and hello.get("version") == __version__
627
228
  )
628
229
 
@@ -641,16 +242,23 @@ def start_background(
641
242
  print(f"hostbridge daemon already running at unix://{path}")
642
243
  return 0
643
244
  if hello is not None and hello.get("service") == "hostbridge":
245
+ daemon_pid = _read_daemon_pid(paths.pid)
246
+ owned_daemon = daemon_pid is not None and _process_matches_daemon(daemon_pid, path)
644
247
  try:
645
- HostBridgeClient(path, default_timeout=2.0).request("system.shutdown", {})
248
+ with HostBridgeClient(path, default_timeout=2.0) as client:
249
+ client.request("system.shutdown", {})
646
250
  except HostBridgeError:
647
251
  if not quiet:
648
252
  print(f"hostbridge incompatible daemon could not be stopped at unix://{path}", file=sys.stderr)
649
253
  return 1
650
- stop_deadline = time.time() + 5
651
- while _socket_accepts_connections(path) and time.time() < stop_deadline:
652
- time.sleep(0.05)
653
- if _socket_accepts_connections(path):
254
+ if owned_daemon and daemon_pid is not None:
255
+ stopped = _stop_daemon_process(daemon_pid, path)
256
+ else:
257
+ stop_deadline = time.monotonic() + _DAEMON_STOP_GRACE_SECONDS
258
+ while _socket_accepts_connections(path) and time.monotonic() < stop_deadline:
259
+ time.sleep(_PROCESS_POLL_INTERVAL_SECONDS)
260
+ stopped = not _socket_accepts_connections(path)
261
+ if not stopped:
654
262
  if not quiet:
655
263
  print(f"hostbridge incompatible daemon did not stop at unix://{path}", file=sys.stderr)
656
264
  return 1
@@ -660,7 +268,8 @@ def start_background(
660
268
  command.extend(["--config", str(config_path)])
661
269
  command.extend(["start", "--foreground", "--socket", str(path)])
662
270
  with open_private_append(paths.log) as log_handle:
663
- process = subprocess.Popen(
271
+ # The command is the current HostBridge module argv.
272
+ process = subprocess.Popen( # nosec B603
664
273
  command,
665
274
  stdin=subprocess.DEVNULL,
666
275
  stdout=log_handle,
@@ -693,6 +302,89 @@ def _read_daemon_pid(path: Path) -> int | None:
693
302
  return value if value > 0 else None
694
303
 
695
304
 
305
+ def _process_is_alive(pid: int) -> bool:
306
+ try:
307
+ os.kill(pid, 0)
308
+ except ProcessLookupError:
309
+ return False
310
+ except PermissionError:
311
+ return True
312
+ return True
313
+
314
+
315
+ def _process_matches_daemon(pid: int, path: Path) -> bool:
316
+ try:
317
+ process = subprocess.run( # nosec B603 B607
318
+ ["/bin/ps", "-p", str(pid), "-o", "uid=", "-o", "command="],
319
+ check=False,
320
+ capture_output=True,
321
+ text=True,
322
+ timeout=1,
323
+ )
324
+ except (OSError, subprocess.TimeoutExpired):
325
+ return False
326
+ if process.returncode != 0:
327
+ return False
328
+ fields = process.stdout.strip().split(maxsplit=1)
329
+ if len(fields) != 2:
330
+ return False
331
+ try:
332
+ process_uid = int(fields[0])
333
+ argv = shlex.split(fields[1])
334
+ except (ValueError, UnicodeError):
335
+ return False
336
+ if process_uid != os.getuid():
337
+ return False
338
+ try:
339
+ module_index = next(
340
+ index for index in range(len(argv) - 2) if argv[index : index + 3] == ["-m", "server_control_mcp", "daemon"]
341
+ )
342
+ socket_index = argv.index("--socket", module_index + 3)
343
+ except (StopIteration, ValueError):
344
+ return False
345
+ expected_socket = os.path.abspath(os.path.expanduser(str(path)))
346
+ actual_socket = os.path.abspath(os.path.expanduser(argv[socket_index + 1])) if socket_index + 1 < len(argv) else ""
347
+ return (
348
+ "start" in argv[module_index + 3 : socket_index]
349
+ and "--foreground" in argv[module_index + 3 : socket_index]
350
+ and actual_socket == expected_socket
351
+ )
352
+
353
+
354
+ def _wait_for_process_exit(pid: int, timeout: float) -> bool:
355
+ deadline = time.monotonic() + max(0.0, timeout)
356
+ while _process_is_alive(pid):
357
+ remaining = deadline - time.monotonic()
358
+ if remaining <= 0:
359
+ return False
360
+ time.sleep(min(_PROCESS_POLL_INTERVAL_SECONDS, remaining))
361
+ return True
362
+
363
+
364
+ def _stop_daemon_process(pid: int, path: Path) -> bool:
365
+ if _wait_for_process_exit(pid, _DAEMON_STOP_GRACE_SECONDS):
366
+ return True
367
+ if not _process_matches_daemon(pid, path):
368
+ return False
369
+ try:
370
+ os.kill(pid, signal.SIGTERM)
371
+ except ProcessLookupError:
372
+ return True
373
+ except OSError:
374
+ return False
375
+ if _wait_for_process_exit(pid, _DAEMON_TERM_SECONDS):
376
+ return True
377
+ if not _process_matches_daemon(pid, path):
378
+ return not _process_is_alive(pid)
379
+ try:
380
+ os.kill(pid, signal.SIGKILL)
381
+ except ProcessLookupError:
382
+ return True
383
+ except OSError:
384
+ return False
385
+ return _wait_for_process_exit(pid, _DAEMON_KILL_SECONDS)
386
+
387
+
696
388
  def _terminate_background_process(process: subprocess.Popen[bytes]) -> None:
697
389
  if process.poll() is not None:
698
390
  return
@@ -705,11 +397,26 @@ def _terminate_background_process(process: subprocess.Popen[bytes]) -> None:
705
397
 
706
398
 
707
399
  def stop_background(*, socket_file: Path | str | None = None) -> int:
708
- path = socket_path(socket_file)
400
+ paths = runtime_paths(socket_file)
401
+ path = paths.socket
402
+ daemon_pid = _read_daemon_pid(paths.pid)
403
+ owned_daemon = daemon_pid is not None and _process_matches_daemon(daemon_pid, path)
709
404
  hello = _probe_daemon(path)
710
405
  if hello is not None and hello.get("service") == "hostbridge":
711
- HostBridgeClient(path, default_timeout=2.0).request("system.shutdown", {})
406
+ with HostBridgeClient(path, default_timeout=2.0) as client:
407
+ client.request("system.shutdown", {})
408
+ elif not owned_daemon:
409
+ print("hostbridge daemon is not running")
410
+ return 0
411
+ if owned_daemon and daemon_pid is not None:
412
+ stopped = _stop_daemon_process(daemon_pid, path)
413
+ else:
414
+ stop_deadline = time.monotonic() + _DAEMON_STOP_GRACE_SECONDS
415
+ while _socket_accepts_connections(path) and time.monotonic() < stop_deadline:
416
+ time.sleep(_PROCESS_POLL_INTERVAL_SECONDS)
417
+ stopped = not _socket_accepts_connections(path)
418
+ if stopped:
712
419
  print("hostbridge daemon stopped")
713
420
  return 0
714
- print("hostbridge daemon is not running")
715
- return 0
421
+ print(f"hostbridge daemon did not stop at unix://{path}", file=sys.stderr)
422
+ return 1