@neoline/hostbridge 2.0.3 → 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.
- package/README.md +15 -8
- package/bin/hostbridge.js +80 -9
- package/examples/claude_desktop_config.json +1 -1
- package/examples/codex.config.toml +1 -1
- package/examples/hosts.example.json +1 -0
- package/package.json +1 -1
- package/pyproject.toml +5 -4
- package/src/server_control_mcp/__init__.py +58 -23
- package/src/server_control_mcp/cli.py +1 -1
- package/src/server_control_mcp/client.py +99 -7
- package/src/server_control_mcp/config.py +16 -5
- package/src/server_control_mcp/daemon.py +239 -49
- package/src/server_control_mcp/doctor.py +8 -4
- package/src/server_control_mcp/hosts.py +118 -24
- package/src/server_control_mcp/mock_ssh.py +53 -954
- package/src/server_control_mcp/mock_ssh_exec.py +210 -0
- package/src/server_control_mcp/mock_ssh_session.py +68 -0
- package/src/server_control_mcp/mock_ssh_sftp.py +506 -0
- package/src/server_control_mcp/mock_ssh_tunnel.py +592 -0
- package/src/server_control_mcp/policy.py +86 -14
- package/src/server_control_mcp/protocol.py +18 -17
- package/src/server_control_mcp/remote_task_agent.py +102 -0
- package/src/server_control_mcp/remote_tunnel_agent.py +143 -0
- package/src/server_control_mcp/runtime.py +3 -2
- package/src/server_control_mcp/secrets.py +6 -6
- package/src/server_control_mcp/secure_files.py +121 -0
- package/src/server_control_mcp/server.py +30 -12
- package/src/server_control_mcp/services.py +363 -165
- package/src/server_control_mcp/transports/pty.py +114 -33
- package/src/server_control_mcp/transports/ssh.py +308 -24
|
@@ -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 .
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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)
|
|
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
|
-
|
|
381
|
-
|
|
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
|
-
|
|
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.
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
server =
|
|
446
|
-
|
|
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
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
483
|
-
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
64
|
-
return DiagnosticResult(
|
|
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(
|
|
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",
|