@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.
- package/README.md +24 -15
- package/bin/hostbridge.js +80 -9
- package/docs/ARCHITECTURE.md +62 -0
- package/examples/claude_desktop_config.json +1 -1
- package/examples/codex.config.toml +1 -1
- package/examples/hosts.example.json +1 -0
- package/package.json +2 -1
- package/pyproject.toml +5 -4
- package/src/server_control_mcp/__init__.py +60 -24
- package/src/server_control_mcp/async_lifecycle.py +41 -0
- package/src/server_control_mcp/cli.py +2 -2
- package/src/server_control_mcp/client.py +345 -238
- package/src/server_control_mcp/config.py +18 -6
- package/src/server_control_mcp/daemon.py +309 -412
- package/src/server_control_mcp/doctor.py +41 -5
- package/src/server_control_mcp/hosts.py +252 -24
- package/src/server_control_mcp/mock_ssh.py +97 -960
- package/src/server_control_mcp/mock_ssh_exec.py +299 -0
- package/src/server_control_mcp/mock_ssh_session.py +69 -0
- package/src/server_control_mcp/mock_ssh_sftp.py +604 -0
- package/src/server_control_mcp/mock_ssh_tunnel.py +289 -0
- package/src/server_control_mcp/mux_connection.py +326 -0
- package/src/server_control_mcp/mux_daemon.py +186 -0
- package/src/server_control_mcp/mux_protocol.py +279 -0
- package/src/server_control_mcp/mux_records.py +71 -0
- package/src/server_control_mcp/mux_rpc.py +1779 -0
- package/src/server_control_mcp/mux_service.py +506 -0
- package/src/server_control_mcp/mux_stream.py +283 -0
- package/src/server_control_mcp/mux_sync_client.py +224 -0
- package/src/server_control_mcp/policy.py +86 -14
- package/src/server_control_mcp/remote_agent_bundle.py +56 -0
- package/src/server_control_mcp/remote_mux_agent.py +769 -0
- package/src/server_control_mcp/remote_task_agent.py +102 -0
- package/src/server_control_mcp/runtime.py +3 -2
- package/src/server_control_mcp/runtime_services.py +862 -0
- package/src/server_control_mcp/secrets.py +6 -6
- package/src/server_control_mcp/secure_files.py +121 -0
- package/src/server_control_mcp/server.py +53 -20
- package/src/server_control_mcp/services.py +3 -469
- package/src/server_control_mcp/transports/__init__.py +4 -8
- package/src/server_control_mcp/transports/base.py +60 -38
- package/src/server_control_mcp/transports/ssh.py +315 -84
- package/src/server_control_mcp/tunnel_manager.py +1245 -0
- package/src/server_control_mcp/tunnel_native_ssh.py +454 -0
- package/src/server_control_mcp/tunnel_providers.py +20 -0
- package/src/server_control_mcp/tunnel_pty_agent.py +909 -0
- package/src/server_control_mcp/protocol.py +0 -362
- 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 .
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
174
|
-
"
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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
|
|
188
|
-
params: dict[str, object] = {"session_id": session_id
|
|
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
|
-
|
|
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] = {
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
"
|
|
235
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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 <=
|
|
400
|
-
raise ValueError(
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|