@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.
- package/README.md +24 -15
- package/bin/hostbridge.js +80 -9
- package/docs/ARCHITECTURE.md +62 -0
- 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 +2 -1
- package/pyproject.toml +5 -4
- package/src/server_control_mcp/__init__.py +60 -24
- package/src/server_control_mcp/async_lifecycle.py +41 -0
- package/src/server_control_mcp/cli.py +2 -2
- package/src/server_control_mcp/client.py +345 -238
- package/src/server_control_mcp/config.py +18 -6
- package/src/server_control_mcp/daemon.py +309 -412
- package/src/server_control_mcp/doctor.py +41 -5
- package/src/server_control_mcp/hosts.py +252 -24
- package/src/server_control_mcp/mock_ssh.py +97 -960
- package/src/server_control_mcp/mock_ssh_exec.py +299 -0
- package/src/server_control_mcp/mock_ssh_session.py +69 -0
- package/src/server_control_mcp/mock_ssh_sftp.py +604 -0
- package/src/server_control_mcp/mock_ssh_tunnel.py +289 -0
- package/src/server_control_mcp/mux_connection.py +326 -0
- package/src/server_control_mcp/mux_daemon.py +186 -0
- package/src/server_control_mcp/mux_protocol.py +279 -0
- package/src/server_control_mcp/mux_records.py +71 -0
- package/src/server_control_mcp/mux_rpc.py +1779 -0
- package/src/server_control_mcp/mux_service.py +506 -0
- package/src/server_control_mcp/mux_stream.py +283 -0
- package/src/server_control_mcp/mux_sync_client.py +224 -0
- package/src/server_control_mcp/policy.py +86 -14
- package/src/server_control_mcp/remote_agent_bundle.py +56 -0
- package/src/server_control_mcp/remote_mux_agent.py +769 -0
- package/src/server_control_mcp/remote_task_agent.py +102 -0
- package/src/server_control_mcp/runtime.py +3 -2
- package/src/server_control_mcp/runtime_services.py +862 -0
- 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 +53 -20
- package/src/server_control_mcp/services.py +3 -469
- package/src/server_control_mcp/transports/__init__.py +4 -8
- package/src/server_control_mcp/transports/base.py +60 -38
- package/src/server_control_mcp/transports/ssh.py +315 -84
- package/src/server_control_mcp/tunnel_manager.py +1245 -0
- package/src/server_control_mcp/tunnel_native_ssh.py +454 -0
- package/src/server_control_mcp/tunnel_providers.py +20 -0
- package/src/server_control_mcp/tunnel_pty_agent.py +909 -0
- package/src/server_control_mcp/protocol.py +0 -362
- package/src/server_control_mcp/transports/pty.py +0 -434
|
@@ -1,399 +1,62 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import
|
|
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
|
|
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
|
|
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 .
|
|
20
|
-
from .
|
|
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 .
|
|
36
|
-
from .
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class DaemonRuntime:
|
|
38
|
+
services: AsyncHostBridgeServices
|
|
39
|
+
tunnel_manager: HostTunnelManager
|
|
45
40
|
|
|
46
41
|
|
|
47
|
-
def
|
|
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
|
-
) ->
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
132
|
+
await shutdown.wait()
|
|
453
133
|
finally:
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
461
|
-
return
|
|
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
|
-
|
|
471
|
-
|
|
216
|
+
with HostBridgeClient(path, default_timeout=1.0) as client:
|
|
217
|
+
return client.request("system.hello", {})
|
|
472
218
|
except HostBridgeError:
|
|
473
|
-
return
|
|
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
|
-
|
|
483
|
-
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
path
|
|
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
|
|
495
|
-
|
|
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 {
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
|
525
|
-
return
|
|
421
|
+
print(f"hostbridge daemon did not stop at unix://{path}", file=sys.stderr)
|
|
422
|
+
return 1
|