@neoline/hostbridge 0.2.1 → 1.0.0
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 +57 -11
- package/examples/claude_desktop_config.json +4 -1
- package/examples/codex.config.toml +2 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/server_control_mcp/__init__.py +1 -1
- package/src/server_control_mcp/__main__.py +6 -3
- package/src/server_control_mcp/cli.py +52 -12
- package/src/server_control_mcp/daemon.py +419 -0
- package/src/server_control_mcp/manager.py +294 -11
- package/src/server_control_mcp/server.py +144 -115
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import signal
|
|
6
|
+
import socket
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from contextlib import suppress
|
|
12
|
+
from dataclasses import asdict
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from socketserver import StreamRequestHandler, ThreadingMixIn, UnixStreamServer
|
|
15
|
+
from typing import Any
|
|
16
|
+
from urllib.parse import parse_qs, urlparse
|
|
17
|
+
|
|
18
|
+
from .hosts import HostRegistry, load_hosts
|
|
19
|
+
from .manager import (
|
|
20
|
+
DEFAULT_COMMAND_TIMEOUT,
|
|
21
|
+
DEFAULT_CONNECT_TIMEOUT,
|
|
22
|
+
PolicyDeniedError,
|
|
23
|
+
RemoteCommandError,
|
|
24
|
+
SessionManager,
|
|
25
|
+
_new_token,
|
|
26
|
+
)
|
|
27
|
+
from .policy import build_audit_sink, load_policy
|
|
28
|
+
|
|
29
|
+
DEFAULT_DAEMON_DIR = Path("~/.hostbridge").expanduser()
|
|
30
|
+
DEFAULT_SOCKET_FILE = DEFAULT_DAEMON_DIR / "daemon.sock"
|
|
31
|
+
PID_FILE = DEFAULT_DAEMON_DIR / "daemon.pid"
|
|
32
|
+
LOG_FILE = DEFAULT_DAEMON_DIR / "daemon.log"
|
|
33
|
+
|
|
34
|
+
_ERROR_CODES = {
|
|
35
|
+
PolicyDeniedError: "denied",
|
|
36
|
+
RemoteCommandError: "remote_error",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ThreadingUnixRPCServer(ThreadingMixIn, UnixStreamServer):
|
|
41
|
+
daemon_threads = True
|
|
42
|
+
allow_reuse_address = True
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _ok(**payload: object) -> dict[str, object]:
|
|
46
|
+
return {"ok": True, **payload}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _error_code(exc: Exception) -> str:
|
|
50
|
+
for exc_type, code in _ERROR_CODES.items():
|
|
51
|
+
if isinstance(exc, exc_type):
|
|
52
|
+
return code
|
|
53
|
+
text = str(exc).lower()
|
|
54
|
+
if "unknown host" in text or "unknown session" in text or "unknown task" in text:
|
|
55
|
+
return "not_found"
|
|
56
|
+
return "internal"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _status_for_error(exc: Exception) -> int:
|
|
60
|
+
code = _error_code(exc)
|
|
61
|
+
if code == "not_found":
|
|
62
|
+
return 404
|
|
63
|
+
if code in {"denied", "remote_error"}:
|
|
64
|
+
return 409
|
|
65
|
+
return 500
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _error(exc: Exception) -> dict[str, object]:
|
|
69
|
+
return {"ok": False, "status": _status_for_error(exc), "error": str(exc), "error_code": _error_code(exc)}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class HostBridgeDaemonHandler(StreamRequestHandler):
|
|
73
|
+
"""One JSON request per line, one JSON response per line over a Unix socket."""
|
|
74
|
+
|
|
75
|
+
manager: SessionManager
|
|
76
|
+
registry: HostRegistry
|
|
77
|
+
|
|
78
|
+
def handle(self) -> None:
|
|
79
|
+
for raw_line in self.rfile:
|
|
80
|
+
try:
|
|
81
|
+
request = json.loads(raw_line.decode("utf-8"))
|
|
82
|
+
if not isinstance(request, dict):
|
|
83
|
+
raise ValueError("request must be a JSON object")
|
|
84
|
+
response = self.dispatch(request)
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
response = _error(exc)
|
|
87
|
+
self.wfile.write(json.dumps(response, ensure_ascii=False).encode("utf-8") + b"\n")
|
|
88
|
+
self.wfile.flush()
|
|
89
|
+
|
|
90
|
+
def dispatch(self, request: dict[str, Any]) -> dict[str, object]:
|
|
91
|
+
method = str(request.get("method", "GET")).upper()
|
|
92
|
+
path = str(request.get("path", "/"))
|
|
93
|
+
body = request.get("body", {})
|
|
94
|
+
if body is None:
|
|
95
|
+
body = {}
|
|
96
|
+
if not isinstance(body, dict):
|
|
97
|
+
raise ValueError("request body must be a JSON object")
|
|
98
|
+
try:
|
|
99
|
+
return self._dispatch(method, path, body)
|
|
100
|
+
except Exception as exc:
|
|
101
|
+
return _error(exc)
|
|
102
|
+
|
|
103
|
+
def _dispatch(self, method: str, path: str, body: dict[str, Any]) -> dict[str, object]:
|
|
104
|
+
parsed = urlparse(path)
|
|
105
|
+
if method == "GET" and parsed.path == "/health":
|
|
106
|
+
return _ok(service="hostbridge-daemon", transport="unix-jsonl")
|
|
107
|
+
if method == "GET" and parsed.path == "/hosts":
|
|
108
|
+
return _ok(hosts=self.registry.describe())
|
|
109
|
+
if method == "POST" and parsed.path == "/hosts/reload":
|
|
110
|
+
registry = _reload_handler_hosts(self)
|
|
111
|
+
return _ok(hosts=registry.describe())
|
|
112
|
+
if method == "GET" and parsed.path == "/sessions":
|
|
113
|
+
return _ok(sessions=self.manager.list_sessions())
|
|
114
|
+
if method == "POST" and parsed.path == "/sessions":
|
|
115
|
+
token = body.get("token")
|
|
116
|
+
token_factory = (lambda: str(token)) if token else None
|
|
117
|
+
session = self.manager.open_session(
|
|
118
|
+
str(body.get("host_id", "")),
|
|
119
|
+
connect_timeout=int(body.get("connect_timeout", DEFAULT_CONNECT_TIMEOUT)),
|
|
120
|
+
owner_agent_id=_optional_str(body.get("owner_agent_id")),
|
|
121
|
+
**({"token_factory": token_factory} if token_factory else {}),
|
|
122
|
+
)
|
|
123
|
+
descriptor = next(item for item in self.manager.list_sessions() if item["session_id"] == session.id)
|
|
124
|
+
return _ok(session=descriptor)
|
|
125
|
+
if method == "POST" and parsed.path == "/sessions/close_all":
|
|
126
|
+
return _ok(**self.manager.close_all_sessions())
|
|
127
|
+
if method == "POST" and parsed.path == "/shutdown":
|
|
128
|
+
threading.Thread(target=self.server.shutdown, daemon=True).start()
|
|
129
|
+
return _ok(shutting_down=True)
|
|
130
|
+
|
|
131
|
+
parts = parsed.path.strip("/").split("/")
|
|
132
|
+
if len(parts) == 3 and parts[0] == "sessions" and parts[2] == "commands" and method == "POST":
|
|
133
|
+
token = body.get("token")
|
|
134
|
+
token_factory = (lambda: str(token)) if token else _new_token
|
|
135
|
+
command_result = self.manager.run_command(
|
|
136
|
+
parts[1],
|
|
137
|
+
str(body.get("command", "")),
|
|
138
|
+
timeout=int(body.get("timeout", DEFAULT_COMMAND_TIMEOUT)),
|
|
139
|
+
owner_agent_id=_optional_str(body.get("owner_agent_id")),
|
|
140
|
+
token_factory=token_factory,
|
|
141
|
+
)
|
|
142
|
+
return _ok(result=asdict(command_result))
|
|
143
|
+
if len(parts) == 3 and parts[0] == "sessions" and parts[2] == "tasks" and method == "POST":
|
|
144
|
+
task = self.manager.start_task(
|
|
145
|
+
parts[1],
|
|
146
|
+
str(body.get("command", "")),
|
|
147
|
+
owner_agent_id=_optional_str(body.get("owner_agent_id")),
|
|
148
|
+
)
|
|
149
|
+
return _ok(task=asdict(task))
|
|
150
|
+
if len(parts) == 4 and parts[0] == "sessions" and parts[2] == "tasks" and method == "GET":
|
|
151
|
+
query = parse_qs(parsed.query)
|
|
152
|
+
owner_agent_id = _optional_str(body.get("owner_agent_id") or query.get("owner_agent_id", [None])[0])
|
|
153
|
+
return _ok(
|
|
154
|
+
status=self.manager.task_status(
|
|
155
|
+
parts[1],
|
|
156
|
+
parts[3],
|
|
157
|
+
tail_lines=int(query.get("tail_lines", [80])[0]),
|
|
158
|
+
owner_agent_id=owner_agent_id,
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
if len(parts) == 4 and parts[0] == "sessions" and parts[2] == "tasks" and method == "DELETE":
|
|
162
|
+
return _ok(**self.manager.stop_task(parts[1], parts[3], owner_agent_id=_optional_str(body.get("owner_agent_id")), force=bool(body.get("force"))))
|
|
163
|
+
if len(parts) == 3 and parts[0] == "sessions" and parts[2] == "files" and method == "POST":
|
|
164
|
+
action = str(body.get("action", ""))
|
|
165
|
+
owner = _optional_str(body.get("owner_agent_id"))
|
|
166
|
+
token = body.get("token")
|
|
167
|
+
token_factory = (lambda: str(token)) if token else None
|
|
168
|
+
token_factory = token_factory or _new_token
|
|
169
|
+
if action == "write_text":
|
|
170
|
+
return _ok(
|
|
171
|
+
transfer=self.manager.file_write_text(
|
|
172
|
+
parts[1],
|
|
173
|
+
str(body.get("remote_path", "")),
|
|
174
|
+
str(body.get("content", "")),
|
|
175
|
+
mode=str(body.get("mode", "0644")),
|
|
176
|
+
max_bytes=int(body.get("max_bytes", 1024 * 1024)),
|
|
177
|
+
owner_agent_id=owner,
|
|
178
|
+
token_factory=token_factory,
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
if action == "read_text":
|
|
182
|
+
return _ok(
|
|
183
|
+
transfer=self.manager.file_read_text(
|
|
184
|
+
parts[1],
|
|
185
|
+
str(body.get("remote_path", "")),
|
|
186
|
+
max_bytes=int(body.get("max_bytes", 1024 * 1024)),
|
|
187
|
+
owner_agent_id=owner,
|
|
188
|
+
token_factory=token_factory,
|
|
189
|
+
)
|
|
190
|
+
)
|
|
191
|
+
if action == "upload":
|
|
192
|
+
return _ok(
|
|
193
|
+
transfer=self.manager.file_upload(
|
|
194
|
+
parts[1],
|
|
195
|
+
str(body.get("local_path", "")),
|
|
196
|
+
str(body.get("remote_path", "")),
|
|
197
|
+
mode=str(body.get("mode", "0644")),
|
|
198
|
+
max_bytes=int(body.get("max_bytes", 1024 * 1024)),
|
|
199
|
+
owner_agent_id=owner,
|
|
200
|
+
token_factory=token_factory,
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
if action == "download":
|
|
204
|
+
return _ok(
|
|
205
|
+
transfer=self.manager.file_download(
|
|
206
|
+
parts[1],
|
|
207
|
+
str(body.get("remote_path", "")),
|
|
208
|
+
str(body.get("local_path", "")),
|
|
209
|
+
max_bytes=int(body.get("max_bytes", 1024 * 1024)),
|
|
210
|
+
owner_agent_id=owner,
|
|
211
|
+
token_factory=token_factory,
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
return {"ok": False, "status": 400, "error": "unknown file action", "error_code": "bad_request"}
|
|
215
|
+
if len(parts) == 3 and parts[0] == "sessions" and parts[2] == "write" and method == "POST":
|
|
216
|
+
result = self.manager.write(parts[1], str(body.get("text", "")), owner_agent_id=_optional_str(body.get("owner_agent_id")))
|
|
217
|
+
return _ok(**result)
|
|
218
|
+
if len(parts) == 3 and parts[0] == "sessions" and parts[2] == "read" and method == "POST":
|
|
219
|
+
result = self.manager.read(
|
|
220
|
+
parts[1],
|
|
221
|
+
timeout=float(body.get("timeout", 1.0)),
|
|
222
|
+
max_chars=int(body.get("max_chars", 20000)),
|
|
223
|
+
owner_agent_id=_optional_str(body.get("owner_agent_id")),
|
|
224
|
+
)
|
|
225
|
+
return _ok(**result)
|
|
226
|
+
if len(parts) == 2 and parts[0] == "sessions" and method == "DELETE":
|
|
227
|
+
query = parse_qs(parsed.query)
|
|
228
|
+
force = bool(body.get("force")) or query.get("force", [""])[0].lower() in {"1", "true", "yes"}
|
|
229
|
+
return _ok(
|
|
230
|
+
**self.manager.close_session(
|
|
231
|
+
parts[1],
|
|
232
|
+
owner_agent_id=_optional_str(body.get("owner_agent_id")),
|
|
233
|
+
force=force,
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
return {"ok": False, "status": 404, "error": "not found", "error_code": "not_found"}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _optional_str(value: object) -> str | None:
|
|
240
|
+
if value is None:
|
|
241
|
+
return None
|
|
242
|
+
text = str(value).strip()
|
|
243
|
+
return text or None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _reload_handler_hosts(handler: HostBridgeDaemonHandler) -> HostRegistry:
|
|
247
|
+
registry = HostRegistry(load_hosts())
|
|
248
|
+
handler.manager.hosts = registry
|
|
249
|
+
type(handler).registry = registry
|
|
250
|
+
return registry
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def make_handler(manager: SessionManager, registry: HostRegistry | None = None) -> type[HostBridgeDaemonHandler]:
|
|
254
|
+
class Handler(HostBridgeDaemonHandler):
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
Handler.manager = manager
|
|
258
|
+
Handler.registry = registry or manager.hosts
|
|
259
|
+
return Handler
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _max_sessions_per_host_from_env() -> int:
|
|
263
|
+
raw = os.environ.get("HOSTBRIDGE_MAX_SESSIONS_PER_HOST", "0")
|
|
264
|
+
with suppress(ValueError):
|
|
265
|
+
return max(0, int(raw))
|
|
266
|
+
return 0
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def build_manager() -> SessionManager:
|
|
270
|
+
registry = HostRegistry(load_hosts())
|
|
271
|
+
policy = load_policy()
|
|
272
|
+
manager = SessionManager(
|
|
273
|
+
registry,
|
|
274
|
+
policy=policy,
|
|
275
|
+
audit_sink=build_audit_sink(policy),
|
|
276
|
+
max_sessions_per_host=_max_sessions_per_host_from_env(),
|
|
277
|
+
)
|
|
278
|
+
manager.start_idle_reaper()
|
|
279
|
+
return manager
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def socket_path(path: Path | str | None = None) -> Path:
|
|
283
|
+
configured = path or os.environ.get("HOSTBRIDGE_DAEMON_SOCKET") or DEFAULT_SOCKET_FILE
|
|
284
|
+
return Path(configured).expanduser()
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _prepare_socket_path(path: Path) -> None:
|
|
288
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
289
|
+
with suppress(OSError):
|
|
290
|
+
path.parent.chmod(0o700)
|
|
291
|
+
if path.exists():
|
|
292
|
+
if _socket_accepts_connections(path):
|
|
293
|
+
raise RuntimeError(f"hostbridge daemon already running at unix://{path}")
|
|
294
|
+
path.unlink()
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _socket_accepts_connections(path: Path) -> bool:
|
|
298
|
+
try:
|
|
299
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
|
|
300
|
+
client.settimeout(0.5)
|
|
301
|
+
client.connect(str(path))
|
|
302
|
+
return True
|
|
303
|
+
except OSError:
|
|
304
|
+
return False
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def run_daemon(*, socket_file: Path | str | None = None) -> int:
|
|
308
|
+
manager = build_manager()
|
|
309
|
+
path = socket_path(socket_file)
|
|
310
|
+
_prepare_socket_path(path)
|
|
311
|
+
server = ThreadingUnixRPCServer(str(path), make_handler(manager))
|
|
312
|
+
PID_FILE.write_text(str(os.getpid()), encoding="utf-8")
|
|
313
|
+
try:
|
|
314
|
+
print(f"hostbridge daemon listening on unix://{path}", flush=True)
|
|
315
|
+
server.serve_forever()
|
|
316
|
+
finally:
|
|
317
|
+
manager.close_all_sessions()
|
|
318
|
+
manager.stop_idle_reaper()
|
|
319
|
+
server.server_close()
|
|
320
|
+
with suppress(FileNotFoundError):
|
|
321
|
+
path.unlink()
|
|
322
|
+
with suppress(FileNotFoundError):
|
|
323
|
+
PID_FILE.unlink()
|
|
324
|
+
return 0
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def daemon_endpoint() -> str:
|
|
328
|
+
return f"unix://{socket_path()}"
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def is_running(*, socket_file: Path | str | None = None) -> bool:
|
|
332
|
+
try:
|
|
333
|
+
health = request_json("GET", "/health", timeout=1.0, socket_file=socket_file)
|
|
334
|
+
return health.get("ok") is True and health.get("service") == "hostbridge-daemon"
|
|
335
|
+
except Exception:
|
|
336
|
+
return False
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def request_json(
|
|
340
|
+
method: str,
|
|
341
|
+
path: str,
|
|
342
|
+
payload: dict[str, object] | None = None,
|
|
343
|
+
*,
|
|
344
|
+
timeout: float = 5.0,
|
|
345
|
+
socket_file: Path | str | None = None,
|
|
346
|
+
) -> dict[str, object]:
|
|
347
|
+
request = {"method": method.upper(), "path": path, "body": payload or {}}
|
|
348
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
|
|
349
|
+
client.settimeout(timeout)
|
|
350
|
+
client.connect(str(socket_path(socket_file)))
|
|
351
|
+
client.sendall(json.dumps(request, ensure_ascii=False).encode("utf-8") + b"\n")
|
|
352
|
+
response = _recv_json_line(client)
|
|
353
|
+
if not isinstance(response, dict):
|
|
354
|
+
raise RuntimeError("daemon returned non-object JSON")
|
|
355
|
+
return response
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _recv_json_line(client: socket.socket) -> dict[str, object]:
|
|
359
|
+
chunks: list[bytes] = []
|
|
360
|
+
while True:
|
|
361
|
+
chunk = client.recv(65536)
|
|
362
|
+
if not chunk:
|
|
363
|
+
break
|
|
364
|
+
chunks.append(chunk)
|
|
365
|
+
if b"\n" in chunk:
|
|
366
|
+
break
|
|
367
|
+
line = b"".join(chunks).split(b"\n", 1)[0]
|
|
368
|
+
if not line:
|
|
369
|
+
return {}
|
|
370
|
+
decoded = json.loads(line.decode("utf-8"))
|
|
371
|
+
if not isinstance(decoded, dict):
|
|
372
|
+
raise RuntimeError("daemon returned non-object JSON")
|
|
373
|
+
return decoded
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def start_background(*, socket_file: Path | str | None = None, quiet: bool = False) -> int:
|
|
377
|
+
path = socket_path(socket_file)
|
|
378
|
+
if is_running(socket_file=path):
|
|
379
|
+
if not quiet:
|
|
380
|
+
print(f"hostbridge daemon already running at unix://{path}")
|
|
381
|
+
return 0
|
|
382
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
383
|
+
with suppress(OSError):
|
|
384
|
+
path.parent.chmod(0o700)
|
|
385
|
+
log_handle = LOG_FILE.open("a", encoding="utf-8")
|
|
386
|
+
process = subprocess.Popen(
|
|
387
|
+
[sys.executable, "-m", "server_control_mcp", "daemon", "start", "--foreground", "--socket", str(path)],
|
|
388
|
+
stdin=subprocess.DEVNULL,
|
|
389
|
+
stdout=log_handle,
|
|
390
|
+
stderr=log_handle,
|
|
391
|
+
start_new_session=True,
|
|
392
|
+
env=os.environ.copy(),
|
|
393
|
+
)
|
|
394
|
+
PID_FILE.write_text(str(process.pid), encoding="utf-8")
|
|
395
|
+
deadline = time.time() + 5
|
|
396
|
+
while time.time() < deadline:
|
|
397
|
+
if is_running(socket_file=path):
|
|
398
|
+
if not quiet:
|
|
399
|
+
print(f"hostbridge daemon started at unix://{path} (pid {process.pid})")
|
|
400
|
+
return 0
|
|
401
|
+
time.sleep(0.1)
|
|
402
|
+
if not quiet:
|
|
403
|
+
print(f"hostbridge daemon failed to start; see {LOG_FILE}", file=sys.stderr)
|
|
404
|
+
return 1
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def stop_background(*, socket_file: Path | str | None = None) -> int:
|
|
408
|
+
path = socket_path(socket_file)
|
|
409
|
+
if is_running(socket_file=path):
|
|
410
|
+
request_json("POST", "/shutdown", timeout=2.0, socket_file=path)
|
|
411
|
+
print("hostbridge daemon stopped")
|
|
412
|
+
return 0
|
|
413
|
+
if PID_FILE.exists():
|
|
414
|
+
with suppress(Exception):
|
|
415
|
+
os.kill(int(PID_FILE.read_text(encoding="utf-8").strip()), signal.SIGTERM)
|
|
416
|
+
print("hostbridge daemon process signaled")
|
|
417
|
+
return 0
|
|
418
|
+
print("hostbridge daemon is not running")
|
|
419
|
+
return 0
|