@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.
@@ -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)