@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.
@@ -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