@neoline/hostbridge 1.4.4 → 2.0.1

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,478 @@
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(
150
+ self,
151
+ host_id: str,
152
+ *,
153
+ owner: str | None = None,
154
+ timeout: float | None = None,
155
+ ) -> SessionInfo:
156
+ result = self.request(
157
+ "session.open",
158
+ {"host_id": host_id, "owner": owner},
159
+ timeout=timeout,
160
+ )
161
+ return SessionInfo.from_dict(result)
162
+
163
+ def exec(
164
+ self,
165
+ session_id: str,
166
+ command: str,
167
+ *,
168
+ stdin: bytes | None = None,
169
+ timeout: float | None = None,
170
+ output_limit: int | None = None,
171
+ owner: str | None = None,
172
+ ) -> ExecResult:
173
+ params: dict[str, object] = {
174
+ "session_id": session_id,
175
+ "command": command,
176
+ "stdin": _encode_inline_bytes(stdin),
177
+ }
178
+ if timeout is not None:
179
+ params["timeout"] = timeout
180
+ if output_limit is not None:
181
+ params["output_limit"] = output_limit
182
+ if owner is not None:
183
+ params["owner"] = owner
184
+ result = self.request("exec.start", params, timeout=timeout)
185
+ return ExecResult.from_dict(result)
186
+
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}
189
+ if owner is not None:
190
+ params["owner"] = owner
191
+ self.request("session.close", params)
192
+
193
+ def write_text(
194
+ self,
195
+ session_id: str,
196
+ remote_path: str,
197
+ content: str,
198
+ *,
199
+ mode: int = 0o644,
200
+ max_bytes: int = 4 * 1024 * 1024,
201
+ owner: str | None = None,
202
+ ) -> TransferResult:
203
+ params: dict[str, object] = {
204
+ "session_id": session_id,
205
+ "remote_path": remote_path,
206
+ "content": content,
207
+ "mode": mode,
208
+ "max_bytes": max_bytes,
209
+ }
210
+ if owner is not None:
211
+ params["owner"] = owner
212
+ result = self.request("file.write_text", params)
213
+ return self._transfer_result(result)
214
+
215
+ def read_text(
216
+ self,
217
+ session_id: str,
218
+ remote_path: str,
219
+ *,
220
+ max_bytes: int = 4 * 1024 * 1024,
221
+ owner: str | None = None,
222
+ ) -> str:
223
+ params: dict[str, object] = {"session_id": session_id, "remote_path": remote_path, "max_bytes": max_bytes}
224
+ if owner is not None:
225
+ params["owner"] = owner
226
+ result = self.request("file.read_text", params)
227
+ content = result.get("content")
228
+ if not isinstance(content, str):
229
+ raise HostBridgeError("protocol_mismatch", "text response content must be a string")
230
+ return content
231
+
232
+ 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
+ }
237
+ if owner is not None:
238
+ params["owner"] = owner
239
+ result = self.request("shell.write", params)
240
+ written = result.get("written")
241
+ if not isinstance(written, int) or isinstance(written, bool) or written < 0:
242
+ raise HostBridgeError("protocol_mismatch", "shell write response contains invalid byte count")
243
+ return written
244
+
245
+ def shell_read(
246
+ self,
247
+ session_id: str,
248
+ *,
249
+ timeout: float = 0.2,
250
+ max_bytes: int = 4096,
251
+ owner: str | None = None,
252
+ ) -> ShellReadResult:
253
+ params: dict[str, object] = {
254
+ "session_id": session_id,
255
+ "timeout": timeout,
256
+ "max_bytes": max_bytes,
257
+ }
258
+ if owner is not None:
259
+ params["owner"] = owner
260
+ result = self.request("shell.read", params, timeout=max(self.default_timeout, timeout + 1))
261
+ alive = result.get("alive")
262
+ if not isinstance(alive, bool):
263
+ raise HostBridgeError("protocol_mismatch", "shell read response contains invalid alive flag")
264
+ return ShellReadResult(_decode_inline_bytes(result.get("data"), "data"), alive)
265
+
266
+ def upload_file(
267
+ self,
268
+ session_id: str,
269
+ local_path: Path | str,
270
+ remote_path: str,
271
+ *,
272
+ chunk_size: int = 256 * 1024,
273
+ mode: int = 0o644,
274
+ timeout: float | None = None,
275
+ cancel_event: threading.Event | None = None,
276
+ owner: str | None = None,
277
+ ) -> TransferResult:
278
+ self._validate_chunk_size(chunk_size)
279
+ source = Path(local_path)
280
+ expected_size = source.stat().st_size
281
+ stream_id = uuid.uuid4().hex
282
+ params: dict[str, object] = {
283
+ "session_id": session_id,
284
+ "remote_path": remote_path,
285
+ "stream_id": stream_id,
286
+ "mode": mode,
287
+ "expected_size": expected_size,
288
+ }
289
+ if owner is not None:
290
+ params["owner"] = owner
291
+ request = Request.new("transfer.upload", params)
292
+ digest = hashlib.sha256()
293
+ sequence = 0
294
+ request_timeout = self.default_timeout if timeout is None else timeout
295
+ 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)
325
+ if result.bytes_transferred != expected_size or result.sha256 != digest.hexdigest():
326
+ raise HostBridgeError("integrity_error", "HostBridge upload integrity check failed")
327
+ return result
328
+
329
+ def download_file(
330
+ self,
331
+ session_id: str,
332
+ remote_path: str,
333
+ local_path: Path | str,
334
+ *,
335
+ chunk_size: int = 256 * 1024,
336
+ timeout: float | None = None,
337
+ owner: str | None = None,
338
+ ) -> TransferResult:
339
+ self._validate_chunk_size(chunk_size)
340
+ destination = Path(local_path)
341
+ destination.parent.mkdir(parents=True, exist_ok=True)
342
+ temporary = destination.with_name(f".{destination.name}.hostbridge-{uuid.uuid4().hex}.tmp")
343
+ stream_id = uuid.uuid4().hex
344
+ params: dict[str, object] = {
345
+ "session_id": session_id,
346
+ "remote_path": remote_path,
347
+ "stream_id": stream_id,
348
+ "chunk_size": chunk_size,
349
+ }
350
+ if owner is not None:
351
+ params["owner"] = owner
352
+ request = Request.new("transfer.download", params)
353
+ digest = hashlib.sha256()
354
+ transferred = 0
355
+ validator = FrameSequenceValidator()
356
+ request_timeout = self.default_timeout if timeout is None else timeout
357
+ 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():
392
+ temporary.unlink(missing_ok=True)
393
+ raise HostBridgeError("integrity_error", "HostBridge download integrity check failed")
394
+ temporary.replace(destination)
395
+ return expected
396
+
397
+ @staticmethod
398
+ 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")
401
+
402
+ @staticmethod
403
+ def _transfer_result(value: Mapping[str, object]) -> TransferResult:
404
+ byte_count = value.get("bytes_transferred")
405
+ sha256 = value.get("sha256")
406
+ if not isinstance(byte_count, int) or isinstance(byte_count, bool) or byte_count < 0:
407
+ raise HostBridgeError("protocol_mismatch", "transfer response contains invalid byte count")
408
+ if not isinstance(sha256, str) or len(sha256) != 64:
409
+ raise HostBridgeError("protocol_mismatch", "transfer response contains invalid SHA-256")
410
+ return TransferResult(byte_count, sha256.lower())
411
+
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
+
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
@@ -0,0 +1,158 @@
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
+ from .secrets import SCHEME, encrypt_secret
13
+
14
+ SCHEMA_VERSION = 1
15
+ _HOST_ID = re.compile(r"^[A-Za-z0-9_.-]+$")
16
+ _CONNECTION_STYLES = {"script", "ssh", "command"}
17
+ _TRANSPORTS = {"auto", "pty", "ssh"}
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class ConfigDocument:
22
+ path: Path
23
+ data: dict[str, object]
24
+
25
+ @property
26
+ def hosts(self) -> list[dict[str, object]]:
27
+ value = self.data["hosts"]
28
+ assert isinstance(value, list)
29
+ return value
30
+
31
+
32
+ def load_v1_config(path: Path | str) -> ConfigDocument:
33
+ config_path = Path(path).expanduser()
34
+ try:
35
+ payload = json.loads(config_path.read_text(encoding="utf-8"))
36
+ except json.JSONDecodeError as exc:
37
+ raise ValueError(f"{config_path} is not valid JSON") from exc
38
+ validated = validate_v1_config(payload, source=config_path)
39
+ return ConfigDocument(config_path, validated)
40
+
41
+
42
+ def validate_v1_config(payload: object, *, source: Path | str = "configuration") -> dict[str, object]:
43
+ if not isinstance(payload, dict):
44
+ raise ValueError(f"{source} must be a JSON object")
45
+ if "schema_version" not in payload:
46
+ raise ValueError(f"{source} is missing schema_version")
47
+ if payload.get("schema_version") != SCHEMA_VERSION:
48
+ raise ValueError(f"{source} has unsupported schema_version {payload.get('schema_version')!r}")
49
+ raw_hosts = payload.get("hosts")
50
+ if not isinstance(raw_hosts, list):
51
+ raise ValueError(f"{source}.hosts must be a list")
52
+ hosts: list[dict[str, object]] = []
53
+ seen: set[str] = set()
54
+ for index, raw_host in enumerate(raw_hosts):
55
+ if not isinstance(raw_host, dict):
56
+ raise ValueError(f"{source}.hosts[{index}] must be an object")
57
+ host = dict(raw_host)
58
+ host_id = host.get("id")
59
+ if not isinstance(host_id, str) or not _HOST_ID.fullmatch(host_id):
60
+ raise ValueError(f"{source}.hosts[{index}].id is invalid")
61
+ if host_id in seen:
62
+ raise ValueError(f"{source} contains duplicate host id {host_id!r}")
63
+ seen.add(host_id)
64
+ styles = [name for name in _CONNECTION_STYLES if name in host]
65
+ if len(styles) != 1:
66
+ raise ValueError(f"{source}.hosts[{index}] must define exactly one connection style")
67
+ transport = host.get("transport", "auto")
68
+ if transport not in _TRANSPORTS:
69
+ raise ValueError(f"{source}.hosts[{index}].transport must be one of: auto, pty, ssh")
70
+ host["transport"] = transport
71
+ _validate_secrets(host.get("secrets", {}), source, index)
72
+ _validate_limits(host.get("limits"), source, index)
73
+ hosts.append(host)
74
+ result = dict(payload)
75
+ result["schema_version"] = SCHEMA_VERSION
76
+ result["hosts"] = hosts
77
+ return result
78
+
79
+
80
+ def _validate_secrets(value: object, source: Path | str, index: int) -> None:
81
+ if value in (None, {}):
82
+ return
83
+ if not isinstance(value, dict):
84
+ raise ValueError(f"{source}.hosts[{index}].secrets must be an object")
85
+ for name, secret in value.items():
86
+ if not isinstance(name, str):
87
+ raise ValueError(f"{source}.hosts[{index}].secrets contains an invalid name")
88
+ encrypted = (
89
+ isinstance(secret, dict)
90
+ and set(secret) == {"scheme", "ciphertext"}
91
+ and secret.get("scheme") == SCHEME
92
+ and isinstance(secret.get("ciphertext"), str)
93
+ and bool(secret.get("ciphertext"))
94
+ )
95
+ environment_ref = (
96
+ isinstance(secret, dict)
97
+ and set(secret) == {"env"}
98
+ and isinstance(secret.get("env"), str)
99
+ and bool(secret.get("env"))
100
+ )
101
+ if not encrypted and not environment_ref:
102
+ raise ValueError(f"{source}.hosts[{index}] contains plaintext secret {name!r}")
103
+
104
+
105
+ def _validate_limits(value: object, source: Path | str, index: int) -> None:
106
+ if value is None:
107
+ return
108
+ if not isinstance(value, dict):
109
+ raise ValueError(f"{source}.hosts[{index}].limits must be an object")
110
+ for name, limit in value.items():
111
+ if not isinstance(name, str) or not isinstance(limit, int) or isinstance(limit, bool) or limit <= 0:
112
+ raise ValueError(f"{source}.hosts[{index}].limits values must be positive integers")
113
+
114
+
115
+ def migrate_config(source: Path | str, destination: Path | str) -> ConfigDocument:
116
+ source_path = Path(source).expanduser()
117
+ destination_path = Path(destination).expanduser()
118
+ if destination_path.exists():
119
+ raise FileExistsError(destination_path)
120
+ try:
121
+ legacy = json.loads(source_path.read_text(encoding="utf-8"))
122
+ except json.JSONDecodeError as exc:
123
+ raise ValueError(f"{source_path} is not valid JSON") from exc
124
+ if not isinstance(legacy, dict) or not isinstance(legacy.get("hosts"), list):
125
+ raise ValueError(f"{source_path} must contain a hosts list")
126
+ migrated_hosts = []
127
+ for raw_host in legacy["hosts"]:
128
+ if not isinstance(raw_host, dict):
129
+ raise ValueError(f"{source_path} contains a non-object host")
130
+ host = dict(raw_host)
131
+ host.setdefault("transport", "auto")
132
+ secrets = host.get("secrets")
133
+ if isinstance(secrets, dict):
134
+ host["secrets"] = {
135
+ name: encrypt_secret(secret) if isinstance(secret, str) else secret
136
+ for name, secret in secrets.items()
137
+ }
138
+ migrated_hosts.append(host)
139
+ payload = validate_v1_config(
140
+ {"schema_version": SCHEMA_VERSION, "hosts": migrated_hosts},
141
+ source=source_path,
142
+ )
143
+ destination_path.parent.mkdir(parents=True, exist_ok=True)
144
+ encoded = (json.dumps(payload, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
145
+ fd, temporary_name = tempfile.mkstemp(prefix=f".{destination_path.name}.", dir=destination_path.parent)
146
+ temporary_path = Path(temporary_name)
147
+ try:
148
+ os.fchmod(fd, 0o600)
149
+ with os.fdopen(fd, "wb") as handle:
150
+ handle.write(encoded)
151
+ handle.flush()
152
+ os.fsync(handle.fileno())
153
+ temporary_path.replace(destination_path)
154
+ destination_path.chmod(0o600)
155
+ except Exception:
156
+ temporary_path.unlink(missing_ok=True)
157
+ raise
158
+ return ConfigDocument(destination_path, payload)