@neoline/hostbridge 0.2.2 → 1.1.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,420 @@
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
+ DEFAULT_MAX_TRANSFER_BYTES,
23
+ PolicyDeniedError,
24
+ RemoteCommandError,
25
+ SessionManager,
26
+ _new_token,
27
+ )
28
+ from .policy import build_audit_sink, load_policy
29
+
30
+ DEFAULT_DAEMON_DIR = Path("~/.hostbridge").expanduser()
31
+ DEFAULT_SOCKET_FILE = DEFAULT_DAEMON_DIR / "daemon.sock"
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
+ }
39
+
40
+
41
+ class ThreadingUnixRPCServer(ThreadingMixIn, UnixStreamServer):
42
+ daemon_threads = True
43
+ allow_reuse_address = True
44
+
45
+
46
+ def _ok(**payload: object) -> dict[str, object]:
47
+ return {"ok": True, **payload}
48
+
49
+
50
+ def _error_code(exc: Exception) -> str:
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
67
+
68
+
69
+ def _error(exc: Exception) -> dict[str, object]:
70
+ return {"ok": False, "status": _status_for_error(exc), "error": str(exc), "error_code": _error_code(exc)}
71
+
72
+
73
+ class HostBridgeDaemonHandler(StreamRequestHandler):
74
+ """One JSON request per line, one JSON response per line over a Unix socket."""
75
+
76
+ manager: SessionManager
77
+ registry: HostRegistry
78
+
79
+ def handle(self) -> None:
80
+ for raw_line in self.rfile:
81
+ try:
82
+ request = json.loads(raw_line.decode("utf-8"))
83
+ if not isinstance(request, dict):
84
+ raise ValueError("request must be a JSON object")
85
+ response = self.dispatch(request)
86
+ except Exception as exc:
87
+ response = _error(exc)
88
+ self.wfile.write(json.dumps(response, ensure_ascii=False).encode("utf-8") + b"\n")
89
+ self.wfile.flush()
90
+
91
+ def dispatch(self, request: dict[str, Any]) -> dict[str, object]:
92
+ method = str(request.get("method", "GET")).upper()
93
+ path = str(request.get("path", "/"))
94
+ body = request.get("body", {})
95
+ if body is None:
96
+ body = {}
97
+ if not isinstance(body, dict):
98
+ raise ValueError("request body must be a JSON object")
99
+ try:
100
+ return self._dispatch(method, path, body)
101
+ except Exception as exc:
102
+ return _error(exc)
103
+
104
+ def _dispatch(self, method: str, path: str, body: dict[str, Any]) -> dict[str, object]:
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
+ )
161
+ )
162
+ if len(parts) == 4 and parts[0] == "sessions" and parts[2] == "tasks" and method == "DELETE":
163
+ 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"))))
164
+ if len(parts) == 3 and parts[0] == "sessions" and parts[2] == "files" and method == "POST":
165
+ action = str(body.get("action", ""))
166
+ owner = _optional_str(body.get("owner_agent_id"))
167
+ token = body.get("token")
168
+ token_factory = (lambda: str(token)) if token else None
169
+ token_factory = token_factory or _new_token
170
+ if action == "write_text":
171
+ return _ok(
172
+ transfer=self.manager.file_write_text(
173
+ parts[1],
174
+ str(body.get("remote_path", "")),
175
+ str(body.get("content", "")),
176
+ mode=str(body.get("mode", "0644")),
177
+ max_bytes=int(body.get("max_bytes", DEFAULT_MAX_TRANSFER_BYTES)),
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
252
+
253
+
254
+ def make_handler(manager: SessionManager, registry: HostRegistry | None = None) -> type[HostBridgeDaemonHandler]:
255
+ class Handler(HostBridgeDaemonHandler):
256
+ pass
257
+
258
+ Handler.manager = manager
259
+ Handler.registry = registry or manager.hosts
260
+ return Handler
261
+
262
+
263
+ def _max_sessions_per_host_from_env() -> int:
264
+ raw = os.environ.get("HOSTBRIDGE_MAX_SESSIONS_PER_HOST", "0")
265
+ with suppress(ValueError):
266
+ return max(0, int(raw))
267
+ return 0
268
+
269
+
270
+ def build_manager() -> SessionManager:
271
+ registry = HostRegistry(load_hosts())
272
+ policy = load_policy()
273
+ manager = SessionManager(
274
+ registry,
275
+ policy=policy,
276
+ audit_sink=build_audit_sink(policy),
277
+ max_sessions_per_host=_max_sessions_per_host_from_env(),
278
+ )
279
+ manager.start_idle_reaper()
280
+ return manager
281
+
282
+
283
+ def socket_path(path: Path | str | None = None) -> Path:
284
+ configured = path or os.environ.get("HOSTBRIDGE_DAEMON_SOCKET") or DEFAULT_SOCKET_FILE
285
+ return Path(configured).expanduser()
286
+
287
+
288
+ def _prepare_socket_path(path: Path) -> None:
289
+ path.parent.mkdir(parents=True, exist_ok=True)
290
+ with suppress(OSError):
291
+ path.parent.chmod(0o700)
292
+ if path.exists():
293
+ if _socket_accepts_connections(path):
294
+ raise RuntimeError(f"hostbridge daemon already running at unix://{path}")
295
+ path.unlink()
296
+
297
+
298
+ def _socket_accepts_connections(path: Path) -> bool:
299
+ try:
300
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
301
+ client.settimeout(0.5)
302
+ client.connect(str(path))
303
+ return True
304
+ except OSError:
305
+ return False
306
+
307
+
308
+ def run_daemon(*, socket_file: Path | str | None = None) -> int:
309
+ manager = build_manager()
310
+ path = socket_path(socket_file)
311
+ _prepare_socket_path(path)
312
+ server = ThreadingUnixRPCServer(str(path), make_handler(manager))
313
+ PID_FILE.write_text(str(os.getpid()), encoding="utf-8")
314
+ try:
315
+ print(f"hostbridge daemon listening on unix://{path}", flush=True)
316
+ server.serve_forever()
317
+ finally:
318
+ manager.close_all_sessions()
319
+ manager.stop_idle_reaper()
320
+ server.server_close()
321
+ with suppress(FileNotFoundError):
322
+ path.unlink()
323
+ with suppress(FileNotFoundError):
324
+ PID_FILE.unlink()
325
+ return 0
326
+
327
+
328
+ def daemon_endpoint() -> str:
329
+ return f"unix://{socket_path()}"
330
+
331
+
332
+ def is_running(*, socket_file: Path | str | None = None) -> bool:
333
+ try:
334
+ health = request_json("GET", "/health", timeout=1.0, socket_file=socket_file)
335
+ return health.get("ok") is True and health.get("service") == "hostbridge-daemon"
336
+ except Exception:
337
+ return False
338
+
339
+
340
+ def request_json(
341
+ method: str,
342
+ path: str,
343
+ payload: dict[str, object] | None = None,
344
+ *,
345
+ timeout: float = 5.0,
346
+ socket_file: Path | str | None = None,
347
+ ) -> dict[str, object]:
348
+ request = {"method": method.upper(), "path": path, "body": payload or {}}
349
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
350
+ client.settimeout(timeout)
351
+ client.connect(str(socket_path(socket_file)))
352
+ client.sendall(json.dumps(request, ensure_ascii=False).encode("utf-8") + b"\n")
353
+ response = _recv_json_line(client)
354
+ if not isinstance(response, dict):
355
+ raise RuntimeError("daemon returned non-object JSON")
356
+ return response
357
+
358
+
359
+ def _recv_json_line(client: socket.socket) -> dict[str, object]:
360
+ chunks: list[bytes] = []
361
+ while True:
362
+ chunk = client.recv(65536)
363
+ if not chunk:
364
+ break
365
+ chunks.append(chunk)
366
+ if b"\n" in chunk:
367
+ break
368
+ line = b"".join(chunks).split(b"\n", 1)[0]
369
+ if not line:
370
+ return {}
371
+ decoded = json.loads(line.decode("utf-8"))
372
+ if not isinstance(decoded, dict):
373
+ raise RuntimeError("daemon returned non-object JSON")
374
+ return decoded
375
+
376
+
377
+ def start_background(*, socket_file: Path | str | None = None, quiet: bool = False) -> int:
378
+ path = socket_path(socket_file)
379
+ if is_running(socket_file=path):
380
+ if not quiet:
381
+ print(f"hostbridge daemon already running at unix://{path}")
382
+ return 0
383
+ path.parent.mkdir(parents=True, exist_ok=True)
384
+ with suppress(OSError):
385
+ path.parent.chmod(0o700)
386
+ log_handle = LOG_FILE.open("a", encoding="utf-8")
387
+ process = subprocess.Popen(
388
+ [sys.executable, "-m", "server_control_mcp", "daemon", "start", "--foreground", "--socket", str(path)],
389
+ stdin=subprocess.DEVNULL,
390
+ stdout=log_handle,
391
+ stderr=log_handle,
392
+ start_new_session=True,
393
+ env=os.environ.copy(),
394
+ )
395
+ PID_FILE.write_text(str(process.pid), encoding="utf-8")
396
+ deadline = time.time() + 5
397
+ while time.time() < deadline:
398
+ if is_running(socket_file=path):
399
+ if not quiet:
400
+ print(f"hostbridge daemon started at unix://{path} (pid {process.pid})")
401
+ return 0
402
+ time.sleep(0.1)
403
+ if not quiet:
404
+ print(f"hostbridge daemon failed to start; see {LOG_FILE}", file=sys.stderr)
405
+ return 1
406
+
407
+
408
+ def stop_background(*, socket_file: Path | str | None = None) -> int:
409
+ path = socket_path(socket_file)
410
+ if is_running(socket_file=path):
411
+ request_json("POST", "/shutdown", timeout=2.0, socket_file=path)
412
+ print("hostbridge daemon stopped")
413
+ return 0
414
+ if PID_FILE.exists():
415
+ with suppress(Exception):
416
+ os.kill(int(PID_FILE.read_text(encoding="utf-8").strip()), signal.SIGTERM)
417
+ print("hostbridge daemon process signaled")
418
+ return 0
419
+ print("hostbridge daemon is not running")
420
+ return 0