@neoline/hostbridge 2.0.4 → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +11 -9
  2. package/docs/ARCHITECTURE.md +62 -0
  3. package/package.json +2 -1
  4. package/pyproject.toml +1 -1
  5. package/src/server_control_mcp/__init__.py +4 -3
  6. package/src/server_control_mcp/async_lifecycle.py +41 -0
  7. package/src/server_control_mcp/cli.py +1 -1
  8. package/src/server_control_mcp/client.py +302 -287
  9. package/src/server_control_mcp/config.py +2 -1
  10. package/src/server_control_mcp/daemon.py +233 -526
  11. package/src/server_control_mcp/doctor.py +34 -2
  12. package/src/server_control_mcp/hosts.py +136 -2
  13. package/src/server_control_mcp/mock_ssh.py +52 -14
  14. package/src/server_control_mcp/mock_ssh_exec.py +147 -58
  15. package/src/server_control_mcp/mock_ssh_session.py +3 -2
  16. package/src/server_control_mcp/mock_ssh_sftp.py +126 -28
  17. package/src/server_control_mcp/mock_ssh_tunnel.py +141 -444
  18. package/src/server_control_mcp/mux_connection.py +326 -0
  19. package/src/server_control_mcp/mux_daemon.py +186 -0
  20. package/src/server_control_mcp/mux_protocol.py +279 -0
  21. package/src/server_control_mcp/mux_records.py +71 -0
  22. package/src/server_control_mcp/mux_rpc.py +1779 -0
  23. package/src/server_control_mcp/mux_service.py +506 -0
  24. package/src/server_control_mcp/mux_stream.py +283 -0
  25. package/src/server_control_mcp/mux_sync_client.py +224 -0
  26. package/src/server_control_mcp/remote_agent_bundle.py +56 -0
  27. package/src/server_control_mcp/remote_mux_agent.py +769 -0
  28. package/src/server_control_mcp/runtime_services.py +862 -0
  29. package/src/server_control_mcp/server.py +33 -18
  30. package/src/server_control_mcp/services.py +2 -666
  31. package/src/server_control_mcp/transports/__init__.py +4 -8
  32. package/src/server_control_mcp/transports/base.py +60 -38
  33. package/src/server_control_mcp/transports/ssh.py +206 -259
  34. package/src/server_control_mcp/tunnel_manager.py +1245 -0
  35. package/src/server_control_mcp/tunnel_native_ssh.py +454 -0
  36. package/src/server_control_mcp/tunnel_providers.py +20 -0
  37. package/src/server_control_mcp/tunnel_pty_agent.py +909 -0
  38. package/src/server_control_mcp/protocol.py +0 -363
  39. package/src/server_control_mcp/remote_tunnel_agent.py +0 -143
  40. package/src/server_control_mcp/transports/pty.py +0 -515
@@ -1,515 +0,0 @@
1
- """Ordered interactive PTY transport."""
2
-
3
- from __future__ import annotations
4
-
5
- import base64
6
- import binascii
7
- import hashlib
8
- import re
9
- import shlex
10
- import threading
11
- import time
12
- import uuid
13
- from collections.abc import Callable, Iterable, Iterator
14
- from contextlib import suppress
15
- from typing import Protocol
16
-
17
- import pexpect
18
-
19
- from ..hosts import HostConfig
20
- from .base import ExecResult, ShellReadResult, TransferResult, TransportBusy, TransportCapabilities, TransportError
21
-
22
- _REMOTE_BASE64_DECODE = "{ base64 --decode 2>/dev/null || base64 -d 2>/dev/null || base64 -D; }"
23
- _REMOTE_BASE64_ENCODE = "{ base64 --wrap=0 2>/dev/null || base64 | tr -d '\\r\\n'; }"
24
- PTY_BASE64_LINE_LENGTH = 3072
25
-
26
-
27
- class ChildProcess(Protocol):
28
- before: str
29
- after: object
30
-
31
- def sendline(self, line: str) -> None:
32
- raise NotImplementedError
33
-
34
- def send(self, text: str) -> None:
35
- raise NotImplementedError
36
-
37
- def expect(self, patterns: object, timeout: int | float = -1) -> int:
38
- raise NotImplementedError
39
-
40
- def isalive(self) -> bool:
41
- raise NotImplementedError
42
-
43
- def close(self, force: bool = False) -> None:
44
- raise NotImplementedError
45
-
46
- def read_nonblocking(self, size: int = 1, timeout: int | float = -1) -> str:
47
- raise NotImplementedError
48
-
49
-
50
- ChildFactory = Callable[[str, list[str], int | float], ChildProcess]
51
-
52
-
53
- def _default_child_factory(command: str, args: list[str], timeout: int | float) -> ChildProcess:
54
- return pexpect.spawn(
55
- command,
56
- args,
57
- encoding="utf-8",
58
- codec_errors="replace",
59
- timeout=timeout,
60
- echo=False,
61
- )
62
-
63
-
64
- def _iter_base64(chunks: Iterable[bytes]) -> Iterator[str]:
65
- def split_line(encoded: str) -> Iterator[str]:
66
- for offset in range(0, len(encoded), PTY_BASE64_LINE_LENGTH):
67
- yield encoded[offset : offset + PTY_BASE64_LINE_LENGTH]
68
-
69
- remainder = b""
70
- for chunk in chunks:
71
- if not isinstance(chunk, bytes):
72
- raise TypeError("stdin chunks must be bytes")
73
- data = remainder + chunk
74
- complete = len(data) - (len(data) % 3)
75
- if complete:
76
- yield from split_line(base64.b64encode(data[:complete]).decode("ascii"))
77
- remainder = data[complete:]
78
- if remainder:
79
- yield from split_line(base64.b64encode(remainder).decode("ascii"))
80
-
81
-
82
- def _marker_text(value: object) -> str:
83
- return value.group(0) if hasattr(value, "group") else str(value)
84
-
85
-
86
- def _private_temp_path(kind: str, token: str, name: str | None = None) -> str:
87
- path = f"$HOME/.hostbridge/tmp/{kind}.{token}"
88
- return f"{path}/{name}" if name is not None else path
89
-
90
-
91
- def _quote_private_temp_path(path: str) -> str:
92
- prefix = "$HOME/"
93
- if not path.startswith(prefix):
94
- raise ValueError("private temporary path must be relative to $HOME")
95
- return f'"$HOME"/{shlex.quote(path[len(prefix) :])}'
96
-
97
-
98
- def _prepare_private_temp_dir(path: str) -> str:
99
- directory = _quote_private_temp_path(path)
100
- return (
101
- 'umask 077; tmp_root="$HOME/.hostbridge/tmp"; '
102
- '[ -n "${HOME:-}" ] && [ ! -L "$HOME/.hostbridge" ] && [ ! -L "$tmp_root" ] && '
103
- 'mkdir -p -- "$tmp_root" && chmod 700 "$HOME/.hostbridge" "$tmp_root" && '
104
- f"mkdir -m 700 -- {directory}"
105
- )
106
-
107
-
108
- class PtyTransport:
109
- capabilities = TransportCapabilities(
110
- separate_stderr=False,
111
- native_binary_transfer=False,
112
- parallel_channels=1,
113
- )
114
-
115
- @classmethod
116
- def connect(
117
- cls,
118
- host: HostConfig,
119
- *,
120
- child_factory: ChildFactory = _default_child_factory,
121
- connect_timeout: int | float = 60,
122
- remote_shell: str = "bash",
123
- token_factory: Callable[[], str] | None = None,
124
- ) -> PtyTransport:
125
- if connect_timeout <= 0:
126
- raise ValueError("connect_timeout must be positive")
127
- tokens = token_factory or (lambda: uuid.uuid4().hex)
128
- child = child_factory(host.command, list(host.args), connect_timeout)
129
- try:
130
- for step in host.login_steps:
131
- child.expect(step.expect, timeout=connect_timeout)
132
- if step.send_secret is not None:
133
- secret = host.secrets.get(step.send_secret)
134
- if secret is None:
135
- raise TransportError(f"login step references missing secret {step.send_secret!r}")
136
- child.send(f"{secret}\r")
137
- else:
138
- child.send(f"{step.send or ''}\r")
139
- if host.login_steps:
140
- child.expect(host.ready_expect or r"(?m)[^\r\n]*[#$]\s*$", timeout=connect_timeout)
141
- token = tokens()
142
- marker = f"__HB_READY_{token}__"
143
- child.send(f"printf '\n{marker}\n'\r")
144
- child.expect(re.escape(marker), timeout=connect_timeout)
145
- except Exception as exc:
146
- child.close(force=True)
147
- if isinstance(exc, TransportError):
148
- raise
149
- raise TransportError(f"failed to establish PTY transport for {host.id}") from exc
150
- return cls(child, remote_shell=remote_shell, token_factory=tokens)
151
-
152
- def __init__(
153
- self,
154
- child: ChildProcess,
155
- *,
156
- remote_shell: str = "bash",
157
- token_factory: Callable[[], str] | None = None,
158
- marker_prefix: str = "HB",
159
- ) -> None:
160
- self.child = child
161
- self.remote_shell = remote_shell
162
- self._token_factory = token_factory or (lambda: uuid.uuid4().hex)
163
- self._marker_prefix = marker_prefix
164
- self.operation_lock = threading.Lock()
165
- self._io_lock = threading.Lock()
166
-
167
- def exec(
168
- self,
169
- command: str,
170
- *,
171
- stdin: Iterable[bytes] | None,
172
- timeout: float,
173
- output_limit: int,
174
- ) -> ExecResult:
175
- if not command.strip():
176
- raise ValueError("command must not be empty")
177
- if timeout <= 0:
178
- raise ValueError("timeout must be positive")
179
- if output_limit <= 0:
180
- raise ValueError("output_limit must be positive")
181
- if not self.operation_lock.acquire(blocking=False):
182
- raise TransportBusy("PTY transport already has an active operation")
183
- stdin_path: str | None = None
184
- try:
185
- if not self.child.isalive():
186
- raise TransportError("PTY transport is not alive")
187
- if stdin is not None:
188
- stdin_path = self._stage_stdin_locked(stdin, timeout=timeout)
189
- return self._exec_locked(command, timeout=timeout, output_limit=output_limit, stdin_path=stdin_path)
190
- finally:
191
- if stdin_path is not None and self.child.isalive():
192
- with suppress(Exception):
193
- stdin_dir = stdin_path.rsplit("/", 1)[0]
194
- self._exec_locked(
195
- f"trap - EXIT HUP INT TERM; rm -rf -- {_quote_private_temp_path(stdin_dir)}",
196
- timeout=min(timeout, 10),
197
- output_limit=1024,
198
- )
199
- self.operation_lock.release()
200
-
201
- def _stage_stdin_locked(self, chunks: Iterable[bytes], *, timeout: float) -> str:
202
- token = self._token_factory()
203
- temp_dir = _private_temp_path("stdin", token)
204
- path = _private_temp_path("stdin", token, "stdin")
205
- base64_path = _private_temp_path("stdin", token, "stdin.b64")
206
- temp_dir_q = _quote_private_temp_path(temp_dir)
207
- path_q = _quote_private_temp_path(path)
208
- base64_path_q = _quote_private_temp_path(base64_path)
209
- heredoc = f"__{self._marker_prefix}_STDIN_{token}__"
210
- ready_pattern = re.compile(rf"__{self._marker_prefix}_STDIN_READY_{re.escape(token)}__:(\d+)")
211
- done_pattern = re.compile(rf"__{self._marker_prefix}_STDIN_DONE_{re.escape(token)}__:(\d+)")
212
- cleanup = f"rm -rf -- {temp_dir_q}"
213
- try:
214
- setup = _prepare_private_temp_dir(temp_dir)
215
- self.child.sendline(
216
- f"if {setup}; then status=0; else status=$?; fi; "
217
- f"printf '__{self._marker_prefix}_STDIN_READY_{token}__:%s\n' \"$status\""
218
- )
219
- self.child.expect(ready_pattern, timeout=timeout)
220
- ready_match = re.search(r":(\d+)", _marker_text(self.child.after))
221
- if ready_match is None or int(ready_match.group(1)) != 0:
222
- raise TransportError("failed to prepare private PTY stdin directory")
223
- self.child.send(f"trap {shlex.quote(cleanup)} EXIT HUP INT TERM; cat > {path_q} <<'{heredoc}'\n")
224
- for encoded in _iter_base64(chunks):
225
- self.child.send(encoded)
226
- self.child.send(
227
- f"\n{heredoc}\n"
228
- f'tmp={base64_path_q}; mv {path_q} "$tmp"; '
229
- f'{{ {_REMOTE_BASE64_DECODE} < "$tmp"; }} > {path_q}; status=$?; rm -f "$tmp"; '
230
- f"printf '\n__{self._marker_prefix}_STDIN_DONE_{token}__:%s\n' \"$status\"\n"
231
- )
232
- self.child.expect(done_pattern, timeout=timeout)
233
- except BaseException:
234
- if self.child.isalive():
235
- with suppress(Exception):
236
- self.child.send("\x03")
237
- with suppress(Exception):
238
- self.child.sendline(f"trap - EXIT HUP INT TERM; {cleanup}")
239
- with suppress(Exception):
240
- self.child.close(force=True)
241
- raise
242
- match = re.search(r":(\d+)", _marker_text(self.child.after))
243
- if match is None or int(match.group(1)) != 0:
244
- raise TransportError("failed to stage PTY stdin")
245
- return path
246
-
247
- def _exec_locked(
248
- self,
249
- command: str,
250
- *,
251
- timeout: float,
252
- output_limit: int,
253
- stdin_path: str | None = None,
254
- ) -> ExecResult:
255
- token = self._token_factory()
256
- start_marker = f"__{self._marker_prefix}_START_{token}__"
257
- exit_pattern = re.compile(rf"__{self._marker_prefix}_EXIT_{re.escape(token)}__:(\d+)")
258
- encoded_command = base64.b64encode(command.encode("utf-8")).decode("ascii")
259
- stdin_redirect = f" < {_quote_private_temp_path(stdin_path)}" if stdin_path is not None else ""
260
- status_dir = _private_temp_path("exec", token)
261
- status_path = _private_temp_path("exec", token, "status")
262
- cleanup_dirs = [status_dir]
263
- if stdin_path is not None:
264
- cleanup_dirs.append(stdin_path.rsplit("/", 1)[0])
265
- signal_cleanup = "rm -rf -- " + " ".join(_quote_private_temp_path(path) for path in cleanup_dirs)
266
- remaining_trap = (
267
- f"trap {shlex.quote('rm -rf -- ' + _quote_private_temp_path(cleanup_dirs[1]))} EXIT HUP INT TERM"
268
- if len(cleanup_dirs) > 1
269
- else "trap - EXIT HUP INT TERM"
270
- )
271
- status_path_q = _quote_private_temp_path(status_path)
272
- status_dir_q = _quote_private_temp_path(status_dir)
273
- setup = _prepare_private_temp_dir(status_dir)
274
- remote_line = (
275
- f"if {setup}; then trap {shlex.quote(signal_cleanup)} EXIT HUP INT TERM; "
276
- f"__scm_cmd=$(printf %s {shlex.quote(encoded_command)} | {_REMOTE_BASE64_DECODE}); "
277
- f"printf '\n{start_marker}'; "
278
- f'{{ {self.remote_shell} -lc "$__scm_cmd"{stdin_redirect}; '
279
- f'printf %s "$?" > {status_path_q}; }} 2>&1 | '
280
- f"tail -c {output_limit} | {_REMOTE_BASE64_ENCODE}; "
281
- f"__scm_status=$(cat {status_path_q} 2>/dev/null || printf 255); "
282
- f"rm -rf -- {status_dir_q}; {remaining_trap}; "
283
- f"printf '__{self._marker_prefix}_EXIT_{token}__:%s\n' \"$__scm_status\"; "
284
- f"else printf '\n{start_marker}'; printf '__{self._marker_prefix}_EXIT_{token}__:255\n'; fi"
285
- )
286
- try:
287
- self.child.sendline(remote_line)
288
- self.child.expect(exit_pattern, timeout=timeout)
289
- except pexpect.TIMEOUT:
290
- output = b""
291
- with suppress(TransportError):
292
- output = self._output_after_marker(str(self.child.before), start_marker, output_limit)
293
- with suppress(Exception):
294
- self.child.send("\x03")
295
- with suppress(Exception):
296
- self.child.close(force=True)
297
- return ExecResult(None, output, timed_out=True)
298
- match = re.search(r":(\d+)", _marker_text(self.child.after))
299
- exit_code = int(match.group(1)) if match else None
300
- try:
301
- output = self._output_after_marker(str(self.child.before), start_marker, output_limit)
302
- except TransportError:
303
- with suppress(Exception):
304
- self.child.close(force=True)
305
- raise
306
- return ExecResult(exit_code, output)
307
-
308
- @staticmethod
309
- def _output_after_marker(raw: str, marker: str, output_limit: int) -> bytes:
310
- normalized = raw.replace("\x1b[?2004h", "").replace("\x1b[?2004l", "")
311
- normalized = normalized.replace("\r\n", "\n").replace("\r", "\n")
312
- marker_index = normalized.rfind(marker)
313
- output = normalized[marker_index + len(marker) :] if marker_index >= 0 else normalized
314
- try:
315
- encoded = "".join(output.split()).encode("ascii")
316
- decoded = base64.b64decode(encoded, validate=True)
317
- except (UnicodeEncodeError, binascii.Error, ValueError) as exc:
318
- raise TransportError("PTY command output contains invalid base64 framing") from exc
319
- return decoded[-output_limit:]
320
-
321
- def upload(
322
- self,
323
- chunks: Iterable[bytes],
324
- remote_path: str,
325
- *,
326
- mode: int,
327
- expected_size: int | None,
328
- ) -> TransferResult:
329
- self._validate_remote_path(remote_path)
330
- if not isinstance(mode, int) or isinstance(mode, bool) or not 0 <= mode <= 0o7777:
331
- raise ValueError("mode must be an integer between 0 and 07777")
332
- if expected_size is not None and expected_size < 0:
333
- raise ValueError("expected_size must be non-negative")
334
- if not self.operation_lock.acquire(blocking=False):
335
- raise TransportBusy("PTY transport already has an active operation")
336
- token = self._token_factory()
337
- temp_dir = _private_temp_path("upload", token)
338
- tmp_b64 = _private_temp_path("upload", token, "payload.b64")
339
- tmp_out = _private_temp_path("upload", token, "payload.out")
340
- temp_dir_q = _quote_private_temp_path(temp_dir)
341
- tmp_b64_q = _quote_private_temp_path(tmp_b64)
342
- tmp_out_q = _quote_private_temp_path(tmp_out)
343
- heredoc = f"__{self._marker_prefix}_UPLOAD_{token}__"
344
- ready_pattern = re.compile(rf"__{self._marker_prefix}_UPLOAD_READY_{re.escape(token)}__:(\d+)")
345
- done_pattern = re.compile(rf"__{self._marker_prefix}_UPLOAD_DONE_{re.escape(token)}__:(\d+)")
346
- digest = hashlib.sha256()
347
- transferred = 0
348
-
349
- def counted_chunks() -> Iterator[bytes]:
350
- nonlocal transferred
351
- for chunk in chunks:
352
- if not isinstance(chunk, bytes):
353
- raise TypeError("upload chunks must be bytes")
354
- transferred += len(chunk)
355
- digest.update(chunk)
356
- yield chunk
357
-
358
- try:
359
- if not self.child.isalive():
360
- raise TransportError("PTY transport is not alive")
361
- setup = _prepare_private_temp_dir(temp_dir)
362
- self.child.sendline(
363
- f"if {setup}; then status=0; else status=$?; fi; "
364
- f"printf '__{self._marker_prefix}_UPLOAD_READY_{token}__:%s\n' \"$status\""
365
- )
366
- self.child.expect(ready_pattern, timeout=60)
367
- ready_match = re.search(r":(\d+)", _marker_text(self.child.after))
368
- if ready_match is None or int(ready_match.group(1)) != 0:
369
- raise TransportError("failed to prepare private PTY upload directory")
370
- self.child.send(f"cat > {tmp_b64_q} <<'{heredoc}'\n")
371
- for encoded in _iter_base64(counted_chunks()):
372
- self.child.send(encoded)
373
- self.child.send(f"\n{heredoc}\nprintf '\n__{self._marker_prefix}_UPLOAD_DONE_{token}__:%s\n' \"$?\"\n")
374
- self.child.expect(done_pattern, timeout=60)
375
- match = re.search(r":(\d+)", _marker_text(self.child.after))
376
- if match is None or int(match.group(1)) != 0:
377
- raise TransportError("failed to stream PTY upload")
378
- if expected_size is not None and transferred != expected_size:
379
- raise TransportError(f"upload size mismatch: expected {expected_size}, received {transferred}")
380
- target_q = shlex.quote(remote_path)
381
- finalize = (
382
- "set -e; "
383
- f"target={target_q}; tmp_b64={tmp_b64_q}; tmp_out={tmp_out_q}; "
384
- 'mkdir -p "$(dirname "$target")"; '
385
- f'{{ {_REMOTE_BASE64_DECODE} < "$tmp_b64"; }} > "$tmp_out"; '
386
- f'chmod {format(mode, "04o")} "$tmp_out"; mv "$tmp_out" "$target"; rm -rf -- {temp_dir_q}; '
387
- "if command -v sha256sum >/dev/null 2>&1; then sha=$(sha256sum \"$target\" | awk '{print $1}'); "
388
- "else sha=$(shasum -a 256 \"$target\" | awk '{print $1}'); fi; "
389
- 'bytes=$(wc -c < "$target" | tr -d " "); printf "BYTES=%s\\nSHA256=%s\\n" "$bytes" "$sha"'
390
- )
391
- result = self._exec_locked(finalize, timeout=60, output_limit=4096)
392
- if result.timed_out or result.exit_code != 0:
393
- raise TransportError("failed to finalize PTY upload")
394
- fields = self._metadata_fields(result.stdout)
395
- remote_size = int(fields.get("BYTES", transferred))
396
- remote_sha = fields.get("SHA256", digest.hexdigest())
397
- if remote_size != transferred or remote_sha != digest.hexdigest():
398
- raise TransportError("PTY upload integrity check failed")
399
- return TransferResult(transferred, remote_sha)
400
- except Exception:
401
- if self.child.isalive():
402
- with suppress(Exception):
403
- self._exec_locked(
404
- f"rm -rf -- {temp_dir_q}",
405
- timeout=10,
406
- output_limit=1024,
407
- )
408
- raise
409
- finally:
410
- self.operation_lock.release()
411
-
412
- def download(self, remote_path: str, *, chunk_size: int) -> Iterator[bytes]:
413
- self._validate_remote_path(remote_path)
414
- if not isinstance(chunk_size, int) or isinstance(chunk_size, bool) or not 4096 <= chunk_size <= 1024 * 1024:
415
- raise ValueError("chunk_size must be between 4096 and 1048576 bytes")
416
-
417
- def stream() -> Iterator[bytes]:
418
- if not self.operation_lock.acquire(blocking=False):
419
- raise TransportBusy("PTY transport already has an active operation")
420
- digest = hashlib.sha256()
421
- transferred = 0
422
- try:
423
- if not self.child.isalive():
424
- raise TransportError("PTY transport is not alive")
425
- remote_q = shlex.quote(remote_path)
426
- metadata = (
427
- f"target={remote_q}; "
428
- 'if [ ! -e "$target" ] && [ ! -L "$target" ]; then printf "__HB_FILE_NOT_FOUND__\\n"; exit 44; fi; '
429
- 'if [ ! -r "$target" ]; then printf "__HB_PERMISSION_DENIED__\\n"; exit 45; fi; '
430
- "bytes=$(wc -c < \"$target\" | tr -d ' '); "
431
- "if command -v sha256sum >/dev/null 2>&1; then sha=$(sha256sum \"$target\" | awk '{print $1}'); "
432
- "else sha=$(shasum -a 256 \"$target\" | awk '{print $1}'); fi; "
433
- 'printf "BYTES=%s\\nSHA256=%s\\n" "$bytes" "$sha"'
434
- )
435
- result = self._exec_locked(metadata, timeout=60, output_limit=4096)
436
- if result.timed_out or result.exit_code != 0:
437
- if b"__HB_FILE_NOT_FOUND__" in result.stdout:
438
- raise TransportError(f"remote file not found: {remote_path}")
439
- if b"__HB_PERMISSION_DENIED__" in result.stdout:
440
- raise TransportError(f"permission denied reading remote file: {remote_path}")
441
- raise TransportError(f"failed to inspect remote file {remote_path}")
442
- fields = self._metadata_fields(result.stdout)
443
- byte_count = int(fields.get("BYTES", "0"))
444
- expected_sha = fields.get("SHA256", "")
445
- chunk_count = (byte_count + chunk_size - 1) // chunk_size
446
- for index in range(chunk_count):
447
- command = (
448
- f"dd if={remote_q} bs={chunk_size} skip={index} count=1 2>/dev/null | "
449
- "{ base64 --wrap=0 2>/dev/null || base64 | tr -d '\\n'; }"
450
- )
451
- result = self._exec_locked(command, timeout=60, output_limit=chunk_size * 2 + 4096)
452
- if result.timed_out or result.exit_code != 0:
453
- raise TransportError(f"failed to download PTY chunk {index}")
454
- try:
455
- chunk = base64.b64decode(b"".join(result.stdout.split()), validate=True)
456
- except (binascii.Error, ValueError) as exc:
457
- raise TransportError(f"remote chunk {index} contains invalid base64") from exc
458
- transferred += len(chunk)
459
- digest.update(chunk)
460
- yield chunk
461
- if transferred != byte_count or (expected_sha and digest.hexdigest() != expected_sha):
462
- raise TransportError("PTY download integrity check failed")
463
- finally:
464
- self.operation_lock.release()
465
-
466
- return stream()
467
-
468
- @staticmethod
469
- def _validate_remote_path(remote_path: str) -> None:
470
- if not isinstance(remote_path, str) or not remote_path.strip() or "\x00" in remote_path:
471
- raise ValueError("remote_path must be a non-empty path without NUL bytes")
472
-
473
- @staticmethod
474
- def _metadata_fields(output: bytes) -> dict[str, str]:
475
- fields: dict[str, str] = {}
476
- for line in output.decode("utf-8", errors="replace").splitlines():
477
- key, separator, value = line.partition("=")
478
- if separator:
479
- fields[key] = value
480
- return fields
481
-
482
- def close(self) -> None:
483
- self.child.close(force=True)
484
-
485
- def shell_write(self, data: bytes) -> None:
486
- if not isinstance(data, bytes):
487
- raise TypeError("shell data must be bytes")
488
- if not self.child.isalive():
489
- raise TransportError("PTY transport is not alive")
490
- with self._io_lock:
491
- self.child.send(data.decode("utf-8", errors="replace"))
492
-
493
- def shell_read(self, *, timeout: float, max_bytes: int) -> ShellReadResult:
494
- if timeout < 0:
495
- raise ValueError("timeout must be non-negative")
496
- if max_bytes <= 0:
497
- raise ValueError("max_bytes must be positive")
498
- output = bytearray()
499
- deadline = time.monotonic() + timeout
500
- with self._io_lock:
501
- while len(output) < max_bytes:
502
- remaining = max(0.0, deadline - time.monotonic())
503
- try:
504
- chunk = self.child.read_nonblocking(
505
- size=min(4096, max_bytes - len(output)),
506
- timeout=remaining,
507
- )
508
- except (pexpect.TIMEOUT, pexpect.EOF):
509
- break
510
- if not chunk:
511
- break
512
- output.extend(chunk.encode("utf-8", errors="replace"))
513
- if remaining <= 0:
514
- break
515
- return ShellReadResult(bytes(output), self.child.isalive())