@neoline/hostbridge 2.0.3 → 2.0.5

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.
Files changed (48) hide show
  1. package/README.md +24 -15
  2. package/bin/hostbridge.js +80 -9
  3. package/docs/ARCHITECTURE.md +62 -0
  4. package/examples/claude_desktop_config.json +1 -1
  5. package/examples/codex.config.toml +1 -1
  6. package/examples/hosts.example.json +1 -0
  7. package/package.json +2 -1
  8. package/pyproject.toml +5 -4
  9. package/src/server_control_mcp/__init__.py +60 -24
  10. package/src/server_control_mcp/async_lifecycle.py +41 -0
  11. package/src/server_control_mcp/cli.py +2 -2
  12. package/src/server_control_mcp/client.py +345 -238
  13. package/src/server_control_mcp/config.py +18 -6
  14. package/src/server_control_mcp/daemon.py +309 -412
  15. package/src/server_control_mcp/doctor.py +41 -5
  16. package/src/server_control_mcp/hosts.py +252 -24
  17. package/src/server_control_mcp/mock_ssh.py +97 -960
  18. package/src/server_control_mcp/mock_ssh_exec.py +299 -0
  19. package/src/server_control_mcp/mock_ssh_session.py +69 -0
  20. package/src/server_control_mcp/mock_ssh_sftp.py +604 -0
  21. package/src/server_control_mcp/mock_ssh_tunnel.py +289 -0
  22. package/src/server_control_mcp/mux_connection.py +326 -0
  23. package/src/server_control_mcp/mux_daemon.py +186 -0
  24. package/src/server_control_mcp/mux_protocol.py +279 -0
  25. package/src/server_control_mcp/mux_records.py +71 -0
  26. package/src/server_control_mcp/mux_rpc.py +1779 -0
  27. package/src/server_control_mcp/mux_service.py +506 -0
  28. package/src/server_control_mcp/mux_stream.py +283 -0
  29. package/src/server_control_mcp/mux_sync_client.py +224 -0
  30. package/src/server_control_mcp/policy.py +86 -14
  31. package/src/server_control_mcp/remote_agent_bundle.py +56 -0
  32. package/src/server_control_mcp/remote_mux_agent.py +769 -0
  33. package/src/server_control_mcp/remote_task_agent.py +102 -0
  34. package/src/server_control_mcp/runtime.py +3 -2
  35. package/src/server_control_mcp/runtime_services.py +862 -0
  36. package/src/server_control_mcp/secrets.py +6 -6
  37. package/src/server_control_mcp/secure_files.py +121 -0
  38. package/src/server_control_mcp/server.py +53 -20
  39. package/src/server_control_mcp/services.py +3 -469
  40. package/src/server_control_mcp/transports/__init__.py +4 -8
  41. package/src/server_control_mcp/transports/base.py +60 -38
  42. package/src/server_control_mcp/transports/ssh.py +315 -84
  43. package/src/server_control_mcp/tunnel_manager.py +1245 -0
  44. package/src/server_control_mcp/tunnel_native_ssh.py +454 -0
  45. package/src/server_control_mcp/tunnel_providers.py +20 -0
  46. package/src/server_control_mcp/tunnel_pty_agent.py +909 -0
  47. package/src/server_control_mcp/protocol.py +0 -362
  48. package/src/server_control_mcp/transports/pty.py +0 -434
@@ -1,34 +1,24 @@
1
- """Typed synchronous client for the HostBridge daemon protocol."""
1
+ """Typed synchronous client for the HostBridge multiplexed daemon protocol."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import base64
6
- import binascii
7
5
  import hashlib
8
- import json
9
- import socket
10
6
  import threading
7
+ import time
11
8
  import uuid
12
- from collections.abc import Mapping
9
+ from collections.abc import Iterable, Mapping
10
+ from contextlib import suppress
13
11
  from dataclasses import dataclass
14
12
  from pathlib import Path
13
+ from types import TracebackType
15
14
 
16
- from .protocol import (
17
- MAX_FRAME_BYTES,
18
- BinaryFrame,
19
- ErrorInfo,
20
- FrameFlags,
21
- FrameSequenceValidator,
22
- ProtocolError,
23
- Request,
24
- Response,
25
- encode_frame,
26
- read_frame,
27
- )
15
+ from . import __version__
16
+ from .mux_protocol import MUX_PROTOCOL_VERSION
17
+ from .mux_records import MuxRecordChannel, MuxRecordDecoder, MuxRecordError
18
+ from .mux_rpc import MuxCapacityError, MuxConnectionClosed, MuxRemoteError
19
+ from .mux_sync_client import MuxSyncClient, MuxSyncStream
28
20
  from .runtime import runtime_paths
29
- from .transports.base import ShellReadResult, TransferResult
30
-
31
- MAX_CONTROL_LINE_BYTES = 4 * 1024 * 1024
21
+ from .transports.base import ShellReadResult, ShellTerminalResult, TransferResult
32
22
 
33
23
 
34
24
  def _required_text(value: object, field: str) -> str:
@@ -37,23 +27,6 @@ def _required_text(value: object, field: str) -> str:
37
27
  return value
38
28
 
39
29
 
40
- def _encode_inline_bytes(value: bytes | None) -> dict[str, str] | None:
41
- if value is None:
42
- return None
43
- if not isinstance(value, bytes):
44
- raise TypeError("stdin must be bytes or None")
45
- return {"encoding": "base64", "data": base64.b64encode(value).decode("ascii")}
46
-
47
-
48
- def _decode_inline_bytes(value: object, field: str) -> bytes:
49
- if not isinstance(value, dict) or value.get("encoding") != "base64" or not isinstance(value.get("data"), str):
50
- raise HostBridgeError("protocol_mismatch", f"response field {field} must contain base64 data")
51
- try:
52
- return base64.b64decode(value["data"], validate=True)
53
- except (binascii.Error, ValueError) as exc:
54
- raise HostBridgeError("protocol_mismatch", f"response field {field} contains invalid base64") from exc
55
-
56
-
57
30
  @dataclass(frozen=True, slots=True)
58
31
  class SessionInfo:
59
32
  session_id: str
@@ -80,21 +53,70 @@ class ExecResult:
80
53
  stderr: bytes
81
54
  timed_out: bool = False
82
55
 
83
- @classmethod
84
- def from_dict(cls, value: Mapping[str, object]) -> ExecResult:
85
- exit_code = value.get("exit_code")
86
- if exit_code is not None and (not isinstance(exit_code, int) or isinstance(exit_code, bool)):
87
- raise HostBridgeError("protocol_mismatch", "response field exit_code must be an integer or null")
88
- timed_out = value.get("timed_out", False)
89
- if not isinstance(timed_out, bool):
90
- raise HostBridgeError("protocol_mismatch", "response field timed_out must be a boolean")
91
- return cls(
92
- session_id=_required_text(value.get("session_id"), "session_id"),
93
- exit_code=exit_code,
94
- stdout=_decode_inline_bytes(value.get("stdout"), "stdout"),
95
- stderr=_decode_inline_bytes(value.get("stderr"), "stderr"),
96
- timed_out=timed_out,
97
- )
56
+
57
+ class HostBridgeShell:
58
+ def __init__(self, stream: MuxSyncStream) -> None:
59
+ self._stream = stream
60
+ self._state_lock = threading.Lock()
61
+ self._closed = False
62
+ self._input_closed = False
63
+ self._terminal: ShellTerminalResult | None = None
64
+
65
+ @property
66
+ def closed(self) -> bool:
67
+ with self._state_lock:
68
+ return self._closed
69
+
70
+ @property
71
+ def exit_code(self) -> int | None:
72
+ with self._state_lock:
73
+ return None if self._terminal is None else self._terminal.exit_code
74
+
75
+ @property
76
+ def exit_signal(self) -> str | None:
77
+ with self._state_lock:
78
+ return None if self._terminal is None else self._terminal.signal
79
+
80
+ def receive(self, max_bytes: int = 64 * 1024) -> bytes | None:
81
+ if max_bytes <= 0:
82
+ raise ValueError("max_bytes must be positive")
83
+ with self._state_lock:
84
+ if self._closed:
85
+ return None
86
+ chunk = self._stream.receive_unbounded(max_bytes)
87
+ if chunk is not None:
88
+ return chunk
89
+ result = self._stream.finish(timeout=1)
90
+ try:
91
+ terminal = ShellTerminalResult.from_mapping(result)
92
+ except ValueError as exc:
93
+ raise HostBridgeError("protocol_mismatch", f"invalid shell terminal result: {exc}") from exc
94
+ with self._state_lock:
95
+ self._terminal = terminal
96
+ self._closed = True
97
+ return None
98
+
99
+ def send(self, data: bytes) -> None:
100
+ if not isinstance(data, bytes):
101
+ raise TypeError("shell data must be bytes")
102
+ with self._state_lock:
103
+ if self._closed or self._input_closed:
104
+ raise HostBridgeError("connection_lost", "shell input is closed")
105
+ self._stream.send(data)
106
+
107
+ def send_eof(self) -> None:
108
+ with self._state_lock:
109
+ if self._closed or self._input_closed:
110
+ return
111
+ self._input_closed = True
112
+ self._stream.send_eof()
113
+
114
+ def close(self) -> None:
115
+ with self._state_lock:
116
+ if self._closed:
117
+ return
118
+ self._closed = True
119
+ self._stream.reset("shell output consumer closed", timeout=1)
98
120
 
99
121
 
100
122
  class HostBridgeError(RuntimeError):
@@ -111,10 +133,6 @@ class HostBridgeError(RuntimeError):
111
133
  self.retryable = retryable
112
134
  self.details = details or {}
113
135
 
114
- @classmethod
115
- def from_info(cls, error: ErrorInfo) -> HostBridgeError:
116
- return cls(error.code, error.message, retryable=error.retryable, details=error.details)
117
-
118
136
 
119
137
  class HostBridgeClient:
120
138
  def __init__(
@@ -127,6 +145,25 @@ class HostBridgeClient:
127
145
  raise ValueError("default_timeout must be positive")
128
146
  self.socket_path = runtime_paths(socket_path).socket
129
147
  self.default_timeout = float(default_timeout)
148
+ self._mux = MuxSyncClient(self.socket_path, default_timeout=self.default_timeout)
149
+
150
+ @property
151
+ def closed(self) -> bool:
152
+ return self._mux.closed
153
+
154
+ def close(self) -> None:
155
+ self._mux.close()
156
+
157
+ def __enter__(self) -> HostBridgeClient:
158
+ return self
159
+
160
+ def __exit__(
161
+ self,
162
+ exc_type: type[BaseException] | None,
163
+ exc: BaseException | None,
164
+ traceback: TracebackType | None,
165
+ ) -> None:
166
+ self.close()
130
167
 
131
168
  def request(
132
169
  self,
@@ -135,28 +172,56 @@ class HostBridgeClient:
135
172
  *,
136
173
  timeout: float | None = None,
137
174
  ) -> dict[str, object]:
138
- request = Request.new(method, dict(params))
139
- response = self._exchange(request, timeout=self.default_timeout if timeout is None else timeout)
140
- if not response.ok:
141
- if response.error is None:
142
- raise HostBridgeError("protocol_mismatch", "failed response omitted error details")
143
- raise HostBridgeError.from_info(response.error)
144
- return response.result
175
+ try:
176
+ return self._mux.request(method, params, timeout=timeout)
177
+ except BaseException as exc:
178
+ raise _translate_error(exc) from exc
145
179
 
146
180
  def hello(self) -> dict[str, object]:
147
- return self.request("system.hello", {})
181
+ result = self.request("system.hello", {})
182
+ if (
183
+ result.get("service") != "hostbridge"
184
+ or result.get("protocol") != MUX_PROTOCOL_VERSION
185
+ or result.get("version") != __version__
186
+ ):
187
+ raise HostBridgeError(
188
+ "protocol_mismatch",
189
+ f"HostBridge daemon is not compatible with runtime {__version__}",
190
+ )
191
+ return result
192
+
193
+ def list_hosts(self) -> list[dict[str, object]]:
194
+ hosts = self.request("host.list", {}).get("hosts")
195
+ if not isinstance(hosts, list) or any(not isinstance(host, dict) for host in hosts):
196
+ raise HostBridgeError("protocol_mismatch", "host list response must contain objects")
197
+ return hosts
198
+
199
+ def tunnel_health(self) -> dict[str, object]:
200
+ result = self.request("tunnel.health", {})
201
+ hosts = result.get("hosts")
202
+ if not isinstance(hosts, list) or any(not isinstance(host, dict) for host in hosts):
203
+ raise HostBridgeError("protocol_mismatch", "tunnel health response must contain host objects")
204
+ return result
205
+
206
+ def list_sessions(self) -> list[dict[str, object]]:
207
+ sessions = self.request("session.list", {}).get("sessions")
208
+ if not isinstance(sessions, list) or any(not isinstance(session, dict) for session in sessions):
209
+ raise HostBridgeError("protocol_mismatch", "session list response must contain objects")
210
+ return sessions
148
211
 
149
212
  def open_session(
150
213
  self,
151
214
  host_id: str,
152
215
  *,
153
216
  owner: str | None = None,
154
- timeout: float | None = None,
217
+ connect_timeout: float = 30,
155
218
  ) -> SessionInfo:
219
+ if connect_timeout <= 0:
220
+ raise ValueError("connect_timeout must be positive")
156
221
  result = self.request(
157
222
  "session.open",
158
- {"host_id": host_id, "owner": owner},
159
- timeout=timeout,
223
+ {"host_id": host_id, "owner": owner, "connect_timeout": connect_timeout},
224
+ timeout=connect_timeout + 5,
160
225
  )
161
226
  return SessionInfo.from_dict(result)
162
227
 
@@ -170,26 +235,76 @@ class HostBridgeClient:
170
235
  output_limit: int | None = None,
171
236
  owner: str | None = None,
172
237
  ) -> ExecResult:
173
- params: dict[str, object] = {
174
- "session_id": session_id,
175
- "command": command,
176
- "stdin": _encode_inline_bytes(stdin),
177
- }
238
+ if stdin is not None and not isinstance(stdin, bytes):
239
+ raise TypeError("stdin must be bytes or None")
240
+ chunks: Iterable[bytes] = () if stdin is None else (stdin,)
241
+ return self.exec_stream(
242
+ session_id,
243
+ command,
244
+ chunks,
245
+ timeout=timeout,
246
+ output_limit=output_limit,
247
+ owner=owner,
248
+ )
249
+
250
+ def exec_stream(
251
+ self,
252
+ session_id: str,
253
+ command: str,
254
+ stdin: Iterable[bytes],
255
+ *,
256
+ timeout: float | None = None,
257
+ output_limit: int | None = None,
258
+ owner: str | None = None,
259
+ cancel_event: threading.Event | None = None,
260
+ ) -> ExecResult:
261
+ params: dict[str, object] = {"session_id": session_id, "command": command}
178
262
  if timeout is not None:
179
263
  params["timeout"] = timeout
180
264
  if output_limit is not None:
181
265
  params["output_limit"] = output_limit
182
266
  if owner is not None:
183
267
  params["owner"] = owner
184
- result = self.request("exec.start", params, timeout=timeout)
185
- return ExecResult.from_dict(result)
268
+ deadline = _deadline(self.default_timeout if timeout is None else timeout)
269
+ stream = self._open_stream("exec.start", params, deadline)
270
+ decoder = MuxRecordDecoder()
271
+ stdout = bytearray()
272
+ stderr = bytearray()
273
+ try:
274
+ self._send_input(stream, stdin, deadline, cancel_event=cancel_event, label="exec stdin")
275
+ while chunk := stream.receive(timeout=_remaining(deadline)):
276
+ for record in decoder.feed(chunk):
277
+ target = stdout if record.channel is MuxRecordChannel.STDOUT else stderr
278
+ target.extend(record.payload)
279
+ if decoder.buffered_bytes:
280
+ raise HostBridgeError("protocol_mismatch", "exec output ended with an incomplete binary record")
281
+ result = stream.finish(timeout=_remaining(deadline))
282
+ except BaseException as exc:
283
+ self._reset_stream(stream, str(exc))
284
+ raise _translate_error(exc) from exc
285
+ exit_code = result.get("exit_code")
286
+ if exit_code is not None and (not isinstance(exit_code, int) or isinstance(exit_code, bool)):
287
+ raise HostBridgeError("protocol_mismatch", "response field exit_code must be an integer or null")
288
+ timed_out = result.get("timed_out", False)
289
+ if not isinstance(timed_out, bool):
290
+ raise HostBridgeError("protocol_mismatch", "response field timed_out must be a boolean")
291
+ return ExecResult(
292
+ _required_text(result.get("session_id"), "session_id"),
293
+ exit_code,
294
+ bytes(stdout),
295
+ bytes(stderr),
296
+ timed_out,
297
+ )
186
298
 
187
- def close_session(self, session_id: str, *, owner: str | None = None, force: bool = False) -> None:
188
- params: dict[str, object] = {"session_id": session_id, "force": force}
299
+ def close_session(self, session_id: str, *, owner: str | None = None) -> None:
300
+ params: dict[str, object] = {"session_id": session_id}
189
301
  if owner is not None:
190
302
  params["owner"] = owner
191
303
  self.request("session.close", params)
192
304
 
305
+ def release_mock_ssh_session(self, session_id: str) -> None:
306
+ self.request("session.release_mock_ssh", {"session_id": session_id})
307
+
193
308
  def write_text(
194
309
  self,
195
310
  session_id: str,
@@ -209,8 +324,7 @@ class HostBridgeClient:
209
324
  }
210
325
  if owner is not None:
211
326
  params["owner"] = owner
212
- result = self.request("file.write_text", params)
213
- return self._transfer_result(result)
327
+ return self._transfer_result(self.request("file.write_text", params))
214
328
 
215
329
  def read_text(
216
330
  self,
@@ -220,28 +334,55 @@ class HostBridgeClient:
220
334
  max_bytes: int = 4 * 1024 * 1024,
221
335
  owner: str | None = None,
222
336
  ) -> str:
223
- params: dict[str, object] = {"session_id": session_id, "remote_path": remote_path, "max_bytes": max_bytes}
337
+ params: dict[str, object] = {
338
+ "session_id": session_id,
339
+ "remote_path": remote_path,
340
+ "max_bytes": max_bytes,
341
+ }
224
342
  if owner is not None:
225
343
  params["owner"] = owner
226
- result = self.request("file.read_text", params)
227
- content = result.get("content")
344
+ content = self.request("file.read_text", params).get("content")
228
345
  if not isinstance(content, str):
229
346
  raise HostBridgeError("protocol_mismatch", "text response content must be a string")
230
347
  return content
231
348
 
232
349
  def shell_write(self, session_id: str, data: bytes, *, owner: str | None = None) -> int:
233
- params: dict[str, object] = {
234
- "session_id": session_id,
235
- "data": _encode_inline_bytes(data),
236
- }
350
+ if not isinstance(data, bytes):
351
+ raise TypeError("shell data must be bytes")
352
+ params: dict[str, object] = {"session_id": session_id, "max_bytes": max(1, len(data))}
237
353
  if owner is not None:
238
354
  params["owner"] = owner
239
- result = self.request("shell.write", params)
355
+ deadline = _deadline(self.default_timeout)
356
+ stream = self._open_stream("shell.write", params, deadline)
357
+ try:
358
+ self._send_input(stream, (data,), deadline, cancel_event=None, label="shell input")
359
+ while stream.receive(timeout=_remaining(deadline)) is not None:
360
+ pass
361
+ result = stream.finish(timeout=_remaining(deadline))
362
+ except BaseException as exc:
363
+ self._reset_stream(stream, str(exc))
364
+ raise _translate_error(exc) from exc
240
365
  written = result.get("written")
241
366
  if not isinstance(written, int) or isinstance(written, bool) or written < 0:
242
367
  raise HostBridgeError("protocol_mismatch", "shell write response contains invalid byte count")
243
368
  return written
244
369
 
370
+ def open_shell(
371
+ self,
372
+ session_id: str,
373
+ *,
374
+ owner: str | None = None,
375
+ max_bytes: int = 64 * 1024,
376
+ ) -> HostBridgeShell:
377
+ if max_bytes <= 0:
378
+ raise ValueError("max_bytes must be positive")
379
+ params: dict[str, object] = {"session_id": session_id, "max_bytes": max_bytes}
380
+ if owner is not None:
381
+ params["owner"] = owner
382
+ deadline = _deadline(self.default_timeout)
383
+ stream = self._open_stream("shell.attach", params, deadline)
384
+ return HostBridgeShell(stream)
385
+
245
386
  def shell_read(
246
387
  self,
247
388
  session_id: str,
@@ -250,18 +391,24 @@ class HostBridgeClient:
250
391
  max_bytes: int = 4096,
251
392
  owner: str | None = None,
252
393
  ) -> ShellReadResult:
253
- params: dict[str, object] = {
254
- "session_id": session_id,
255
- "timeout": timeout,
256
- "max_bytes": max_bytes,
257
- }
394
+ params: dict[str, object] = {"session_id": session_id, "timeout": timeout, "max_bytes": max_bytes}
258
395
  if owner is not None:
259
396
  params["owner"] = owner
260
- result = self.request("shell.read", params, timeout=max(self.default_timeout, timeout + 1))
397
+ deadline = _deadline(max(self.default_timeout, timeout + 1))
398
+ stream = self._open_stream("shell.read", params, deadline)
399
+ output = bytearray()
400
+ try:
401
+ stream.send_eof(timeout=_remaining(deadline))
402
+ while chunk := stream.receive(timeout=_remaining(deadline)):
403
+ output.extend(chunk)
404
+ result = stream.finish(timeout=_remaining(deadline))
405
+ except BaseException as exc:
406
+ self._reset_stream(stream, str(exc))
407
+ raise _translate_error(exc) from exc
261
408
  alive = result.get("alive")
262
409
  if not isinstance(alive, bool):
263
410
  raise HostBridgeError("protocol_mismatch", "shell read response contains invalid alive flag")
264
- return ShellReadResult(_decode_inline_bytes(result.get("data"), "data"), alive)
411
+ return ShellReadResult(bytes(output), alive)
265
412
 
266
413
  def upload_file(
267
414
  self,
@@ -274,54 +421,40 @@ class HostBridgeClient:
274
421
  timeout: float | None = None,
275
422
  cancel_event: threading.Event | None = None,
276
423
  owner: str | None = None,
424
+ max_bytes: int | None = None,
277
425
  ) -> TransferResult:
278
426
  self._validate_chunk_size(chunk_size)
427
+ self._validate_optional_byte_limit(max_bytes)
279
428
  source = Path(local_path)
280
429
  expected_size = source.stat().st_size
281
- stream_id = uuid.uuid4().hex
430
+ if max_bytes is not None and expected_size > max_bytes:
431
+ raise HostBridgeError("resource_limit", f"upload exceeds {max_bytes} bytes")
282
432
  params: dict[str, object] = {
283
433
  "session_id": session_id,
284
434
  "remote_path": remote_path,
285
- "stream_id": stream_id,
286
435
  "mode": mode,
287
436
  "expected_size": expected_size,
288
437
  }
289
438
  if owner is not None:
290
439
  params["owner"] = owner
291
- request = Request.new("transfer.upload", params)
440
+ deadline = _deadline(self.default_timeout if timeout is None else timeout)
441
+ stream = self._open_stream("transfer.upload", params, deadline)
292
442
  digest = hashlib.sha256()
293
- sequence = 0
294
- request_timeout = self.default_timeout if timeout is None else timeout
443
+
444
+ def chunks() -> Iterable[bytes]:
445
+ with source.open("rb") as handle:
446
+ while chunk := handle.read(chunk_size):
447
+ digest.update(chunk)
448
+ yield chunk
449
+
295
450
  try:
296
- with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
297
- connection.settimeout(request_timeout)
298
- connection.connect(str(self.socket_path))
299
- connection.sendall(request.to_json().encode("utf-8") + b"\n")
300
- with source.open("rb") as local_file:
301
- while True:
302
- if cancel_event is not None and cancel_event.is_set():
303
- payload = json.dumps(
304
- {"code": "cancelled", "message": "upload cancelled by client"},
305
- separators=(",", ":"),
306
- ).encode("utf-8")
307
- connection.sendall(
308
- encode_frame(BinaryFrame(stream_id, sequence, FrameFlags.CANCEL, payload))
309
- )
310
- break
311
- chunk = local_file.read(chunk_size)
312
- if not chunk:
313
- connection.sendall(encode_frame(BinaryFrame(stream_id, sequence, FrameFlags.EOF)))
314
- break
315
- digest.update(chunk)
316
- connection.sendall(encode_frame(BinaryFrame(stream_id, sequence, FrameFlags.DATA, chunk)))
317
- sequence += 1
318
- with connection.makefile("rb") as reader:
319
- response = self._read_response(reader, request)
320
- except TimeoutError as exc:
321
- raise HostBridgeError("timeout", "HostBridge upload timed out", retryable=True) from exc
322
- except OSError as exc:
323
- raise HostBridgeError("connection_lost", "HostBridge upload connection failed", retryable=True) from exc
324
- result = self._transfer_result(response.result)
451
+ self._send_input(stream, chunks(), deadline, cancel_event=cancel_event, label="upload")
452
+ while stream.receive(timeout=_remaining(deadline)) is not None:
453
+ pass
454
+ result = self._transfer_result(stream.finish(timeout=_remaining(deadline)))
455
+ except BaseException as exc:
456
+ self._reset_stream(stream, str(exc))
457
+ raise _translate_error(exc) from exc
325
458
  if result.bytes_transferred != expected_size or result.sha256 != digest.hexdigest():
326
459
  raise HostBridgeError("integrity_error", "HostBridge upload integrity check failed")
327
460
  return result
@@ -335,69 +468,82 @@ class HostBridgeClient:
335
468
  chunk_size: int = 256 * 1024,
336
469
  timeout: float | None = None,
337
470
  owner: str | None = None,
471
+ max_bytes: int | None = None,
338
472
  ) -> TransferResult:
339
473
  self._validate_chunk_size(chunk_size)
474
+ self._validate_optional_byte_limit(max_bytes)
340
475
  destination = Path(local_path)
341
476
  destination.parent.mkdir(parents=True, exist_ok=True)
342
477
  temporary = destination.with_name(f".{destination.name}.hostbridge-{uuid.uuid4().hex}.tmp")
343
- stream_id = uuid.uuid4().hex
344
478
  params: dict[str, object] = {
345
479
  "session_id": session_id,
346
480
  "remote_path": remote_path,
347
- "stream_id": stream_id,
348
481
  "chunk_size": chunk_size,
349
482
  }
350
483
  if owner is not None:
351
484
  params["owner"] = owner
352
- request = Request.new("transfer.download", params)
485
+ deadline = _deadline(self.default_timeout if timeout is None else timeout)
486
+ stream = self._open_stream("transfer.download", params, deadline)
353
487
  digest = hashlib.sha256()
354
488
  transferred = 0
355
- validator = FrameSequenceValidator()
356
- request_timeout = self.default_timeout if timeout is None else timeout
357
489
  try:
358
- with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
359
- connection.settimeout(request_timeout)
360
- connection.connect(str(self.socket_path))
361
- connection.sendall(request.to_json().encode("utf-8") + b"\n")
362
- with connection.makefile("rb") as reader:
363
- response = self._read_response(reader, request)
364
- if response.result.get("stream_id") != stream_id:
365
- raise HostBridgeError("protocol_mismatch", "download stream_id does not match")
366
- with temporary.open("wb") as local_file:
367
- while True:
368
- frame = read_frame(reader)
369
- validator.accept(frame)
370
- if frame.stream_id != stream_id:
371
- raise HostBridgeError("protocol_mismatch", "download frame stream_id does not match")
372
- if frame.flags & FrameFlags.CANCEL:
373
- self._raise_cancel_frame(frame)
374
- if frame.flags & FrameFlags.DATA:
375
- local_file.write(frame.payload)
376
- digest.update(frame.payload)
377
- transferred += len(frame.payload)
378
- if frame.flags & FrameFlags.EOF:
379
- metadata = self._frame_metadata(frame.payload)
380
- break
381
- except HostBridgeError:
382
- temporary.unlink(missing_ok=True)
383
- raise
384
- except TimeoutError as exc:
385
- temporary.unlink(missing_ok=True)
386
- raise HostBridgeError("timeout", "HostBridge download timed out", retryable=True) from exc
387
- except (OSError, ProtocolError) as exc:
388
- temporary.unlink(missing_ok=True)
389
- raise HostBridgeError("connection_lost", "HostBridge download connection failed", retryable=True) from exc
390
- expected = self._transfer_result(metadata)
391
- if expected.bytes_transferred != transferred or expected.sha256 != digest.hexdigest():
490
+ stream.send_eof(timeout=_remaining(deadline))
491
+ with temporary.open("wb") as handle:
492
+ while chunk := stream.receive(timeout=_remaining(deadline)):
493
+ transferred += len(chunk)
494
+ if max_bytes is not None and transferred > max_bytes:
495
+ raise HostBridgeError("resource_limit", f"download exceeds {max_bytes} bytes")
496
+ handle.write(chunk)
497
+ digest.update(chunk)
498
+ result = self._transfer_result(stream.finish(timeout=_remaining(deadline)))
499
+ if result.bytes_transferred != transferred or result.sha256 != digest.hexdigest():
500
+ raise HostBridgeError("integrity_error", "HostBridge download integrity check failed")
501
+ temporary.replace(destination)
502
+ return result
503
+ except BaseException as exc:
392
504
  temporary.unlink(missing_ok=True)
393
- raise HostBridgeError("integrity_error", "HostBridge download integrity check failed")
394
- temporary.replace(destination)
395
- return expected
505
+ self._reset_stream(stream, str(exc))
506
+ raise _translate_error(exc) from exc
507
+
508
+ def _open_stream(self, method: str, params: Mapping[str, object], deadline: float) -> MuxSyncStream:
509
+ try:
510
+ return self._mux.open_stream(method, params, timeout=_remaining(deadline))
511
+ except BaseException as exc:
512
+ raise _translate_error(exc) from exc
513
+
514
+ @staticmethod
515
+ def _send_input(
516
+ stream: MuxSyncStream,
517
+ chunks: Iterable[bytes],
518
+ deadline: float,
519
+ *,
520
+ cancel_event: threading.Event | None,
521
+ label: str,
522
+ ) -> None:
523
+ for chunk in chunks:
524
+ if cancel_event is not None and cancel_event.is_set():
525
+ raise HostBridgeError("cancelled", f"{label} cancelled")
526
+ if not isinstance(chunk, bytes):
527
+ raise TypeError(f"{label} chunks must be bytes")
528
+ stream.send(chunk, timeout=_remaining(deadline))
529
+ if cancel_event is not None and cancel_event.is_set():
530
+ raise HostBridgeError("cancelled", f"{label} cancelled")
531
+ stream.send_eof(timeout=_remaining(deadline))
532
+
533
+ @staticmethod
534
+ def _reset_stream(stream: MuxSyncStream, message: str) -> None:
535
+ with suppress(BaseException):
536
+ stream.reset(message or "client operation failed", timeout=1)
396
537
 
397
538
  @staticmethod
398
539
  def _validate_chunk_size(chunk_size: int) -> None:
399
- if not isinstance(chunk_size, int) or isinstance(chunk_size, bool) or not 4096 <= chunk_size <= MAX_FRAME_BYTES:
400
- raise ValueError(f"chunk_size must be between 4096 and {MAX_FRAME_BYTES} bytes")
540
+ if not isinstance(chunk_size, int) or isinstance(chunk_size, bool) or not 4096 <= chunk_size <= 1024 * 1024:
541
+ raise ValueError("chunk_size must be between 4096 and 1048576 bytes")
542
+
543
+ @staticmethod
544
+ def _validate_optional_byte_limit(max_bytes: int | None) -> None:
545
+ if max_bytes is not None and (not isinstance(max_bytes, int) or isinstance(max_bytes, bool) or max_bytes <= 0):
546
+ raise ValueError("max_bytes must be a positive integer or None")
401
547
 
402
548
  @staticmethod
403
549
  def _transfer_result(value: Mapping[str, object]) -> TransferResult:
@@ -409,70 +555,31 @@ class HostBridgeClient:
409
555
  raise HostBridgeError("protocol_mismatch", "transfer response contains invalid SHA-256")
410
556
  return TransferResult(byte_count, sha256.lower())
411
557
 
412
- def _read_response(self, reader, request: Request) -> Response:
413
- raw_response = reader.readline(MAX_CONTROL_LINE_BYTES + 1)
414
- if not raw_response:
415
- raise HostBridgeError("connection_lost", "HostBridge daemon closed before responding", retryable=True)
416
- if len(raw_response) > MAX_CONTROL_LINE_BYTES:
417
- raise HostBridgeError("protocol_mismatch", "HostBridge daemon response exceeds the control limit")
418
- try:
419
- response = Response.from_json(raw_response.decode("utf-8"))
420
- except (ProtocolError, UnicodeDecodeError) as exc:
421
- raise HostBridgeError("protocol_mismatch", "HostBridge daemon returned an invalid response") from exc
422
- if response.request_id != request.request_id:
423
- raise HostBridgeError("protocol_mismatch", "HostBridge daemon response request_id does not match")
424
- if not response.ok:
425
- if response.error is None:
426
- raise HostBridgeError("protocol_mismatch", "failed response omitted error details")
427
- raise HostBridgeError.from_info(response.error)
428
- return response
429
-
430
- @staticmethod
431
- def _frame_metadata(payload: bytes) -> dict[str, object]:
432
- try:
433
- value = json.loads(payload.decode("utf-8"))
434
- except (UnicodeDecodeError, json.JSONDecodeError) as exc:
435
- raise HostBridgeError("protocol_mismatch", "download EOF contains invalid metadata") from exc
436
- if not isinstance(value, dict):
437
- raise HostBridgeError("protocol_mismatch", "download EOF metadata must be an object")
438
- return value
439
-
440
- @staticmethod
441
- def _raise_cancel_frame(frame: BinaryFrame) -> None:
442
- metadata = HostBridgeClient._frame_metadata(frame.payload)
443
- code = metadata.get("code")
444
- message = metadata.get("message")
445
- raise HostBridgeError(
446
- code if isinstance(code, str) else "cancelled",
447
- message if isinstance(message, str) else "HostBridge transfer cancelled",
448
- )
449
-
450
- def _exchange(self, request: Request, *, timeout: float) -> Response:
451
- if timeout <= 0:
452
- raise ValueError("timeout must be positive")
453
- try:
454
- with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
455
- connection.settimeout(timeout)
456
- connection.connect(str(self.socket_path))
457
- connection.sendall(request.to_json().encode("utf-8") + b"\n")
458
- with connection.makefile("rb") as reader:
459
- raw_response = reader.readline(MAX_CONTROL_LINE_BYTES + 1)
460
- except TimeoutError as exc:
461
- raise HostBridgeError("timeout", "HostBridge daemon request timed out", retryable=True) from exc
462
- except OSError as exc:
463
- raise HostBridgeError("connection_lost", "HostBridge daemon connection failed", retryable=True) from exc
464
- if not raw_response:
465
- raise HostBridgeError("connection_lost", "HostBridge daemon closed before responding", retryable=True)
466
- if len(raw_response) > MAX_CONTROL_LINE_BYTES:
467
- raise HostBridgeError("protocol_mismatch", "HostBridge daemon response exceeds the control limit")
468
- return self._read_response_from_bytes(raw_response, request)
469
558
 
470
- @staticmethod
471
- def _read_response_from_bytes(raw_response: bytes, request: Request) -> Response:
472
- try:
473
- response = Response.from_json(raw_response.decode("utf-8"))
474
- except (ProtocolError, UnicodeDecodeError) as exc:
475
- raise HostBridgeError("protocol_mismatch", "HostBridge daemon returned an invalid response") from exc
476
- if response.request_id != request.request_id:
477
- raise HostBridgeError("protocol_mismatch", "HostBridge daemon response request_id does not match")
478
- return response
559
+ def _deadline(timeout: float) -> float:
560
+ if timeout <= 0:
561
+ raise ValueError("timeout must be positive")
562
+ return time.monotonic() + timeout
563
+
564
+
565
+ def _remaining(deadline: float) -> float:
566
+ remaining = deadline - time.monotonic()
567
+ if remaining <= 0:
568
+ raise HostBridgeError("timeout", "HostBridge operation timed out", retryable=True)
569
+ return remaining
570
+
571
+
572
+ def _translate_error(error: BaseException) -> BaseException:
573
+ if isinstance(error, MuxRecordError):
574
+ return HostBridgeError("protocol_mismatch", str(error))
575
+ if isinstance(error, HostBridgeError | TypeError | ValueError):
576
+ return error
577
+ if isinstance(error, MuxRemoteError):
578
+ return HostBridgeError(error.code, str(error), retryable=error.retryable, details=error.details)
579
+ if isinstance(error, MuxCapacityError):
580
+ return HostBridgeError("busy", str(error), retryable=True)
581
+ if isinstance(error, MuxConnectionClosed | RuntimeError):
582
+ return HostBridgeError("connection_lost", str(error), retryable=True)
583
+ if isinstance(error, TimeoutError):
584
+ return HostBridgeError("timeout", str(error), retryable=True)
585
+ return error