@neoline/hostbridge 2.0.2 → 2.0.4

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.
@@ -6,6 +6,7 @@ import hashlib
6
6
  import json
7
7
  import os
8
8
  import socket
9
+ import stat
9
10
  import subprocess
10
11
  import sys
11
12
  import threading
@@ -14,10 +15,11 @@ from contextlib import suppress
14
15
  from dataclasses import asdict
15
16
  from pathlib import Path
16
17
  from socketserver import StreamRequestHandler, ThreadingMixIn, UnixStreamServer
18
+ from typing import Any
17
19
 
20
+ from . import __version__
18
21
  from .client import HostBridgeClient, HostBridgeError
19
- from .config import load_v1_config
20
- from .hosts import HostRegistry, load_hosts
22
+ from .hosts import HostRegistry, configured_hosts_path, load_hosts
21
23
  from .policy import build_audit_sink, load_policy
22
24
  from .protocol import (
23
25
  PROTOCOL_VERSION,
@@ -32,17 +34,65 @@ from .protocol import (
32
34
  read_frame,
33
35
  )
34
36
  from .runtime import runtime_paths
37
+ from .secure_files import open_private_append, replace_private_file
35
38
  from .services import HostBridgeServices, ServiceError
36
39
  from .transports.pty import PtyTransport
40
+ from .transports.ssh import SshConnectionConfig, SshTransport
37
41
 
38
42
  MAX_V1_CONTROL_LINE_BYTES = 4 * 1024 * 1024
39
43
  DEFAULT_CONNECT_TIMEOUT = 30
44
+ DEFAULT_MAX_DAEMON_CONNECTIONS = 64
45
+ DEFAULT_DAEMON_CONNECTION_IDLE_TIMEOUT = 30.0
40
46
 
41
47
 
42
48
  class ThreadingUnixRPCServer(ThreadingMixIn, UnixStreamServer):
43
49
  daemon_threads = True
44
50
  allow_reuse_address = True
45
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
+
46
96
 
47
97
  def _required_param(params: dict[str, object], name: str) -> str:
48
98
  value = params.get(name)
@@ -120,6 +170,7 @@ class HostBridgeV1Dispatcher:
120
170
  return {
121
171
  "service": "hostbridge",
122
172
  "protocol": PROTOCOL_VERSION,
173
+ "version": __version__,
123
174
  "capabilities": ["sessions", "exec", "explicit_stdin"],
124
175
  }
125
176
 
@@ -128,28 +179,38 @@ class HostBridgeV1Dispatcher:
128
179
 
129
180
  def system_shutdown(self, params: dict[str, object]) -> dict[str, object]:
130
181
  self.services.stop_idle_reaper()
131
- return self.services.close_all()
182
+ return self.services.close_all(force=True)
132
183
 
133
184
  def host_list(self, params: dict[str, object]) -> dict[str, object]:
134
185
  return {"hosts": self.services.hosts.describe()}
135
186
 
136
187
  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))
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
+ )
138
200
  return session.describe()
139
201
 
140
202
  def session_list(self, params: dict[str, object]) -> dict[str, object]:
141
203
  return {"sessions": self.services.list_sessions()}
142
204
 
143
205
  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")
206
+ if "force" in params:
207
+ raise ServiceError("invalid_request", "force close is not available over the public protocol")
147
208
  session_id = _required_param(params, "session_id")
148
- self.services.close_session(session_id, owner=_optional_owner(params), force=force)
209
+ self.services.close_session(session_id, owner=_optional_owner(params))
149
210
  return {"session_id": session_id, "closed": True}
150
211
 
151
212
  def session_close_all(self, params: dict[str, object]) -> dict[str, object]:
152
- return self.services.close_all()
213
+ return self.services.close_all(owner=_optional_owner(params))
153
214
 
154
215
  def exec_start(self, params: dict[str, object]) -> dict[str, object]:
155
216
  timeout = params.get("timeout", 60)
@@ -263,6 +324,12 @@ class HostBridgeV1Handler(StreamRequestHandler):
263
324
  dispatcher: HostBridgeV1Dispatcher
264
325
 
265
326
  def handle(self) -> None:
327
+ try:
328
+ self._handle_requests()
329
+ except OSError:
330
+ return
331
+
332
+ def _handle_requests(self) -> None:
266
333
  while raw_line := self.rfile.readline(MAX_V1_CONTROL_LINE_BYTES + 1):
267
334
  request_id = "invalid"
268
335
  try:
@@ -272,6 +339,8 @@ class HostBridgeV1Handler(StreamRequestHandler):
272
339
  request_id = request.request_id
273
340
  if request.method == "transfer.upload":
274
341
  response = self._handle_upload(request)
342
+ elif request.method == "exec.stream":
343
+ response = self._handle_exec_stream(request)
275
344
  elif request.method == "transfer.download":
276
345
  self._handle_download(request)
277
346
  continue
@@ -327,6 +396,49 @@ class HostBridgeV1Handler(StreamRequestHandler):
327
396
  {"bytes_transferred": result.bytes_transferred, "sha256": result.sha256},
328
397
  )
329
398
 
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
+
330
442
  def _handle_download(self, request: Request) -> None:
331
443
  params = request.params
332
444
  stream_id = _required_param(params, "stream_id")
@@ -377,17 +489,36 @@ def build_v1_services(
377
489
  *,
378
490
  connect_timeout: int = DEFAULT_CONNECT_TIMEOUT,
379
491
  ) -> HostBridgeServices:
380
- if config_path is not None:
381
- load_v1_config(config_path)
382
- registry = HostRegistry(load_hosts(config_path))
492
+ resolved_config = configured_hosts_path(config_path)
493
+ registry = HostRegistry(load_hosts(resolved_config))
383
494
  policy = load_policy()
384
495
 
385
- def transport_factory(host):
386
- return PtyTransport.connect(host, connect_timeout=connect_timeout)
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)
387
517
 
388
518
  services = HostBridgeServices(
389
519
  registry,
390
520
  transport_factory=transport_factory,
521
+ default_connect_timeout=connect_timeout,
391
522
  policy=policy,
392
523
  audit_sink=build_audit_sink(policy),
393
524
  )
@@ -404,21 +535,22 @@ def socket_path(path: Path | str | None = None) -> Path:
404
535
  return runtime_paths(path).socket
405
536
 
406
537
 
407
- def pid_file() -> Path:
408
- return runtime_paths().pid
538
+ def pid_file(socket_file: Path | str | None = None) -> Path:
539
+ return runtime_paths(socket_file).pid
409
540
 
410
541
 
411
- def log_file() -> Path:
412
- return runtime_paths().log
542
+ def log_file(socket_file: Path | str | None = None) -> Path:
543
+ return runtime_paths(socket_file).log
413
544
 
414
545
 
415
546
  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():
547
+ runtime_paths(path).ensure_directory()
548
+ if os.path.lexists(path):
549
+ metadata = path.lstat()
550
+ if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISSOCK(metadata.st_mode):
551
+ raise RuntimeError(f"refusing to remove non-socket runtime path: {path}")
552
+ if metadata.st_uid != os.getuid():
553
+ raise PermissionError(f"runtime socket is not owned by the current user: {path}")
422
554
  if _socket_accepts_connections(path):
423
555
  raise RuntimeError(f"hostbridge daemon already running at unix://{path}")
424
556
  path.unlink()
@@ -440,24 +572,34 @@ def run_daemon(
440
572
  config_path: Path | str | None = None,
441
573
  ) -> int:
442
574
  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")
575
+ paths = runtime_paths(socket_file)
576
+ path = paths.socket
577
+ server: ThreadingUnixRPCServer | None = None
578
+ owns_runtime_paths = False
450
579
  try:
580
+ _prepare_socket_path(path)
581
+ server = ThreadingUnixRPCServer(
582
+ str(path),
583
+ make_v1_handler(HostBridgeV1Dispatcher(services)),
584
+ bind_and_activate=False,
585
+ )
586
+ server.server_bind()
587
+ owns_runtime_paths = True
588
+ server.server_activate()
589
+ path.chmod(0o600)
590
+ replace_private_file(paths.pid, str(os.getpid()).encode("ascii"))
451
591
  print(f"hostbridge daemon listening on unix://{path}", flush=True)
452
592
  server.serve_forever()
453
593
  finally:
454
- services.close_all()
594
+ services.close_all(force=True)
455
595
  services.stop_idle_reaper()
456
- server.server_close()
457
- with suppress(FileNotFoundError):
458
- path.unlink()
459
- with suppress(FileNotFoundError):
460
- pid_file().unlink()
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()
461
603
  return 0
462
604
 
463
605
 
@@ -466,11 +608,23 @@ def daemon_endpoint() -> str:
466
608
 
467
609
 
468
610
  def is_running(*, socket_file: Path | str | None = None) -> bool:
611
+ return _daemon_is_compatible(_probe_daemon(socket_path(socket_file)))
612
+
613
+
614
+ def _probe_daemon(path: Path) -> dict[str, object] | None:
469
615
  try:
470
- hello = HostBridgeClient(socket_file, default_timeout=1.0).hello()
471
- return hello.get("service") == "hostbridge" and hello.get("protocol") == PROTOCOL_VERSION
616
+ return HostBridgeClient(path, default_timeout=1.0).request("system.hello", {})
472
617
  except HostBridgeError:
473
- return False
618
+ return None
619
+
620
+
621
+ def _daemon_is_compatible(hello: dict[str, object] | None) -> bool:
622
+ return bool(
623
+ hello is not None
624
+ and hello.get("service") == "hostbridge"
625
+ and hello.get("protocol") == PROTOCOL_VERSION
626
+ and hello.get("version") == __version__
627
+ )
474
628
 
475
629
 
476
630
  def start_background(
@@ -479,19 +633,33 @@ def start_background(
479
633
  config_path: Path | str | None = None,
480
634
  quiet: bool = False,
481
635
  ) -> int:
482
- path = socket_path(socket_file)
483
- if is_running(socket_file=path):
636
+ paths = runtime_paths(socket_file)
637
+ path = paths.socket
638
+ hello = _probe_daemon(path)
639
+ if _daemon_is_compatible(hello):
484
640
  if not quiet:
485
641
  print(f"hostbridge daemon already running at unix://{path}")
486
642
  return 0
487
- path.parent.mkdir(parents=True, exist_ok=True)
488
- with suppress(OSError):
489
- path.parent.chmod(0o700)
643
+ if hello is not None and hello.get("service") == "hostbridge":
644
+ try:
645
+ HostBridgeClient(path, default_timeout=2.0).request("system.shutdown", {})
646
+ except HostBridgeError:
647
+ if not quiet:
648
+ print(f"hostbridge incompatible daemon could not be stopped at unix://{path}", file=sys.stderr)
649
+ 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):
654
+ if not quiet:
655
+ print(f"hostbridge incompatible daemon did not stop at unix://{path}", file=sys.stderr)
656
+ return 1
657
+ paths.ensure_directory()
490
658
  command = [sys.executable, "-m", "server_control_mcp", "daemon"]
491
659
  if config_path is not None:
492
660
  command.extend(["--config", str(config_path)])
493
661
  command.extend(["start", "--foreground", "--socket", str(path)])
494
- with log_file().open("a", encoding="utf-8") as log_handle:
662
+ with open_private_append(paths.log) as log_handle:
495
663
  process = subprocess.Popen(
496
664
  command,
497
665
  stdin=subprocess.DEVNULL,
@@ -500,24 +668,46 @@ def start_background(
500
668
  start_new_session=True,
501
669
  env=os.environ.copy(),
502
670
  )
503
- pid_file().write_text(str(process.pid), encoding="utf-8")
504
671
  deadline = time.time() + 5
505
672
  while time.time() < deadline:
506
673
  if is_running(socket_file=path):
674
+ if _read_daemon_pid(paths.pid) != process.pid:
675
+ _terminate_background_process(process)
507
676
  if not quiet:
508
677
  print(f"hostbridge daemon started at unix://{path} (pid {process.pid})")
509
678
  return 0
510
679
  if process.poll() is not None:
511
680
  break
512
681
  time.sleep(0.1)
682
+ _terminate_background_process(process)
513
683
  if not quiet:
514
- print(f"hostbridge daemon failed to start; see {log_file()}", file=sys.stderr)
684
+ print(f"hostbridge daemon failed to start; see {paths.log}", file=sys.stderr)
515
685
  return 1
516
686
 
517
687
 
688
+ def _read_daemon_pid(path: Path) -> int | None:
689
+ try:
690
+ value = int(path.read_text(encoding="ascii").strip())
691
+ except (FileNotFoundError, OSError, UnicodeError, ValueError):
692
+ return None
693
+ return value if value > 0 else None
694
+
695
+
696
+ def _terminate_background_process(process: subprocess.Popen[bytes]) -> None:
697
+ if process.poll() is not None:
698
+ return
699
+ process.terminate()
700
+ try:
701
+ process.wait(timeout=2)
702
+ except subprocess.TimeoutExpired:
703
+ process.kill()
704
+ process.wait(timeout=2)
705
+
706
+
518
707
  def stop_background(*, socket_file: Path | str | None = None) -> int:
519
708
  path = socket_path(socket_file)
520
- if is_running(socket_file=path):
709
+ hello = _probe_daemon(path)
710
+ if hello is not None and hello.get("service") == "hostbridge":
521
711
  HostBridgeClient(path, default_timeout=2.0).request("system.shutdown", {})
522
712
  print("hostbridge daemon stopped")
523
713
  return 0
@@ -60,8 +60,10 @@ def _version_diagnostic() -> DiagnosticResult:
60
60
  "npm": npm.get("version"),
61
61
  "python": pyproject.get("project", {}).get("version"),
62
62
  }
63
- status = "pass" if len(set(versions.values())) == 1 and __version__ == "2.0.2" else "fail"
64
- return DiagnosticResult("version", status, "version sources agree" if status == "pass" else "version drift detected", versions)
63
+ status = "pass" if len(set(versions.values())) == 1 else "fail"
64
+ return DiagnosticResult(
65
+ "version", status, "version sources agree" if status == "pass" else "version drift detected", versions
66
+ )
65
67
 
66
68
 
67
69
  def _config_diagnostic(config_path: Path | str) -> DiagnosticResult:
@@ -69,7 +71,9 @@ def _config_diagnostic(config_path: Path | str) -> DiagnosticResult:
69
71
  config = load_v1_config(config_path)
70
72
  except (OSError, ValueError) as exc:
71
73
  return DiagnosticResult("config", "fail", str(exc), {"path": str(config_path)})
72
- return DiagnosticResult("config", "pass", "schema v1 configuration is valid", {"path": str(config.path), "hosts": len(config.hosts)})
74
+ return DiagnosticResult(
75
+ "config", "pass", "schema v1 configuration is valid", {"path": str(config.path), "hosts": len(config.hosts)}
76
+ )
73
77
 
74
78
 
75
79
  def _runtime_diagnostic() -> DiagnosticResult:
@@ -93,7 +97,7 @@ def _daemon_diagnostic(client: DoctorClient) -> DiagnosticResult:
93
97
  hello = client.hello()
94
98
  except Exception as exc:
95
99
  return DiagnosticResult("daemon", "fail", f"daemon unavailable: {exc}", {})
96
- valid = hello.get("service") == "hostbridge" and hello.get("protocol") == 1
100
+ valid = hello.get("service") == "hostbridge" and hello.get("protocol") == 1 and hello.get("version") == __version__
97
101
  return DiagnosticResult(
98
102
  "daemon",
99
103
  "pass" if valid else "fail",