@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
@@ -1,399 +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
- import subprocess
8
+ import stat
9
+ import subprocess # nosec B404
10
10
  import sys
11
- import threading
12
11
  import time
13
12
  from contextlib import suppress
14
- from dataclasses import asdict
13
+ from dataclasses import dataclass
15
14
  from pathlib import Path
16
- from socketserver import StreamRequestHandler, ThreadingMixIn, UnixStreamServer
17
15
 
16
+ from . import __version__
18
17
  from .client import HostBridgeClient, HostBridgeError
19
- from .config import load_v1_config
20
- from .hosts import HostRegistry, load_hosts
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
21
22
  from .policy import build_audit_sink, load_policy
22
- from .protocol import (
23
- PROTOCOL_VERSION,
24
- BinaryFrame,
25
- ErrorInfo,
26
- FrameFlags,
27
- FrameSequenceValidator,
28
- ProtocolError,
29
- Request,
30
- Response,
31
- encode_frame,
32
- read_frame,
33
- )
34
23
  from .runtime import runtime_paths
35
- from .services import HostBridgeServices, ServiceError
36
- from .transports.pty import PtyTransport
24
+ from .runtime_services import AsyncHostBridgeServices
25
+ from .secure_files import open_private_append, replace_private_file
26
+ from .tunnel_manager import HostTunnelManager
27
+ from .tunnel_providers import tunnel_provider_factory
37
28
 
38
- MAX_V1_CONTROL_LINE_BYTES = 4 * 1024 * 1024
39
29
  DEFAULT_CONNECT_TIMEOUT = 30
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
40
34
 
41
35
 
42
- class ThreadingUnixRPCServer(ThreadingMixIn, UnixStreamServer):
43
- daemon_threads = True
44
- allow_reuse_address = True
36
+ @dataclass(frozen=True, slots=True)
37
+ class DaemonRuntime:
38
+ services: AsyncHostBridgeServices
39
+ tunnel_manager: HostTunnelManager
45
40
 
46
41
 
47
- def _required_param(params: dict[str, object], name: str) -> str:
48
- value = params.get(name)
49
- if not isinstance(value, str) or not value.strip():
50
- raise ServiceError("invalid_request", f"{name} must be a non-empty string")
51
- return value
52
-
53
-
54
- def _optional_owner(params: dict[str, object]) -> str | None:
55
- owner = params.get("owner")
56
- if owner is not None and not isinstance(owner, str):
57
- raise ServiceError("invalid_request", "owner must be a string or null")
58
- return owner
59
-
60
-
61
- def _decode_inline_stdin(value: object) -> list[bytes] | None:
62
- if value is None:
63
- return None
64
- if not isinstance(value, dict) or value.get("encoding") != "base64" or not isinstance(value.get("data"), str):
65
- raise ServiceError("invalid_request", "stdin must contain base64 data")
66
- try:
67
- return [base64.b64decode(value["data"], validate=True)]
68
- except (binascii.Error, ValueError) as exc:
69
- raise ServiceError("invalid_request", "stdin contains invalid base64") from exc
70
-
71
-
72
- def _encoded_bytes(value: bytes) -> dict[str, str]:
73
- return {"encoding": "base64", "data": base64.b64encode(value).decode("ascii")}
74
-
75
-
76
- class HostBridgeV1Dispatcher:
77
- def __init__(self, services: HostBridgeServices) -> None:
78
- self.services = services
79
- self.handlers = {
80
- "system.hello": self.system_hello,
81
- "system.health": self.system_health,
82
- "system.shutdown": self.system_shutdown,
83
- "host.list": self.host_list,
84
- "session.open": self.session_open,
85
- "session.list": self.session_list,
86
- "session.close": self.session_close,
87
- "session.close_all": self.session_close_all,
88
- "exec.start": self.exec_start,
89
- "task.start": self.task_start,
90
- "task.status": self.task_status,
91
- "task.cancel": self.task_cancel,
92
- "file.write_text": self.file_write_text,
93
- "file.read_text": self.file_read_text,
94
- "shell.write": self.shell_write,
95
- "shell.read": self.shell_read,
96
- }
97
-
98
- def dispatch(self, request: Request) -> Response:
99
- handler = self.handlers.get(request.method)
100
- if handler is None:
101
- return Response.failure(
102
- request.request_id,
103
- ErrorInfo("not_found", f"unknown method {request.method}"),
104
- )
105
- try:
106
- return Response.success(request.request_id, handler(request.params))
107
- except ServiceError as exc:
108
- return Response.failure(
109
- request.request_id,
110
- ErrorInfo(exc.code, str(exc), retryable=exc.retryable, details=exc.details),
111
- )
112
- except (TypeError, ValueError) as exc:
113
- return Response.failure(request.request_id, ErrorInfo("invalid_request", str(exc)))
114
- except Exception:
115
- return Response.failure(
116
- request.request_id, ErrorInfo("internal", "internal HostBridge error", retryable=True)
117
- )
118
-
119
- def system_hello(self, params: dict[str, object]) -> dict[str, object]:
120
- return {
121
- "service": "hostbridge",
122
- "protocol": PROTOCOL_VERSION,
123
- "capabilities": ["sessions", "exec", "explicit_stdin"],
124
- }
125
-
126
- def system_health(self, params: dict[str, object]) -> dict[str, object]:
127
- return {"status": "ok", "protocol": PROTOCOL_VERSION}
128
-
129
- def system_shutdown(self, params: dict[str, object]) -> dict[str, object]:
130
- self.services.stop_idle_reaper()
131
- return self.services.close_all()
132
-
133
- def host_list(self, params: dict[str, object]) -> dict[str, object]:
134
- return {"hosts": self.services.hosts.describe()}
135
-
136
- def session_open(self, params: dict[str, object]) -> dict[str, object]:
137
- session = self.services.open_session(_required_param(params, "host_id"), owner=_optional_owner(params))
138
- return session.describe()
139
-
140
- def session_list(self, params: dict[str, object]) -> dict[str, object]:
141
- return {"sessions": self.services.list_sessions()}
142
-
143
- def session_close(self, params: dict[str, object]) -> dict[str, object]:
144
- force = params.get("force", False)
145
- if not isinstance(force, bool):
146
- raise ServiceError("invalid_request", "force must be a boolean")
147
- session_id = _required_param(params, "session_id")
148
- self.services.close_session(session_id, owner=_optional_owner(params), force=force)
149
- return {"session_id": session_id, "closed": True}
150
-
151
- def session_close_all(self, params: dict[str, object]) -> dict[str, object]:
152
- return self.services.close_all()
153
-
154
- def exec_start(self, params: dict[str, object]) -> dict[str, object]:
155
- timeout = params.get("timeout", 60)
156
- output_limit = params.get("output_limit", 1_000_000)
157
- if not isinstance(timeout, int | float) or isinstance(timeout, bool) or timeout <= 0:
158
- raise ServiceError("invalid_request", "timeout must be positive")
159
- if not isinstance(output_limit, int) or isinstance(output_limit, bool) or output_limit <= 0:
160
- raise ServiceError("invalid_request", "output_limit must be a positive integer")
161
- result = self.services.exec(
162
- _required_param(params, "session_id"),
163
- _required_param(params, "command"),
164
- stdin=_decode_inline_stdin(params.get("stdin")),
165
- timeout=float(timeout),
166
- output_limit=output_limit,
167
- owner=_optional_owner(params),
168
- )
169
- return {
170
- "session_id": result.session_id,
171
- "exit_code": result.exit_code,
172
- "stdout": _encoded_bytes(result.stdout),
173
- "stderr": _encoded_bytes(result.stderr),
174
- "timed_out": result.timed_out,
175
- }
176
-
177
- def task_start(self, params: dict[str, object]) -> dict[str, object]:
178
- task = self.services.start_task(
179
- _required_param(params, "session_id"),
180
- _required_param(params, "command"),
181
- owner=_optional_owner(params),
182
- )
183
- return asdict(task)
184
-
185
- def task_status(self, params: dict[str, object]) -> dict[str, object]:
186
- tail_lines = params.get("tail_lines", 80)
187
- if not isinstance(tail_lines, int) or isinstance(tail_lines, bool):
188
- raise ServiceError("invalid_request", "tail_lines must be an integer")
189
- return self.services.task_status(
190
- _required_param(params, "session_id"),
191
- _required_param(params, "task_id"),
192
- owner=_optional_owner(params),
193
- tail_lines=tail_lines,
194
- )
195
-
196
- def task_cancel(self, params: dict[str, object]) -> dict[str, object]:
197
- return self.services.cancel_task(
198
- _required_param(params, "session_id"),
199
- _required_param(params, "task_id"),
200
- owner=_optional_owner(params),
201
- )
202
-
203
- def file_write_text(self, params: dict[str, object]) -> dict[str, object]:
204
- content = params.get("content")
205
- mode = params.get("mode", 0o644)
206
- max_bytes = params.get("max_bytes", 4 * 1024 * 1024)
207
- if not isinstance(content, str):
208
- raise ServiceError("invalid_request", "content must be a string")
209
- if not isinstance(mode, int) or isinstance(mode, bool):
210
- raise ServiceError("invalid_request", "mode must be an integer")
211
- if not isinstance(max_bytes, int) or isinstance(max_bytes, bool) or max_bytes <= 0:
212
- raise ServiceError("invalid_request", "max_bytes must be a positive integer")
213
- result = self.services.write_text(
214
- _required_param(params, "session_id"),
215
- _required_param(params, "remote_path"),
216
- content,
217
- mode=mode,
218
- max_bytes=max_bytes,
219
- owner=_optional_owner(params),
220
- )
221
- return {"bytes_transferred": result.bytes_transferred, "sha256": result.sha256}
222
-
223
- def file_read_text(self, params: dict[str, object]) -> dict[str, object]:
224
- max_bytes = params.get("max_bytes", 4 * 1024 * 1024)
225
- if not isinstance(max_bytes, int) or isinstance(max_bytes, bool) or max_bytes <= 0:
226
- raise ServiceError("invalid_request", "max_bytes must be a positive integer")
227
- content = self.services.read_text(
228
- _required_param(params, "session_id"),
229
- _required_param(params, "remote_path"),
230
- max_bytes=max_bytes,
231
- owner=_optional_owner(params),
232
- )
233
- return {"content": content, "bytes_transferred": len(content.encode("utf-8"))}
234
-
235
- def shell_write(self, params: dict[str, object]) -> dict[str, object]:
236
- chunks = _decode_inline_stdin(params.get("data"))
237
- if chunks is None:
238
- raise ServiceError("invalid_request", "data must contain base64 bytes")
239
- written = self.services.shell_write(
240
- _required_param(params, "session_id"),
241
- b"".join(chunks),
242
- owner=_optional_owner(params),
243
- )
244
- return {"written": written}
245
-
246
- def shell_read(self, params: dict[str, object]) -> dict[str, object]:
247
- timeout = params.get("timeout", 0.2)
248
- max_bytes = params.get("max_bytes", 4096)
249
- if not isinstance(timeout, int | float) or isinstance(timeout, bool) or timeout < 0:
250
- raise ServiceError("invalid_request", "timeout must be non-negative")
251
- if not isinstance(max_bytes, int) or isinstance(max_bytes, bool) or max_bytes <= 0:
252
- raise ServiceError("invalid_request", "max_bytes must be a positive integer")
253
- result = self.services.shell_read(
254
- _required_param(params, "session_id"),
255
- timeout=float(timeout),
256
- max_bytes=max_bytes,
257
- owner=_optional_owner(params),
258
- )
259
- return {"data": _encoded_bytes(result.data), "alive": result.alive}
260
-
261
-
262
- class HostBridgeV1Handler(StreamRequestHandler):
263
- dispatcher: HostBridgeV1Dispatcher
264
-
265
- def handle(self) -> None:
266
- while raw_line := self.rfile.readline(MAX_V1_CONTROL_LINE_BYTES + 1):
267
- request_id = "invalid"
268
- try:
269
- if len(raw_line) > MAX_V1_CONTROL_LINE_BYTES:
270
- raise ProtocolError("request exceeds the control limit")
271
- request = Request.from_json(raw_line.decode("utf-8"))
272
- request_id = request.request_id
273
- if request.method == "transfer.upload":
274
- response = self._handle_upload(request)
275
- elif request.method == "transfer.download":
276
- self._handle_download(request)
277
- continue
278
- else:
279
- response = self.dispatcher.dispatch(request)
280
- except ServiceError as exc:
281
- response = Response.failure(
282
- request_id,
283
- ErrorInfo(exc.code, str(exc), retryable=exc.retryable, details=exc.details),
284
- )
285
- except (ProtocolError, UnicodeDecodeError) as exc:
286
- response = Response.failure(request_id, ErrorInfo("invalid_request", str(exc)))
287
- self.wfile.write(response.to_json().encode("utf-8") + b"\n")
288
- self.wfile.flush()
289
- if request_id != "invalid" and request.method == "system.shutdown":
290
- threading.Thread(target=self.server.shutdown, daemon=True).start()
291
-
292
- def _handle_upload(self, request: Request) -> Response:
293
- params = request.params
294
- stream_id = _required_param(params, "stream_id")
295
- expected_size = params.get("expected_size")
296
- mode = params.get("mode", 0o644)
297
- if not isinstance(expected_size, int) or isinstance(expected_size, bool) or expected_size < 0:
298
- raise ServiceError("invalid_request", "expected_size must be a non-negative integer")
299
- if not isinstance(mode, int) or isinstance(mode, bool):
300
- raise ServiceError("invalid_request", "mode must be an integer")
301
- validator = FrameSequenceValidator()
302
-
303
- def chunks():
304
- while True:
305
- frame = read_frame(self.rfile)
306
- validator.accept(frame)
307
- if frame.stream_id != stream_id:
308
- raise ServiceError("invalid_request", "upload frame stream_id does not match")
309
- if frame.flags & FrameFlags.CANCEL:
310
- raise ServiceError("cancelled", "upload cancelled")
311
- if frame.flags & FrameFlags.DATA:
312
- yield frame.payload
313
- if frame.flags & FrameFlags.EOF:
314
- return
315
-
316
- result = self.dispatcher.services.upload(
317
- _required_param(params, "session_id"),
318
- chunks(),
319
- _required_param(params, "remote_path"),
320
- mode=mode,
321
- expected_size=expected_size,
322
- expected_sha256=None,
323
- owner=_optional_owner(params),
324
- )
325
- return Response.success(
326
- request.request_id,
327
- {"bytes_transferred": result.bytes_transferred, "sha256": result.sha256},
328
- )
329
-
330
- def _handle_download(self, request: Request) -> None:
331
- params = request.params
332
- stream_id = _required_param(params, "stream_id")
333
- chunk_size = params.get("chunk_size", 256 * 1024)
334
- if not isinstance(chunk_size, int) or isinstance(chunk_size, bool) or not 4096 <= chunk_size <= 1024 * 1024:
335
- raise ServiceError("invalid_request", "chunk_size must be between 4096 and 1048576 bytes")
336
- response = Response.success(request.request_id, {"stream_id": stream_id})
337
- self.wfile.write(response.to_json().encode("utf-8") + b"\n")
338
- self.wfile.flush()
339
- digest = hashlib.sha256()
340
- transferred = 0
341
- sequence = 0
342
- try:
343
- chunks = self.dispatcher.services.download(
344
- _required_param(params, "session_id"),
345
- _required_param(params, "remote_path"),
346
- chunk_size=chunk_size,
347
- owner=_optional_owner(params),
348
- )
349
- for chunk in chunks:
350
- digest.update(chunk)
351
- transferred += len(chunk)
352
- self.wfile.write(encode_frame(BinaryFrame(stream_id, sequence, FrameFlags.DATA, chunk)))
353
- self.wfile.flush()
354
- sequence += 1
355
- metadata = json.dumps(
356
- {"bytes_transferred": transferred, "sha256": digest.hexdigest()},
357
- separators=(",", ":"),
358
- ).encode("utf-8")
359
- self.wfile.write(encode_frame(BinaryFrame(stream_id, sequence, FrameFlags.EOF, metadata)))
360
- self.wfile.flush()
361
- except ServiceError as exc:
362
- metadata = json.dumps({"code": exc.code, "message": str(exc)}, separators=(",", ":")).encode("utf-8")
363
- self.wfile.write(encode_frame(BinaryFrame(stream_id, sequence, FrameFlags.CANCEL, metadata)))
364
- self.wfile.flush()
365
-
366
-
367
- def make_v1_handler(dispatcher: HostBridgeV1Dispatcher) -> type[HostBridgeV1Handler]:
368
- class Handler(HostBridgeV1Handler):
369
- pass
370
-
371
- Handler.dispatcher = dispatcher
372
- return Handler
373
-
374
-
375
- def build_v1_services(
42
+ def build_runtime(
376
43
  config_path: Path | str | None = None,
377
44
  *,
378
45
  connect_timeout: int = DEFAULT_CONNECT_TIMEOUT,
379
- ) -> HostBridgeServices:
380
- if config_path is not None:
381
- load_v1_config(config_path)
382
- registry = HostRegistry(load_hosts(config_path))
46
+ ) -> DaemonRuntime:
47
+ resolved_config = configured_hosts_path(config_path)
48
+ registry = HostRegistry(load_hosts(resolved_config))
383
49
  policy = load_policy()
384
-
385
- def transport_factory(host):
386
- return PtyTransport.connect(host, connect_timeout=connect_timeout)
387
-
388
- services = HostBridgeServices(
50
+ tunnel_manager = HostTunnelManager(registry, tunnel_provider_factory)
51
+ services = AsyncHostBridgeServices(
389
52
  registry,
390
- transport_factory=transport_factory,
53
+ tunnel_manager,
54
+ default_connect_timeout=connect_timeout,
391
55
  policy=policy,
56
+ idle_timeout_seconds=policy.idle_timeout_seconds,
392
57
  audit_sink=build_audit_sink(policy),
393
58
  )
394
- if policy.enabled and policy.idle_timeout_seconds > 0:
395
- services.start_idle_reaper(timeout=policy.idle_timeout_seconds)
396
- return services
59
+ return DaemonRuntime(services, tunnel_manager)
397
60
 
398
61
 
399
62
  def daemon_dir(path: Path | str | None = None) -> Path:
@@ -404,21 +67,22 @@ def socket_path(path: Path | str | None = None) -> Path:
404
67
  return runtime_paths(path).socket
405
68
 
406
69
 
407
- def pid_file() -> Path:
408
- return runtime_paths().pid
70
+ def pid_file(socket_file: Path | str | None = None) -> Path:
71
+ return runtime_paths(socket_file).pid
409
72
 
410
73
 
411
- def log_file() -> Path:
412
- return runtime_paths().log
74
+ def log_file(socket_file: Path | str | None = None) -> Path:
75
+ return runtime_paths(socket_file).log
413
76
 
414
77
 
415
78
  def _prepare_socket_path(path: Path) -> None:
416
- path.parent.mkdir(parents=True, exist_ok=True)
417
- log_file().parent.mkdir(parents=True, exist_ok=True)
418
- pid_file().parent.mkdir(parents=True, exist_ok=True)
419
- with suppress(OSError):
420
- path.parent.chmod(0o700)
421
- if path.exists():
79
+ runtime_paths(path).ensure_directory()
80
+ if os.path.lexists(path):
81
+ metadata = path.lstat()
82
+ if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISSOCK(metadata.st_mode):
83
+ raise RuntimeError(f"refusing to remove non-socket runtime path: {path}")
84
+ if metadata.st_uid != os.getuid():
85
+ raise PermissionError(f"runtime socket is not owned by the current user: {path}")
422
86
  if _socket_accepts_connections(path):
423
87
  raise RuntimeError(f"hostbridge daemon already running at unix://{path}")
424
88
  path.unlink()
@@ -434,31 +98,109 @@ def _socket_accepts_connections(path: Path) -> bool:
434
98
  return False
435
99
 
436
100
 
437
- def run_daemon(
101
+ async def run_daemon_async(
438
102
  *,
439
103
  socket_file: Path | str | None = None,
440
104
  config_path: Path | str | None = None,
441
105
  ) -> int:
442
- services = build_v1_services(config_path)
443
- path = socket_path(socket_file)
444
- _prepare_socket_path(path)
445
- server = ThreadingUnixRPCServer(
446
- str(path),
447
- make_v1_handler(HostBridgeV1Dispatcher(services)),
448
- )
449
- pid_file().write_text(str(os.getpid()), encoding="utf-8")
106
+ runtime = build_runtime(config_path)
107
+ services = runtime.services
108
+ tunnel_manager = runtime.tunnel_manager
109
+ paths = runtime_paths(socket_file)
110
+ path = paths.socket
111
+ shutdown = asyncio.Event()
112
+ server: MuxDaemonServer | None = None
113
+ owns_runtime_paths = False
114
+
115
+ async def request_closed(method: str) -> None:
116
+ if method == "system.shutdown":
117
+ shutdown.set()
118
+
450
119
  try:
120
+ _prepare_socket_path(path)
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,
127
+ )
128
+ await server.start()
129
+ owns_runtime_paths = True
130
+ replace_private_file(paths.pid, str(os.getpid()).encode("ascii"))
451
131
  print(f"hostbridge daemon listening on unix://{path}", flush=True)
452
- server.serve_forever()
132
+ await shutdown.wait()
453
133
  finally:
454
- services.close_all()
455
- services.stop_idle_reaper()
456
- server.server_close()
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}")
164
+ return 0
165
+
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:
457
191
  with suppress(FileNotFoundError):
458
192
  path.unlink()
459
193
  with suppress(FileNotFoundError):
460
- pid_file().unlink()
461
- return 0
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))
462
204
 
463
205
 
464
206
  def daemon_endpoint() -> str:
@@ -466,11 +208,24 @@ def daemon_endpoint() -> str:
466
208
 
467
209
 
468
210
  def is_running(*, socket_file: Path | str | None = None) -> bool:
211
+ return _daemon_is_compatible(_probe_daemon(socket_path(socket_file)))
212
+
213
+
214
+ def _probe_daemon(path: Path) -> dict[str, object] | None:
469
215
  try:
470
- hello = HostBridgeClient(socket_file, default_timeout=1.0).hello()
471
- return hello.get("service") == "hostbridge" and hello.get("protocol") == PROTOCOL_VERSION
216
+ with HostBridgeClient(path, default_timeout=1.0) as client:
217
+ return client.request("system.hello", {})
472
218
  except HostBridgeError:
473
- return False
219
+ return None
220
+
221
+
222
+ def _daemon_is_compatible(hello: dict[str, object] | None) -> bool:
223
+ return bool(
224
+ hello is not None
225
+ and hello.get("service") == "hostbridge"
226
+ and hello.get("protocol") == MUX_PROTOCOL_VERSION
227
+ and hello.get("version") == __version__
228
+ )
474
229
 
475
230
 
476
231
  def start_background(
@@ -479,20 +234,42 @@ def start_background(
479
234
  config_path: Path | str | None = None,
480
235
  quiet: bool = False,
481
236
  ) -> int:
482
- path = socket_path(socket_file)
483
- if is_running(socket_file=path):
237
+ paths = runtime_paths(socket_file)
238
+ path = paths.socket
239
+ hello = _probe_daemon(path)
240
+ if _daemon_is_compatible(hello):
484
241
  if not quiet:
485
242
  print(f"hostbridge daemon already running at unix://{path}")
486
243
  return 0
487
- path.parent.mkdir(parents=True, exist_ok=True)
488
- with suppress(OSError):
489
- path.parent.chmod(0o700)
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)
247
+ try:
248
+ with HostBridgeClient(path, default_timeout=2.0) as client:
249
+ client.request("system.shutdown", {})
250
+ except HostBridgeError:
251
+ if not quiet:
252
+ print(f"hostbridge incompatible daemon could not be stopped at unix://{path}", file=sys.stderr)
253
+ return 1
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:
262
+ if not quiet:
263
+ print(f"hostbridge incompatible daemon did not stop at unix://{path}", file=sys.stderr)
264
+ return 1
265
+ paths.ensure_directory()
490
266
  command = [sys.executable, "-m", "server_control_mcp", "daemon"]
491
267
  if config_path is not None:
492
268
  command.extend(["--config", str(config_path)])
493
269
  command.extend(["start", "--foreground", "--socket", str(path)])
494
- with log_file().open("a", encoding="utf-8") as log_handle:
495
- process = subprocess.Popen(
270
+ with open_private_append(paths.log) as log_handle:
271
+ # The command is the current HostBridge module argv.
272
+ process = subprocess.Popen( # nosec B603
496
273
  command,
497
274
  stdin=subprocess.DEVNULL,
498
275
  stdout=log_handle,
@@ -500,26 +277,146 @@ def start_background(
500
277
  start_new_session=True,
501
278
  env=os.environ.copy(),
502
279
  )
503
- pid_file().write_text(str(process.pid), encoding="utf-8")
504
280
  deadline = time.time() + 5
505
281
  while time.time() < deadline:
506
282
  if is_running(socket_file=path):
283
+ if _read_daemon_pid(paths.pid) != process.pid:
284
+ _terminate_background_process(process)
507
285
  if not quiet:
508
286
  print(f"hostbridge daemon started at unix://{path} (pid {process.pid})")
509
287
  return 0
510
288
  if process.poll() is not None:
511
289
  break
512
290
  time.sleep(0.1)
291
+ _terminate_background_process(process)
513
292
  if not quiet:
514
- print(f"hostbridge daemon failed to start; see {log_file()}", file=sys.stderr)
293
+ print(f"hostbridge daemon failed to start; see {paths.log}", file=sys.stderr)
515
294
  return 1
516
295
 
517
296
 
297
+ def _read_daemon_pid(path: Path) -> int | None:
298
+ try:
299
+ value = int(path.read_text(encoding="ascii").strip())
300
+ except (FileNotFoundError, OSError, UnicodeError, ValueError):
301
+ return None
302
+ return value if value > 0 else None
303
+
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
+
388
+ def _terminate_background_process(process: subprocess.Popen[bytes]) -> None:
389
+ if process.poll() is not None:
390
+ return
391
+ process.terminate()
392
+ try:
393
+ process.wait(timeout=2)
394
+ except subprocess.TimeoutExpired:
395
+ process.kill()
396
+ process.wait(timeout=2)
397
+
398
+
518
399
  def stop_background(*, socket_file: Path | str | None = None) -> int:
519
- path = socket_path(socket_file)
520
- if is_running(socket_file=path):
521
- HostBridgeClient(path, default_timeout=2.0).request("system.shutdown", {})
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)
404
+ hello = _probe_daemon(path)
405
+ if hello is not None and hello.get("service") == "hostbridge":
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:
522
419
  print("hostbridge daemon stopped")
523
420
  return 0
524
- print("hostbridge daemon is not running")
525
- return 0
421
+ print(f"hostbridge daemon did not stop at unix://{path}", file=sys.stderr)
422
+ return 1