@neoline/hostbridge 1.4.4 → 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.
- package/README.md +24 -137
- package/package.json +1 -1
- package/pyproject.toml +2 -1
- package/src/server_control_mcp/__init__.py +4 -5
- package/src/server_control_mcp/cli.py +229 -1231
- package/src/server_control_mcp/client.py +468 -0
- package/src/server_control_mcp/config.py +139 -0
- package/src/server_control_mcp/daemon.py +387 -306
- package/src/server_control_mcp/doctor.py +108 -0
- package/src/server_control_mcp/hosts.py +52 -7
- package/src/server_control_mcp/mock_ssh.py +177 -87
- package/src/server_control_mcp/policy.py +2 -0
- package/src/server_control_mcp/protocol.py +362 -0
- package/src/server_control_mcp/runtime.py +40 -0
- package/src/server_control_mcp/server.py +132 -188
- package/src/server_control_mcp/services.py +508 -0
- package/src/server_control_mcp/transports/__init__.py +17 -0
- package/src/server_control_mcp/transports/base.py +78 -0
- package/src/server_control_mcp/transports/pty.py +405 -0
- package/src/server_control_mcp/transports/ssh.py +195 -0
- package/src/server_control_mcp/manager.py +0 -925
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
"""Typed synchronous client for the HostBridge daemon protocol."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import binascii
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import socket
|
|
10
|
+
import threading
|
|
11
|
+
import uuid
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
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
|
+
)
|
|
28
|
+
from .runtime import runtime_paths
|
|
29
|
+
from .transports.base import ShellReadResult, TransferResult
|
|
30
|
+
|
|
31
|
+
MAX_CONTROL_LINE_BYTES = 4 * 1024 * 1024
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _required_text(value: object, field: str) -> str:
|
|
35
|
+
if not isinstance(value, str) or not value.strip():
|
|
36
|
+
raise HostBridgeError("protocol_mismatch", f"response field {field} must be a non-empty string")
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
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
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class SessionInfo:
|
|
59
|
+
session_id: str
|
|
60
|
+
host_id: str
|
|
61
|
+
owner: str | None = None
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_dict(cls, value: Mapping[str, object]) -> SessionInfo:
|
|
65
|
+
owner = value.get("owner")
|
|
66
|
+
if owner is not None and not isinstance(owner, str):
|
|
67
|
+
raise HostBridgeError("protocol_mismatch", "response field owner must be a string or null")
|
|
68
|
+
return cls(
|
|
69
|
+
session_id=_required_text(value.get("session_id"), "session_id"),
|
|
70
|
+
host_id=_required_text(value.get("host_id"), "host_id"),
|
|
71
|
+
owner=owner,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True, slots=True)
|
|
76
|
+
class ExecResult:
|
|
77
|
+
session_id: str
|
|
78
|
+
exit_code: int | None
|
|
79
|
+
stdout: bytes
|
|
80
|
+
stderr: bytes
|
|
81
|
+
timed_out: bool = False
|
|
82
|
+
|
|
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
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class HostBridgeError(RuntimeError):
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
code: str,
|
|
104
|
+
message: str,
|
|
105
|
+
*,
|
|
106
|
+
retryable: bool = False,
|
|
107
|
+
details: dict[str, object] | None = None,
|
|
108
|
+
) -> None:
|
|
109
|
+
super().__init__(message)
|
|
110
|
+
self.code = code
|
|
111
|
+
self.retryable = retryable
|
|
112
|
+
self.details = details or {}
|
|
113
|
+
|
|
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
|
+
|
|
119
|
+
class HostBridgeClient:
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
socket_path: Path | str | None = None,
|
|
123
|
+
*,
|
|
124
|
+
default_timeout: float = 30.0,
|
|
125
|
+
) -> None:
|
|
126
|
+
if default_timeout <= 0:
|
|
127
|
+
raise ValueError("default_timeout must be positive")
|
|
128
|
+
self.socket_path = runtime_paths(socket_path).socket
|
|
129
|
+
self.default_timeout = float(default_timeout)
|
|
130
|
+
|
|
131
|
+
def request(
|
|
132
|
+
self,
|
|
133
|
+
method: str,
|
|
134
|
+
params: Mapping[str, object],
|
|
135
|
+
*,
|
|
136
|
+
timeout: float | None = None,
|
|
137
|
+
) -> 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
|
|
145
|
+
|
|
146
|
+
def hello(self) -> dict[str, object]:
|
|
147
|
+
return self.request("system.hello", {})
|
|
148
|
+
|
|
149
|
+
def open_session(self, host_id: str, *, owner: str | None = None) -> SessionInfo:
|
|
150
|
+
result = self.request("session.open", {"host_id": host_id, "owner": owner})
|
|
151
|
+
return SessionInfo.from_dict(result)
|
|
152
|
+
|
|
153
|
+
def exec(
|
|
154
|
+
self,
|
|
155
|
+
session_id: str,
|
|
156
|
+
command: str,
|
|
157
|
+
*,
|
|
158
|
+
stdin: bytes | None = None,
|
|
159
|
+
timeout: float | None = None,
|
|
160
|
+
output_limit: int | None = None,
|
|
161
|
+
owner: str | None = None,
|
|
162
|
+
) -> ExecResult:
|
|
163
|
+
params: dict[str, object] = {
|
|
164
|
+
"session_id": session_id,
|
|
165
|
+
"command": command,
|
|
166
|
+
"stdin": _encode_inline_bytes(stdin),
|
|
167
|
+
}
|
|
168
|
+
if timeout is not None:
|
|
169
|
+
params["timeout"] = timeout
|
|
170
|
+
if output_limit is not None:
|
|
171
|
+
params["output_limit"] = output_limit
|
|
172
|
+
if owner is not None:
|
|
173
|
+
params["owner"] = owner
|
|
174
|
+
result = self.request("exec.start", params, timeout=timeout)
|
|
175
|
+
return ExecResult.from_dict(result)
|
|
176
|
+
|
|
177
|
+
def close_session(self, session_id: str, *, owner: str | None = None, force: bool = False) -> None:
|
|
178
|
+
params: dict[str, object] = {"session_id": session_id, "force": force}
|
|
179
|
+
if owner is not None:
|
|
180
|
+
params["owner"] = owner
|
|
181
|
+
self.request("session.close", params)
|
|
182
|
+
|
|
183
|
+
def write_text(
|
|
184
|
+
self,
|
|
185
|
+
session_id: str,
|
|
186
|
+
remote_path: str,
|
|
187
|
+
content: str,
|
|
188
|
+
*,
|
|
189
|
+
mode: int = 0o644,
|
|
190
|
+
max_bytes: int = 4 * 1024 * 1024,
|
|
191
|
+
owner: str | None = None,
|
|
192
|
+
) -> TransferResult:
|
|
193
|
+
params: dict[str, object] = {
|
|
194
|
+
"session_id": session_id,
|
|
195
|
+
"remote_path": remote_path,
|
|
196
|
+
"content": content,
|
|
197
|
+
"mode": mode,
|
|
198
|
+
"max_bytes": max_bytes,
|
|
199
|
+
}
|
|
200
|
+
if owner is not None:
|
|
201
|
+
params["owner"] = owner
|
|
202
|
+
result = self.request("file.write_text", params)
|
|
203
|
+
return self._transfer_result(result)
|
|
204
|
+
|
|
205
|
+
def read_text(
|
|
206
|
+
self,
|
|
207
|
+
session_id: str,
|
|
208
|
+
remote_path: str,
|
|
209
|
+
*,
|
|
210
|
+
max_bytes: int = 4 * 1024 * 1024,
|
|
211
|
+
owner: str | None = None,
|
|
212
|
+
) -> str:
|
|
213
|
+
params: dict[str, object] = {"session_id": session_id, "remote_path": remote_path, "max_bytes": max_bytes}
|
|
214
|
+
if owner is not None:
|
|
215
|
+
params["owner"] = owner
|
|
216
|
+
result = self.request("file.read_text", params)
|
|
217
|
+
content = result.get("content")
|
|
218
|
+
if not isinstance(content, str):
|
|
219
|
+
raise HostBridgeError("protocol_mismatch", "text response content must be a string")
|
|
220
|
+
return content
|
|
221
|
+
|
|
222
|
+
def shell_write(self, session_id: str, data: bytes, *, owner: str | None = None) -> int:
|
|
223
|
+
params: dict[str, object] = {
|
|
224
|
+
"session_id": session_id,
|
|
225
|
+
"data": _encode_inline_bytes(data),
|
|
226
|
+
}
|
|
227
|
+
if owner is not None:
|
|
228
|
+
params["owner"] = owner
|
|
229
|
+
result = self.request("shell.write", params)
|
|
230
|
+
written = result.get("written")
|
|
231
|
+
if not isinstance(written, int) or isinstance(written, bool) or written < 0:
|
|
232
|
+
raise HostBridgeError("protocol_mismatch", "shell write response contains invalid byte count")
|
|
233
|
+
return written
|
|
234
|
+
|
|
235
|
+
def shell_read(
|
|
236
|
+
self,
|
|
237
|
+
session_id: str,
|
|
238
|
+
*,
|
|
239
|
+
timeout: float = 0.2,
|
|
240
|
+
max_bytes: int = 4096,
|
|
241
|
+
owner: str | None = None,
|
|
242
|
+
) -> ShellReadResult:
|
|
243
|
+
params: dict[str, object] = {
|
|
244
|
+
"session_id": session_id,
|
|
245
|
+
"timeout": timeout,
|
|
246
|
+
"max_bytes": max_bytes,
|
|
247
|
+
}
|
|
248
|
+
if owner is not None:
|
|
249
|
+
params["owner"] = owner
|
|
250
|
+
result = self.request("shell.read", params, timeout=max(self.default_timeout, timeout + 1))
|
|
251
|
+
alive = result.get("alive")
|
|
252
|
+
if not isinstance(alive, bool):
|
|
253
|
+
raise HostBridgeError("protocol_mismatch", "shell read response contains invalid alive flag")
|
|
254
|
+
return ShellReadResult(_decode_inline_bytes(result.get("data"), "data"), alive)
|
|
255
|
+
|
|
256
|
+
def upload_file(
|
|
257
|
+
self,
|
|
258
|
+
session_id: str,
|
|
259
|
+
local_path: Path | str,
|
|
260
|
+
remote_path: str,
|
|
261
|
+
*,
|
|
262
|
+
chunk_size: int = 256 * 1024,
|
|
263
|
+
mode: int = 0o644,
|
|
264
|
+
timeout: float | None = None,
|
|
265
|
+
cancel_event: threading.Event | None = None,
|
|
266
|
+
owner: str | None = None,
|
|
267
|
+
) -> TransferResult:
|
|
268
|
+
self._validate_chunk_size(chunk_size)
|
|
269
|
+
source = Path(local_path)
|
|
270
|
+
expected_size = source.stat().st_size
|
|
271
|
+
stream_id = uuid.uuid4().hex
|
|
272
|
+
params: dict[str, object] = {
|
|
273
|
+
"session_id": session_id,
|
|
274
|
+
"remote_path": remote_path,
|
|
275
|
+
"stream_id": stream_id,
|
|
276
|
+
"mode": mode,
|
|
277
|
+
"expected_size": expected_size,
|
|
278
|
+
}
|
|
279
|
+
if owner is not None:
|
|
280
|
+
params["owner"] = owner
|
|
281
|
+
request = Request.new("transfer.upload", params)
|
|
282
|
+
digest = hashlib.sha256()
|
|
283
|
+
sequence = 0
|
|
284
|
+
request_timeout = self.default_timeout if timeout is None else timeout
|
|
285
|
+
try:
|
|
286
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
|
287
|
+
connection.settimeout(request_timeout)
|
|
288
|
+
connection.connect(str(self.socket_path))
|
|
289
|
+
connection.sendall(request.to_json().encode("utf-8") + b"\n")
|
|
290
|
+
with source.open("rb") as local_file:
|
|
291
|
+
while True:
|
|
292
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
293
|
+
payload = json.dumps(
|
|
294
|
+
{"code": "cancelled", "message": "upload cancelled by client"},
|
|
295
|
+
separators=(",", ":"),
|
|
296
|
+
).encode("utf-8")
|
|
297
|
+
connection.sendall(
|
|
298
|
+
encode_frame(BinaryFrame(stream_id, sequence, FrameFlags.CANCEL, payload))
|
|
299
|
+
)
|
|
300
|
+
break
|
|
301
|
+
chunk = local_file.read(chunk_size)
|
|
302
|
+
if not chunk:
|
|
303
|
+
connection.sendall(encode_frame(BinaryFrame(stream_id, sequence, FrameFlags.EOF)))
|
|
304
|
+
break
|
|
305
|
+
digest.update(chunk)
|
|
306
|
+
connection.sendall(encode_frame(BinaryFrame(stream_id, sequence, FrameFlags.DATA, chunk)))
|
|
307
|
+
sequence += 1
|
|
308
|
+
with connection.makefile("rb") as reader:
|
|
309
|
+
response = self._read_response(reader, request)
|
|
310
|
+
except TimeoutError as exc:
|
|
311
|
+
raise HostBridgeError("timeout", "HostBridge upload timed out", retryable=True) from exc
|
|
312
|
+
except OSError as exc:
|
|
313
|
+
raise HostBridgeError("connection_lost", "HostBridge upload connection failed", retryable=True) from exc
|
|
314
|
+
result = self._transfer_result(response.result)
|
|
315
|
+
if result.bytes_transferred != expected_size or result.sha256 != digest.hexdigest():
|
|
316
|
+
raise HostBridgeError("integrity_error", "HostBridge upload integrity check failed")
|
|
317
|
+
return result
|
|
318
|
+
|
|
319
|
+
def download_file(
|
|
320
|
+
self,
|
|
321
|
+
session_id: str,
|
|
322
|
+
remote_path: str,
|
|
323
|
+
local_path: Path | str,
|
|
324
|
+
*,
|
|
325
|
+
chunk_size: int = 256 * 1024,
|
|
326
|
+
timeout: float | None = None,
|
|
327
|
+
owner: str | None = None,
|
|
328
|
+
) -> TransferResult:
|
|
329
|
+
self._validate_chunk_size(chunk_size)
|
|
330
|
+
destination = Path(local_path)
|
|
331
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
332
|
+
temporary = destination.with_name(f".{destination.name}.hostbridge-{uuid.uuid4().hex}.tmp")
|
|
333
|
+
stream_id = uuid.uuid4().hex
|
|
334
|
+
params: dict[str, object] = {
|
|
335
|
+
"session_id": session_id,
|
|
336
|
+
"remote_path": remote_path,
|
|
337
|
+
"stream_id": stream_id,
|
|
338
|
+
"chunk_size": chunk_size,
|
|
339
|
+
}
|
|
340
|
+
if owner is not None:
|
|
341
|
+
params["owner"] = owner
|
|
342
|
+
request = Request.new("transfer.download", params)
|
|
343
|
+
digest = hashlib.sha256()
|
|
344
|
+
transferred = 0
|
|
345
|
+
validator = FrameSequenceValidator()
|
|
346
|
+
request_timeout = self.default_timeout if timeout is None else timeout
|
|
347
|
+
try:
|
|
348
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
|
349
|
+
connection.settimeout(request_timeout)
|
|
350
|
+
connection.connect(str(self.socket_path))
|
|
351
|
+
connection.sendall(request.to_json().encode("utf-8") + b"\n")
|
|
352
|
+
with connection.makefile("rb") as reader:
|
|
353
|
+
response = self._read_response(reader, request)
|
|
354
|
+
if response.result.get("stream_id") != stream_id:
|
|
355
|
+
raise HostBridgeError("protocol_mismatch", "download stream_id does not match")
|
|
356
|
+
with temporary.open("wb") as local_file:
|
|
357
|
+
while True:
|
|
358
|
+
frame = read_frame(reader)
|
|
359
|
+
validator.accept(frame)
|
|
360
|
+
if frame.stream_id != stream_id:
|
|
361
|
+
raise HostBridgeError("protocol_mismatch", "download frame stream_id does not match")
|
|
362
|
+
if frame.flags & FrameFlags.CANCEL:
|
|
363
|
+
self._raise_cancel_frame(frame)
|
|
364
|
+
if frame.flags & FrameFlags.DATA:
|
|
365
|
+
local_file.write(frame.payload)
|
|
366
|
+
digest.update(frame.payload)
|
|
367
|
+
transferred += len(frame.payload)
|
|
368
|
+
if frame.flags & FrameFlags.EOF:
|
|
369
|
+
metadata = self._frame_metadata(frame.payload)
|
|
370
|
+
break
|
|
371
|
+
except HostBridgeError:
|
|
372
|
+
temporary.unlink(missing_ok=True)
|
|
373
|
+
raise
|
|
374
|
+
except TimeoutError as exc:
|
|
375
|
+
temporary.unlink(missing_ok=True)
|
|
376
|
+
raise HostBridgeError("timeout", "HostBridge download timed out", retryable=True) from exc
|
|
377
|
+
except (OSError, ProtocolError) as exc:
|
|
378
|
+
temporary.unlink(missing_ok=True)
|
|
379
|
+
raise HostBridgeError("connection_lost", "HostBridge download connection failed", retryable=True) from exc
|
|
380
|
+
expected = self._transfer_result(metadata)
|
|
381
|
+
if expected.bytes_transferred != transferred or expected.sha256 != digest.hexdigest():
|
|
382
|
+
temporary.unlink(missing_ok=True)
|
|
383
|
+
raise HostBridgeError("integrity_error", "HostBridge download integrity check failed")
|
|
384
|
+
temporary.replace(destination)
|
|
385
|
+
return expected
|
|
386
|
+
|
|
387
|
+
@staticmethod
|
|
388
|
+
def _validate_chunk_size(chunk_size: int) -> None:
|
|
389
|
+
if not isinstance(chunk_size, int) or isinstance(chunk_size, bool) or not 4096 <= chunk_size <= MAX_FRAME_BYTES:
|
|
390
|
+
raise ValueError(f"chunk_size must be between 4096 and {MAX_FRAME_BYTES} bytes")
|
|
391
|
+
|
|
392
|
+
@staticmethod
|
|
393
|
+
def _transfer_result(value: Mapping[str, object]) -> TransferResult:
|
|
394
|
+
byte_count = value.get("bytes_transferred")
|
|
395
|
+
sha256 = value.get("sha256")
|
|
396
|
+
if not isinstance(byte_count, int) or isinstance(byte_count, bool) or byte_count < 0:
|
|
397
|
+
raise HostBridgeError("protocol_mismatch", "transfer response contains invalid byte count")
|
|
398
|
+
if not isinstance(sha256, str) or len(sha256) != 64:
|
|
399
|
+
raise HostBridgeError("protocol_mismatch", "transfer response contains invalid SHA-256")
|
|
400
|
+
return TransferResult(byte_count, sha256.lower())
|
|
401
|
+
|
|
402
|
+
def _read_response(self, reader, request: Request) -> Response:
|
|
403
|
+
raw_response = reader.readline(MAX_CONTROL_LINE_BYTES + 1)
|
|
404
|
+
if not raw_response:
|
|
405
|
+
raise HostBridgeError("connection_lost", "HostBridge daemon closed before responding", retryable=True)
|
|
406
|
+
if len(raw_response) > MAX_CONTROL_LINE_BYTES:
|
|
407
|
+
raise HostBridgeError("protocol_mismatch", "HostBridge daemon response exceeds the control limit")
|
|
408
|
+
try:
|
|
409
|
+
response = Response.from_json(raw_response.decode("utf-8"))
|
|
410
|
+
except (ProtocolError, UnicodeDecodeError) as exc:
|
|
411
|
+
raise HostBridgeError("protocol_mismatch", "HostBridge daemon returned an invalid response") from exc
|
|
412
|
+
if response.request_id != request.request_id:
|
|
413
|
+
raise HostBridgeError("protocol_mismatch", "HostBridge daemon response request_id does not match")
|
|
414
|
+
if not response.ok:
|
|
415
|
+
if response.error is None:
|
|
416
|
+
raise HostBridgeError("protocol_mismatch", "failed response omitted error details")
|
|
417
|
+
raise HostBridgeError.from_info(response.error)
|
|
418
|
+
return response
|
|
419
|
+
|
|
420
|
+
@staticmethod
|
|
421
|
+
def _frame_metadata(payload: bytes) -> dict[str, object]:
|
|
422
|
+
try:
|
|
423
|
+
value = json.loads(payload.decode("utf-8"))
|
|
424
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
425
|
+
raise HostBridgeError("protocol_mismatch", "download EOF contains invalid metadata") from exc
|
|
426
|
+
if not isinstance(value, dict):
|
|
427
|
+
raise HostBridgeError("protocol_mismatch", "download EOF metadata must be an object")
|
|
428
|
+
return value
|
|
429
|
+
|
|
430
|
+
@staticmethod
|
|
431
|
+
def _raise_cancel_frame(frame: BinaryFrame) -> None:
|
|
432
|
+
metadata = HostBridgeClient._frame_metadata(frame.payload)
|
|
433
|
+
code = metadata.get("code")
|
|
434
|
+
message = metadata.get("message")
|
|
435
|
+
raise HostBridgeError(
|
|
436
|
+
code if isinstance(code, str) else "cancelled",
|
|
437
|
+
message if isinstance(message, str) else "HostBridge transfer cancelled",
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
def _exchange(self, request: Request, *, timeout: float) -> Response:
|
|
441
|
+
if timeout <= 0:
|
|
442
|
+
raise ValueError("timeout must be positive")
|
|
443
|
+
try:
|
|
444
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
|
445
|
+
connection.settimeout(timeout)
|
|
446
|
+
connection.connect(str(self.socket_path))
|
|
447
|
+
connection.sendall(request.to_json().encode("utf-8") + b"\n")
|
|
448
|
+
with connection.makefile("rb") as reader:
|
|
449
|
+
raw_response = reader.readline(MAX_CONTROL_LINE_BYTES + 1)
|
|
450
|
+
except TimeoutError as exc:
|
|
451
|
+
raise HostBridgeError("timeout", "HostBridge daemon request timed out", retryable=True) from exc
|
|
452
|
+
except OSError as exc:
|
|
453
|
+
raise HostBridgeError("connection_lost", "HostBridge daemon connection failed", retryable=True) from exc
|
|
454
|
+
if not raw_response:
|
|
455
|
+
raise HostBridgeError("connection_lost", "HostBridge daemon closed before responding", retryable=True)
|
|
456
|
+
if len(raw_response) > MAX_CONTROL_LINE_BYTES:
|
|
457
|
+
raise HostBridgeError("protocol_mismatch", "HostBridge daemon response exceeds the control limit")
|
|
458
|
+
return self._read_response_from_bytes(raw_response, request)
|
|
459
|
+
|
|
460
|
+
@staticmethod
|
|
461
|
+
def _read_response_from_bytes(raw_response: bytes, request: Request) -> Response:
|
|
462
|
+
try:
|
|
463
|
+
response = Response.from_json(raw_response.decode("utf-8"))
|
|
464
|
+
except (ProtocolError, UnicodeDecodeError) as exc:
|
|
465
|
+
raise HostBridgeError("protocol_mismatch", "HostBridge daemon returned an invalid response") from exc
|
|
466
|
+
if response.request_id != request.request_id:
|
|
467
|
+
raise HostBridgeError("protocol_mismatch", "HostBridge daemon response request_id does not match")
|
|
468
|
+
return response
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""HostBridge schema-v1 configuration and migration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import tempfile
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
SCHEMA_VERSION = 1
|
|
13
|
+
_HOST_ID = re.compile(r"^[A-Za-z0-9_.-]+$")
|
|
14
|
+
_CONNECTION_STYLES = {"script", "ssh", "command"}
|
|
15
|
+
_TRANSPORTS = {"auto", "pty", "ssh"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class ConfigDocument:
|
|
20
|
+
path: Path
|
|
21
|
+
data: dict[str, object]
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def hosts(self) -> list[dict[str, object]]:
|
|
25
|
+
value = self.data["hosts"]
|
|
26
|
+
assert isinstance(value, list)
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_v1_config(path: Path | str) -> ConfigDocument:
|
|
31
|
+
config_path = Path(path).expanduser()
|
|
32
|
+
try:
|
|
33
|
+
payload = json.loads(config_path.read_text(encoding="utf-8"))
|
|
34
|
+
except json.JSONDecodeError as exc:
|
|
35
|
+
raise ValueError(f"{config_path} is not valid JSON") from exc
|
|
36
|
+
validated = validate_v1_config(payload, source=config_path)
|
|
37
|
+
return ConfigDocument(config_path, validated)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def validate_v1_config(payload: object, *, source: Path | str = "configuration") -> dict[str, object]:
|
|
41
|
+
if not isinstance(payload, dict):
|
|
42
|
+
raise ValueError(f"{source} must be a JSON object")
|
|
43
|
+
if "schema_version" not in payload:
|
|
44
|
+
raise ValueError(f"{source} is missing schema_version")
|
|
45
|
+
if payload.get("schema_version") != SCHEMA_VERSION:
|
|
46
|
+
raise ValueError(f"{source} has unsupported schema_version {payload.get('schema_version')!r}")
|
|
47
|
+
raw_hosts = payload.get("hosts")
|
|
48
|
+
if not isinstance(raw_hosts, list):
|
|
49
|
+
raise ValueError(f"{source}.hosts must be a list")
|
|
50
|
+
hosts: list[dict[str, object]] = []
|
|
51
|
+
seen: set[str] = set()
|
|
52
|
+
for index, raw_host in enumerate(raw_hosts):
|
|
53
|
+
if not isinstance(raw_host, dict):
|
|
54
|
+
raise ValueError(f"{source}.hosts[{index}] must be an object")
|
|
55
|
+
host = dict(raw_host)
|
|
56
|
+
host_id = host.get("id")
|
|
57
|
+
if not isinstance(host_id, str) or not _HOST_ID.fullmatch(host_id):
|
|
58
|
+
raise ValueError(f"{source}.hosts[{index}].id is invalid")
|
|
59
|
+
if host_id in seen:
|
|
60
|
+
raise ValueError(f"{source} contains duplicate host id {host_id!r}")
|
|
61
|
+
seen.add(host_id)
|
|
62
|
+
styles = [name for name in _CONNECTION_STYLES if name in host]
|
|
63
|
+
if len(styles) != 1:
|
|
64
|
+
raise ValueError(f"{source}.hosts[{index}] must define exactly one connection style")
|
|
65
|
+
transport = host.get("transport", "auto")
|
|
66
|
+
if transport not in _TRANSPORTS:
|
|
67
|
+
raise ValueError(f"{source}.hosts[{index}].transport must be one of: auto, pty, ssh")
|
|
68
|
+
host["transport"] = transport
|
|
69
|
+
_validate_secrets(host.get("secrets", {}), source, index)
|
|
70
|
+
_validate_limits(host.get("limits"), source, index)
|
|
71
|
+
hosts.append(host)
|
|
72
|
+
result = dict(payload)
|
|
73
|
+
result["schema_version"] = SCHEMA_VERSION
|
|
74
|
+
result["hosts"] = hosts
|
|
75
|
+
return result
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _validate_secrets(value: object, source: Path | str, index: int) -> None:
|
|
79
|
+
if value in (None, {}):
|
|
80
|
+
return
|
|
81
|
+
if not isinstance(value, dict):
|
|
82
|
+
raise ValueError(f"{source}.hosts[{index}].secrets must be an object")
|
|
83
|
+
for name, secret in value.items():
|
|
84
|
+
if not isinstance(name, str):
|
|
85
|
+
raise ValueError(f"{source}.hosts[{index}].secrets contains an invalid name")
|
|
86
|
+
encrypted = isinstance(secret, str) and secret.startswith("gAAAA")
|
|
87
|
+
environment_ref = isinstance(secret, dict) and set(secret) == {"env"} and isinstance(secret.get("env"), str)
|
|
88
|
+
if not encrypted and not environment_ref:
|
|
89
|
+
raise ValueError(f"{source}.hosts[{index}] contains plaintext secret {name!r}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _validate_limits(value: object, source: Path | str, index: int) -> None:
|
|
93
|
+
if value is None:
|
|
94
|
+
return
|
|
95
|
+
if not isinstance(value, dict):
|
|
96
|
+
raise ValueError(f"{source}.hosts[{index}].limits must be an object")
|
|
97
|
+
for name, limit in value.items():
|
|
98
|
+
if not isinstance(name, str) or not isinstance(limit, int) or isinstance(limit, bool) or limit <= 0:
|
|
99
|
+
raise ValueError(f"{source}.hosts[{index}].limits values must be positive integers")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def migrate_config(source: Path | str, destination: Path | str) -> ConfigDocument:
|
|
103
|
+
source_path = Path(source).expanduser()
|
|
104
|
+
destination_path = Path(destination).expanduser()
|
|
105
|
+
if destination_path.exists():
|
|
106
|
+
raise FileExistsError(destination_path)
|
|
107
|
+
try:
|
|
108
|
+
legacy = json.loads(source_path.read_text(encoding="utf-8"))
|
|
109
|
+
except json.JSONDecodeError as exc:
|
|
110
|
+
raise ValueError(f"{source_path} is not valid JSON") from exc
|
|
111
|
+
if not isinstance(legacy, dict) or not isinstance(legacy.get("hosts"), list):
|
|
112
|
+
raise ValueError(f"{source_path} must contain a hosts list")
|
|
113
|
+
migrated_hosts = []
|
|
114
|
+
for raw_host in legacy["hosts"]:
|
|
115
|
+
if not isinstance(raw_host, dict):
|
|
116
|
+
raise ValueError(f"{source_path} contains a non-object host")
|
|
117
|
+
host = dict(raw_host)
|
|
118
|
+
host.setdefault("transport", "auto")
|
|
119
|
+
migrated_hosts.append(host)
|
|
120
|
+
payload = validate_v1_config(
|
|
121
|
+
{"schema_version": SCHEMA_VERSION, "hosts": migrated_hosts},
|
|
122
|
+
source=source_path,
|
|
123
|
+
)
|
|
124
|
+
destination_path.parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
encoded = (json.dumps(payload, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
|
|
126
|
+
fd, temporary_name = tempfile.mkstemp(prefix=f".{destination_path.name}.", dir=destination_path.parent)
|
|
127
|
+
temporary_path = Path(temporary_name)
|
|
128
|
+
try:
|
|
129
|
+
os.fchmod(fd, 0o600)
|
|
130
|
+
with os.fdopen(fd, "wb") as handle:
|
|
131
|
+
handle.write(encoded)
|
|
132
|
+
handle.flush()
|
|
133
|
+
os.fsync(handle.fileno())
|
|
134
|
+
temporary_path.replace(destination_path)
|
|
135
|
+
destination_path.chmod(0o600)
|
|
136
|
+
except Exception:
|
|
137
|
+
temporary_path.unlink(missing_ok=True)
|
|
138
|
+
raise
|
|
139
|
+
return ConfigDocument(destination_path, payload)
|