@neoline/hostbridge 1.4.4 → 2.0.1
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 -137
- package/package.json +1 -1
- package/pyproject.toml +2 -1
- package/src/server_control_mcp/__init__.py +5 -5
- package/src/server_control_mcp/cli.py +229 -1231
- package/src/server_control_mcp/client.py +478 -0
- package/src/server_control_mcp/config.py +158 -0
- package/src/server_control_mcp/daemon.py +387 -306
- package/src/server_control_mcp/doctor.py +108 -0
- package/src/server_control_mcp/hosts.py +52 -7
- package/src/server_control_mcp/mock_ssh.py +279 -99
- package/src/server_control_mcp/policy.py +2 -0
- package/src/server_control_mcp/protocol.py +362 -0
- package/src/server_control_mcp/runtime.py +40 -0
- package/src/server_control_mcp/secrets.py +8 -0
- package/src/server_control_mcp/server.py +132 -188
- package/src/server_control_mcp/services.py +508 -0
- package/src/server_control_mcp/transports/__init__.py +17 -0
- package/src/server_control_mcp/transports/base.py +78 -0
- package/src/server_control_mcp/transports/pty.py +427 -0
- package/src/server_control_mcp/transports/ssh.py +195 -0
- package/src/server_control_mcp/manager.py +0 -925
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import base64
|
|
4
|
+
import binascii
|
|
5
|
+
import hashlib
|
|
3
6
|
import json
|
|
4
7
|
import os
|
|
5
|
-
import signal
|
|
6
8
|
import socket
|
|
7
9
|
import subprocess
|
|
8
10
|
import sys
|
|
@@ -12,30 +14,29 @@ from contextlib import suppress
|
|
|
12
14
|
from dataclasses import asdict
|
|
13
15
|
from pathlib import Path
|
|
14
16
|
from socketserver import StreamRequestHandler, ThreadingMixIn, UnixStreamServer
|
|
15
|
-
from typing import Any
|
|
16
|
-
from urllib.parse import parse_qs, urlparse
|
|
17
17
|
|
|
18
|
+
from .client import HostBridgeClient, HostBridgeError
|
|
19
|
+
from .config import load_v1_config
|
|
18
20
|
from .hosts import HostRegistry, load_hosts
|
|
19
|
-
from .manager import (
|
|
20
|
-
DEFAULT_COMMAND_TIMEOUT,
|
|
21
|
-
DEFAULT_CONNECT_TIMEOUT,
|
|
22
|
-
DEFAULT_MAX_TRANSFER_BYTES,
|
|
23
|
-
PolicyDeniedError,
|
|
24
|
-
RemoteCommandError,
|
|
25
|
-
SessionManager,
|
|
26
|
-
_new_token,
|
|
27
|
-
)
|
|
28
21
|
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
|
+
from .runtime import runtime_paths
|
|
35
|
+
from .services import HostBridgeServices, ServiceError
|
|
36
|
+
from .transports.pty import PtyTransport
|
|
29
37
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
PID_FILE = DEFAULT_DAEMON_DIR / "daemon.pid"
|
|
33
|
-
LOG_FILE = DEFAULT_DAEMON_DIR / "daemon.log"
|
|
34
|
-
|
|
35
|
-
_ERROR_CODES = {
|
|
36
|
-
PolicyDeniedError: "denied",
|
|
37
|
-
RemoteCommandError: "remote_error",
|
|
38
|
-
}
|
|
38
|
+
MAX_V1_CONTROL_LINE_BYTES = 4 * 1024 * 1024
|
|
39
|
+
DEFAULT_CONNECT_TIMEOUT = 30
|
|
39
40
|
|
|
40
41
|
|
|
41
42
|
class ThreadingUnixRPCServer(ThreadingMixIn, UnixStreamServer):
|
|
@@ -43,268 +44,372 @@ class ThreadingUnixRPCServer(ThreadingMixIn, UnixStreamServer):
|
|
|
43
44
|
allow_reuse_address = True
|
|
44
45
|
|
|
45
46
|
|
|
46
|
-
def
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
for exc_type, code in _ERROR_CODES.items():
|
|
52
|
-
if isinstance(exc, exc_type):
|
|
53
|
-
return code
|
|
54
|
-
text = str(exc).lower()
|
|
55
|
-
if "unknown host" in text or "unknown session" in text or "unknown task" in text:
|
|
56
|
-
return "not_found"
|
|
57
|
-
return "internal"
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def _status_for_error(exc: Exception) -> int:
|
|
61
|
-
code = _error_code(exc)
|
|
62
|
-
if code == "not_found":
|
|
63
|
-
return 404
|
|
64
|
-
if code in {"denied", "remote_error"}:
|
|
65
|
-
return 409
|
|
66
|
-
return 500
|
|
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
|
|
67
52
|
|
|
68
53
|
|
|
69
|
-
def
|
|
70
|
-
|
|
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
|
|
71
59
|
|
|
72
60
|
|
|
73
|
-
|
|
74
|
-
|
|
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
|
+
)
|
|
75
118
|
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
78
264
|
|
|
79
265
|
def handle(self) -> None:
|
|
80
|
-
|
|
266
|
+
while raw_line := self.rfile.readline(MAX_V1_CONTROL_LINE_BYTES + 1):
|
|
267
|
+
request_id = "invalid"
|
|
81
268
|
try:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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")
|
|
89
288
|
self.wfile.flush()
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
99
342
|
try:
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
parsed = urlparse(path)
|
|
106
|
-
if method == "GET" and parsed.path == "/health":
|
|
107
|
-
return _ok(service="hostbridge-daemon", transport="unix-jsonl")
|
|
108
|
-
if method == "GET" and parsed.path == "/hosts":
|
|
109
|
-
return _ok(hosts=self.registry.describe())
|
|
110
|
-
if method == "POST" and parsed.path == "/hosts/reload":
|
|
111
|
-
registry = _reload_handler_hosts(self)
|
|
112
|
-
return _ok(hosts=registry.describe())
|
|
113
|
-
if method == "GET" and parsed.path == "/sessions":
|
|
114
|
-
return _ok(sessions=self.manager.list_sessions())
|
|
115
|
-
if method == "POST" and parsed.path == "/sessions":
|
|
116
|
-
token = body.get("token")
|
|
117
|
-
token_factory = (lambda: str(token)) if token else None
|
|
118
|
-
session = self.manager.open_session(
|
|
119
|
-
str(body.get("host_id", "")),
|
|
120
|
-
connect_timeout=int(body.get("connect_timeout", DEFAULT_CONNECT_TIMEOUT)),
|
|
121
|
-
owner_agent_id=_optional_str(body.get("owner_agent_id")),
|
|
122
|
-
**({"token_factory": token_factory} if token_factory else {}),
|
|
123
|
-
)
|
|
124
|
-
descriptor = next(item for item in self.manager.list_sessions() if item["session_id"] == session.id)
|
|
125
|
-
return _ok(session=descriptor)
|
|
126
|
-
if method == "POST" and parsed.path == "/sessions/close_all":
|
|
127
|
-
return _ok(**self.manager.close_all_sessions())
|
|
128
|
-
if method == "POST" and parsed.path == "/shutdown":
|
|
129
|
-
threading.Thread(target=self.server.shutdown, daemon=True).start()
|
|
130
|
-
return _ok(shutting_down=True)
|
|
131
|
-
|
|
132
|
-
parts = parsed.path.strip("/").split("/")
|
|
133
|
-
if len(parts) == 3 and parts[0] == "sessions" and parts[2] == "commands" and method == "POST":
|
|
134
|
-
token = body.get("token")
|
|
135
|
-
token_factory = (lambda: str(token)) if token else _new_token
|
|
136
|
-
command_result = self.manager.run_command(
|
|
137
|
-
parts[1],
|
|
138
|
-
str(body.get("command", "")),
|
|
139
|
-
timeout=int(body.get("timeout", DEFAULT_COMMAND_TIMEOUT)),
|
|
140
|
-
owner_agent_id=_optional_str(body.get("owner_agent_id")),
|
|
141
|
-
token_factory=token_factory,
|
|
142
|
-
)
|
|
143
|
-
return _ok(result=asdict(command_result))
|
|
144
|
-
if len(parts) == 3 and parts[0] == "sessions" and parts[2] == "tasks" and method == "POST":
|
|
145
|
-
task = self.manager.start_task(
|
|
146
|
-
parts[1],
|
|
147
|
-
str(body.get("command", "")),
|
|
148
|
-
owner_agent_id=_optional_str(body.get("owner_agent_id")),
|
|
149
|
-
)
|
|
150
|
-
return _ok(task=asdict(task))
|
|
151
|
-
if len(parts) == 4 and parts[0] == "sessions" and parts[2] == "tasks" and method == "GET":
|
|
152
|
-
query = parse_qs(parsed.query)
|
|
153
|
-
owner_agent_id = _optional_str(body.get("owner_agent_id") or query.get("owner_agent_id", [None])[0])
|
|
154
|
-
return _ok(
|
|
155
|
-
status=self.manager.task_status(
|
|
156
|
-
parts[1],
|
|
157
|
-
parts[3],
|
|
158
|
-
tail_lines=int(query.get("tail_lines", [80])[0]),
|
|
159
|
-
owner_agent_id=owner_agent_id,
|
|
160
|
-
)
|
|
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),
|
|
161
348
|
)
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
owner_agent_id=owner,
|
|
179
|
-
token_factory=token_factory,
|
|
180
|
-
)
|
|
181
|
-
)
|
|
182
|
-
if action == "read_text":
|
|
183
|
-
return _ok(
|
|
184
|
-
transfer=self.manager.file_read_text(
|
|
185
|
-
parts[1],
|
|
186
|
-
str(body.get("remote_path", "")),
|
|
187
|
-
max_bytes=int(body.get("max_bytes", DEFAULT_MAX_TRANSFER_BYTES)),
|
|
188
|
-
owner_agent_id=owner,
|
|
189
|
-
token_factory=token_factory,
|
|
190
|
-
)
|
|
191
|
-
)
|
|
192
|
-
if action == "upload":
|
|
193
|
-
return _ok(
|
|
194
|
-
transfer=self.manager.file_upload(
|
|
195
|
-
parts[1],
|
|
196
|
-
str(body.get("local_path", "")),
|
|
197
|
-
str(body.get("remote_path", "")),
|
|
198
|
-
mode=str(body.get("mode", "0644")),
|
|
199
|
-
max_bytes=int(body.get("max_bytes", DEFAULT_MAX_TRANSFER_BYTES)),
|
|
200
|
-
owner_agent_id=owner,
|
|
201
|
-
token_factory=token_factory,
|
|
202
|
-
)
|
|
203
|
-
)
|
|
204
|
-
if action == "download":
|
|
205
|
-
return _ok(
|
|
206
|
-
transfer=self.manager.file_download(
|
|
207
|
-
parts[1],
|
|
208
|
-
str(body.get("remote_path", "")),
|
|
209
|
-
str(body.get("local_path", "")),
|
|
210
|
-
max_bytes=int(body.get("max_bytes", DEFAULT_MAX_TRANSFER_BYTES)),
|
|
211
|
-
owner_agent_id=owner,
|
|
212
|
-
token_factory=token_factory,
|
|
213
|
-
)
|
|
214
|
-
)
|
|
215
|
-
return {"ok": False, "status": 400, "error": "unknown file action", "error_code": "bad_request"}
|
|
216
|
-
if len(parts) == 3 and parts[0] == "sessions" and parts[2] == "write" and method == "POST":
|
|
217
|
-
result = self.manager.write(parts[1], str(body.get("text", "")), owner_agent_id=_optional_str(body.get("owner_agent_id")))
|
|
218
|
-
return _ok(**result)
|
|
219
|
-
if len(parts) == 3 and parts[0] == "sessions" and parts[2] == "read" and method == "POST":
|
|
220
|
-
result = self.manager.read(
|
|
221
|
-
parts[1],
|
|
222
|
-
timeout=float(body.get("timeout", 1.0)),
|
|
223
|
-
max_chars=int(body.get("max_chars", 20000)),
|
|
224
|
-
owner_agent_id=_optional_str(body.get("owner_agent_id")),
|
|
225
|
-
)
|
|
226
|
-
return _ok(**result)
|
|
227
|
-
if len(parts) == 2 and parts[0] == "sessions" and method == "DELETE":
|
|
228
|
-
query = parse_qs(parsed.query)
|
|
229
|
-
force = bool(body.get("force")) or query.get("force", [""])[0].lower() in {"1", "true", "yes"}
|
|
230
|
-
return _ok(
|
|
231
|
-
**self.manager.close_session(
|
|
232
|
-
parts[1],
|
|
233
|
-
owner_agent_id=_optional_str(body.get("owner_agent_id")),
|
|
234
|
-
force=force,
|
|
235
|
-
)
|
|
236
|
-
)
|
|
237
|
-
return {"ok": False, "status": 404, "error": "not found", "error_code": "not_found"}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
def _optional_str(value: object) -> str | None:
|
|
241
|
-
if value is None:
|
|
242
|
-
return None
|
|
243
|
-
text = str(value).strip()
|
|
244
|
-
return text or None
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
def _reload_handler_hosts(handler: HostBridgeDaemonHandler) -> HostRegistry:
|
|
248
|
-
registry = HostRegistry(load_hosts())
|
|
249
|
-
handler.manager.hosts = registry
|
|
250
|
-
type(handler).registry = registry
|
|
251
|
-
return registry
|
|
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()
|
|
252
365
|
|
|
253
366
|
|
|
254
|
-
def
|
|
255
|
-
class Handler(
|
|
367
|
+
def make_v1_handler(dispatcher: HostBridgeV1Dispatcher) -> type[HostBridgeV1Handler]:
|
|
368
|
+
class Handler(HostBridgeV1Handler):
|
|
256
369
|
pass
|
|
257
370
|
|
|
258
|
-
Handler.
|
|
259
|
-
Handler.registry = registry or manager.hosts
|
|
371
|
+
Handler.dispatcher = dispatcher
|
|
260
372
|
return Handler
|
|
261
373
|
|
|
262
374
|
|
|
263
|
-
def
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
def build_manager(config_path: Path | str | None = None) -> SessionManager:
|
|
375
|
+
def build_v1_services(
|
|
376
|
+
config_path: Path | str | None = None,
|
|
377
|
+
*,
|
|
378
|
+
connect_timeout: int = DEFAULT_CONNECT_TIMEOUT,
|
|
379
|
+
) -> HostBridgeServices:
|
|
380
|
+
if config_path is not None:
|
|
381
|
+
load_v1_config(config_path)
|
|
271
382
|
registry = HostRegistry(load_hosts(config_path))
|
|
272
383
|
policy = load_policy()
|
|
273
|
-
|
|
384
|
+
|
|
385
|
+
def transport_factory(host):
|
|
386
|
+
return PtyTransport.connect(host, connect_timeout=connect_timeout)
|
|
387
|
+
|
|
388
|
+
services = HostBridgeServices(
|
|
274
389
|
registry,
|
|
390
|
+
transport_factory=transport_factory,
|
|
275
391
|
policy=policy,
|
|
276
392
|
audit_sink=build_audit_sink(policy),
|
|
277
|
-
max_sessions_per_host=_max_sessions_per_host_from_env(),
|
|
278
393
|
)
|
|
279
|
-
|
|
280
|
-
|
|
394
|
+
if policy.enabled and policy.idle_timeout_seconds > 0:
|
|
395
|
+
services.start_idle_reaper(timeout=policy.idle_timeout_seconds)
|
|
396
|
+
return services
|
|
281
397
|
|
|
282
398
|
|
|
283
399
|
def daemon_dir(path: Path | str | None = None) -> Path:
|
|
284
|
-
|
|
285
|
-
if configured:
|
|
286
|
-
return Path(configured).expanduser()
|
|
287
|
-
socket_override = os.environ.get("HOSTBRIDGE_DAEMON_SOCKET")
|
|
288
|
-
if socket_override:
|
|
289
|
-
return Path(socket_override).expanduser().parent
|
|
290
|
-
configured = DEFAULT_DAEMON_DIR
|
|
291
|
-
return Path(configured).expanduser()
|
|
400
|
+
return Path(path).expanduser() if path is not None else runtime_paths().directory
|
|
292
401
|
|
|
293
402
|
|
|
294
403
|
def socket_path(path: Path | str | None = None) -> Path:
|
|
295
|
-
|
|
296
|
-
if configured:
|
|
297
|
-
return Path(configured).expanduser()
|
|
298
|
-
configured = daemon_dir() / "daemon.sock"
|
|
299
|
-
return Path(configured).expanduser()
|
|
404
|
+
return runtime_paths(path).socket
|
|
300
405
|
|
|
301
406
|
|
|
302
407
|
def pid_file() -> Path:
|
|
303
|
-
return
|
|
408
|
+
return runtime_paths().pid
|
|
304
409
|
|
|
305
410
|
|
|
306
411
|
def log_file() -> Path:
|
|
307
|
-
return
|
|
412
|
+
return runtime_paths().log
|
|
308
413
|
|
|
309
414
|
|
|
310
415
|
def _prepare_socket_path(path: Path) -> None:
|
|
@@ -329,18 +434,25 @@ def _socket_accepts_connections(path: Path) -> bool:
|
|
|
329
434
|
return False
|
|
330
435
|
|
|
331
436
|
|
|
332
|
-
def run_daemon(
|
|
333
|
-
|
|
437
|
+
def run_daemon(
|
|
438
|
+
*,
|
|
439
|
+
socket_file: Path | str | None = None,
|
|
440
|
+
config_path: Path | str | None = None,
|
|
441
|
+
) -> int:
|
|
442
|
+
services = build_v1_services(config_path)
|
|
334
443
|
path = socket_path(socket_file)
|
|
335
444
|
_prepare_socket_path(path)
|
|
336
|
-
server = ThreadingUnixRPCServer(
|
|
445
|
+
server = ThreadingUnixRPCServer(
|
|
446
|
+
str(path),
|
|
447
|
+
make_v1_handler(HostBridgeV1Dispatcher(services)),
|
|
448
|
+
)
|
|
337
449
|
pid_file().write_text(str(os.getpid()), encoding="utf-8")
|
|
338
450
|
try:
|
|
339
451
|
print(f"hostbridge daemon listening on unix://{path}", flush=True)
|
|
340
452
|
server.serve_forever()
|
|
341
453
|
finally:
|
|
342
|
-
|
|
343
|
-
|
|
454
|
+
services.close_all()
|
|
455
|
+
services.stop_idle_reaper()
|
|
344
456
|
server.server_close()
|
|
345
457
|
with suppress(FileNotFoundError):
|
|
346
458
|
path.unlink()
|
|
@@ -355,50 +467,18 @@ def daemon_endpoint() -> str:
|
|
|
355
467
|
|
|
356
468
|
def is_running(*, socket_file: Path | str | None = None) -> bool:
|
|
357
469
|
try:
|
|
358
|
-
|
|
359
|
-
return
|
|
360
|
-
except
|
|
470
|
+
hello = HostBridgeClient(socket_file, default_timeout=1.0).hello()
|
|
471
|
+
return hello.get("service") == "hostbridge" and hello.get("protocol") == PROTOCOL_VERSION
|
|
472
|
+
except HostBridgeError:
|
|
361
473
|
return False
|
|
362
474
|
|
|
363
475
|
|
|
364
|
-
def
|
|
365
|
-
method: str,
|
|
366
|
-
path: str,
|
|
367
|
-
payload: dict[str, object] | None = None,
|
|
476
|
+
def start_background(
|
|
368
477
|
*,
|
|
369
|
-
timeout: float = 5.0,
|
|
370
478
|
socket_file: Path | str | None = None,
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
client.settimeout(timeout)
|
|
375
|
-
client.connect(str(socket_path(socket_file)))
|
|
376
|
-
client.sendall(json.dumps(request, ensure_ascii=False).encode("utf-8") + b"\n")
|
|
377
|
-
response = _recv_json_line(client)
|
|
378
|
-
if not isinstance(response, dict):
|
|
379
|
-
raise RuntimeError("daemon returned non-object JSON")
|
|
380
|
-
return response
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
def _recv_json_line(client: socket.socket) -> dict[str, object]:
|
|
384
|
-
chunks: list[bytes] = []
|
|
385
|
-
while True:
|
|
386
|
-
chunk = client.recv(65536)
|
|
387
|
-
if not chunk:
|
|
388
|
-
break
|
|
389
|
-
chunks.append(chunk)
|
|
390
|
-
if b"\n" in chunk:
|
|
391
|
-
break
|
|
392
|
-
line = b"".join(chunks).split(b"\n", 1)[0]
|
|
393
|
-
if not line:
|
|
394
|
-
return {}
|
|
395
|
-
decoded = json.loads(line.decode("utf-8"))
|
|
396
|
-
if not isinstance(decoded, dict):
|
|
397
|
-
raise RuntimeError("daemon returned non-object JSON")
|
|
398
|
-
return decoded
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
def start_background(*, socket_file: Path | str | None = None, quiet: bool = False) -> int:
|
|
479
|
+
config_path: Path | str | None = None,
|
|
480
|
+
quiet: bool = False,
|
|
481
|
+
) -> int:
|
|
402
482
|
path = socket_path(socket_file)
|
|
403
483
|
if is_running(socket_file=path):
|
|
404
484
|
if not quiet:
|
|
@@ -407,15 +487,19 @@ def start_background(*, socket_file: Path | str | None = None, quiet: bool = Fal
|
|
|
407
487
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
408
488
|
with suppress(OSError):
|
|
409
489
|
path.parent.chmod(0o700)
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
[
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
490
|
+
command = [sys.executable, "-m", "server_control_mcp", "daemon"]
|
|
491
|
+
if config_path is not None:
|
|
492
|
+
command.extend(["--config", str(config_path)])
|
|
493
|
+
command.extend(["start", "--foreground", "--socket", str(path)])
|
|
494
|
+
with log_file().open("a", encoding="utf-8") as log_handle:
|
|
495
|
+
process = subprocess.Popen(
|
|
496
|
+
command,
|
|
497
|
+
stdin=subprocess.DEVNULL,
|
|
498
|
+
stdout=log_handle,
|
|
499
|
+
stderr=log_handle,
|
|
500
|
+
start_new_session=True,
|
|
501
|
+
env=os.environ.copy(),
|
|
502
|
+
)
|
|
419
503
|
pid_file().write_text(str(process.pid), encoding="utf-8")
|
|
420
504
|
deadline = time.time() + 5
|
|
421
505
|
while time.time() < deadline:
|
|
@@ -423,6 +507,8 @@ def start_background(*, socket_file: Path | str | None = None, quiet: bool = Fal
|
|
|
423
507
|
if not quiet:
|
|
424
508
|
print(f"hostbridge daemon started at unix://{path} (pid {process.pid})")
|
|
425
509
|
return 0
|
|
510
|
+
if process.poll() is not None:
|
|
511
|
+
break
|
|
426
512
|
time.sleep(0.1)
|
|
427
513
|
if not quiet:
|
|
428
514
|
print(f"hostbridge daemon failed to start; see {log_file()}", file=sys.stderr)
|
|
@@ -432,13 +518,8 @@ def start_background(*, socket_file: Path | str | None = None, quiet: bool = Fal
|
|
|
432
518
|
def stop_background(*, socket_file: Path | str | None = None) -> int:
|
|
433
519
|
path = socket_path(socket_file)
|
|
434
520
|
if is_running(socket_file=path):
|
|
435
|
-
|
|
521
|
+
HostBridgeClient(path, default_timeout=2.0).request("system.shutdown", {})
|
|
436
522
|
print("hostbridge daemon stopped")
|
|
437
523
|
return 0
|
|
438
|
-
if pid_file().exists():
|
|
439
|
-
with suppress(Exception):
|
|
440
|
-
os.kill(int(pid_file().read_text(encoding="utf-8").strip()), signal.SIGTERM)
|
|
441
|
-
print("hostbridge daemon process signaled")
|
|
442
|
-
return 0
|
|
443
524
|
print("hostbridge daemon is not running")
|
|
444
525
|
return 0
|