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