@neoline/hostbridge 1.4.3 → 2.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.
@@ -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
- DEFAULT_DAEMON_DIR = Path("~/.hostbridge")
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
- }
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 _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
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 _error(exc: Exception) -> dict[str, object]:
70
- return {"ok": False, "status": _status_for_error(exc), "error": str(exc), "error_code": _error_code(exc)}
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
- class HostBridgeDaemonHandler(StreamRequestHandler):
74
- """One JSON request per line, one JSON response per line over a Unix socket."""
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
- manager: SessionManager
77
- registry: HostRegistry
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
- for raw_line in self.rfile:
266
+ while raw_line := self.rfile.readline(MAX_V1_CONTROL_LINE_BYTES + 1):
267
+ request_id = "invalid"
81
268
  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")
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
- 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")
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
- 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
- )
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
- 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
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 make_handler(manager: SessionManager, registry: HostRegistry | None = None) -> type[HostBridgeDaemonHandler]:
255
- class Handler(HostBridgeDaemonHandler):
367
+ def make_v1_handler(dispatcher: HostBridgeV1Dispatcher) -> type[HostBridgeV1Handler]:
368
+ class Handler(HostBridgeV1Handler):
256
369
  pass
257
370
 
258
- Handler.manager = manager
259
- Handler.registry = registry or manager.hosts
371
+ Handler.dispatcher = dispatcher
260
372
  return Handler
261
373
 
262
374
 
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(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
- manager = SessionManager(
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
- manager.start_idle_reaper()
280
- return manager
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
- configured = path or os.environ.get("HOSTBRIDGE_DAEMON_DIR")
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
- configured = path or os.environ.get("HOSTBRIDGE_DAEMON_SOCKET")
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 daemon_dir() / "daemon.pid"
408
+ return runtime_paths().pid
304
409
 
305
410
 
306
411
  def log_file() -> Path:
307
- return daemon_dir() / "daemon.log"
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(*, socket_file: Path | str | None = None) -> int:
333
- manager = build_manager()
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(str(path), make_handler(manager))
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
- manager.close_all_sessions()
343
- manager.stop_idle_reaper()
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
- health = request_json("GET", "/health", timeout=1.0, socket_file=socket_file)
359
- return health.get("ok") is True and health.get("service") == "hostbridge-daemon"
360
- except Exception:
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 request_json(
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
- ) -> dict[str, object]:
372
- request = {"method": method.upper(), "path": path, "body": payload or {}}
373
- with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
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
- log_handle = log_file().open("a", encoding="utf-8")
411
- process = subprocess.Popen(
412
- [sys.executable, "-m", "server_control_mcp", "daemon", "start", "--foreground", "--socket", str(path)],
413
- stdin=subprocess.DEVNULL,
414
- stdout=log_handle,
415
- stderr=log_handle,
416
- start_new_session=True,
417
- env=os.environ.copy(),
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
- request_json("POST", "/shutdown", timeout=2.0, socket_file=path)
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