@neoline/hostbridge 2.0.0 → 2.0.2
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/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/server_control_mcp/__init__.py +2 -1
- package/src/server_control_mcp/client.py +12 -2
- package/src/server_control_mcp/config.py +21 -2
- package/src/server_control_mcp/doctor.py +1 -1
- package/src/server_control_mcp/mock_ssh.py +122 -32
- package/src/server_control_mcp/secrets.py +8 -0
- package/src/server_control_mcp/transports/pty.py +47 -18
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -8,13 +8,14 @@ import sys
|
|
|
8
8
|
from typing import NoReturn
|
|
9
9
|
|
|
10
10
|
__all__ = ["__version__", "ensure_runtime"]
|
|
11
|
-
__version__ = "2.0.
|
|
11
|
+
__version__ = "2.0.2"
|
|
12
12
|
|
|
13
13
|
# (import_name, pip_name) pairs for runtime dependency probing.
|
|
14
14
|
_REQUIRED_DEPENDENCIES = (
|
|
15
15
|
("mcp", "mcp"),
|
|
16
16
|
("pexpect", "pexpect"),
|
|
17
17
|
("cryptography", "cryptography"),
|
|
18
|
+
("asyncssh", "asyncssh"),
|
|
18
19
|
)
|
|
19
20
|
|
|
20
21
|
|
|
@@ -146,8 +146,18 @@ class HostBridgeClient:
|
|
|
146
146
|
def hello(self) -> dict[str, object]:
|
|
147
147
|
return self.request("system.hello", {})
|
|
148
148
|
|
|
149
|
-
def open_session(
|
|
150
|
-
|
|
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
|
+
)
|
|
151
161
|
return SessionInfo.from_dict(result)
|
|
152
162
|
|
|
153
163
|
def exec(
|
|
@@ -9,6 +9,8 @@ import tempfile
|
|
|
9
9
|
from dataclasses import dataclass
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
|
+
from .secrets import SCHEME, encrypt_secret
|
|
13
|
+
|
|
12
14
|
SCHEMA_VERSION = 1
|
|
13
15
|
_HOST_ID = re.compile(r"^[A-Za-z0-9_.-]+$")
|
|
14
16
|
_CONNECTION_STYLES = {"script", "ssh", "command"}
|
|
@@ -83,8 +85,19 @@ def _validate_secrets(value: object, source: Path | str, index: int) -> None:
|
|
|
83
85
|
for name, secret in value.items():
|
|
84
86
|
if not isinstance(name, str):
|
|
85
87
|
raise ValueError(f"{source}.hosts[{index}].secrets contains an invalid name")
|
|
86
|
-
encrypted =
|
|
87
|
-
|
|
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
|
+
)
|
|
88
101
|
if not encrypted and not environment_ref:
|
|
89
102
|
raise ValueError(f"{source}.hosts[{index}] contains plaintext secret {name!r}")
|
|
90
103
|
|
|
@@ -116,6 +129,12 @@ def migrate_config(source: Path | str, destination: Path | str) -> ConfigDocumen
|
|
|
116
129
|
raise ValueError(f"{source_path} contains a non-object host")
|
|
117
130
|
host = dict(raw_host)
|
|
118
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
|
+
}
|
|
119
138
|
migrated_hosts.append(host)
|
|
120
139
|
payload = validate_v1_config(
|
|
121
140
|
{"schema_version": SCHEMA_VERSION, "hosts": migrated_hosts},
|
|
@@ -60,7 +60,7 @@ def _version_diagnostic() -> DiagnosticResult:
|
|
|
60
60
|
"npm": npm.get("version"),
|
|
61
61
|
"python": pyproject.get("project", {}).get("version"),
|
|
62
62
|
}
|
|
63
|
-
status = "pass" if len(set(versions.values())) == 1 and __version__ == "2.0.
|
|
63
|
+
status = "pass" if len(set(versions.values())) == 1 and __version__ == "2.0.2" else "fail"
|
|
64
64
|
return DiagnosticResult("version", status, "version sources agree" if status == "pass" else "version drift detected", versions)
|
|
65
65
|
|
|
66
66
|
|
|
@@ -7,7 +7,9 @@ import re
|
|
|
7
7
|
import shlex
|
|
8
8
|
import sys
|
|
9
9
|
import tempfile
|
|
10
|
+
import threading
|
|
10
11
|
import time
|
|
12
|
+
from contextlib import suppress
|
|
11
13
|
from dataclasses import dataclass, field
|
|
12
14
|
from functools import partial
|
|
13
15
|
from pathlib import Path
|
|
@@ -31,6 +33,7 @@ STDIN_READING_COMMANDS = [
|
|
|
31
33
|
re.compile(r"(?:^|\s)dd\s+.*\bof="),
|
|
32
34
|
re.compile(r"(?:^|\s)scp(?:\s+\S+)*\s+-[A-Za-z]*(?:t|f)[A-Za-z]*(?:\s|$)"),
|
|
33
35
|
]
|
|
36
|
+
MAX_IDLE_BACKEND_SESSIONS = 4
|
|
34
37
|
|
|
35
38
|
|
|
36
39
|
def _to_bytes(chunk: str | bytes) -> bytes:
|
|
@@ -39,6 +42,10 @@ def _to_bytes(chunk: str | bytes) -> bytes:
|
|
|
39
42
|
return chunk.encode("utf-8")
|
|
40
43
|
|
|
41
44
|
|
|
45
|
+
def _command_reads_stdin(command: str | None) -> bool:
|
|
46
|
+
return bool(command and any(pattern.search(command) for pattern in STDIN_READING_COMMANDS))
|
|
47
|
+
|
|
48
|
+
|
|
42
49
|
class HostBridgeSSHServer(asyncssh.SSHServer):
|
|
43
50
|
def __init__(self, authorized_public_key: str) -> None:
|
|
44
51
|
self._authorized_public_key = authorized_public_key.strip()
|
|
@@ -63,22 +70,26 @@ class HostBridgeSSHProcess:
|
|
|
63
70
|
self._timeout = timeout
|
|
64
71
|
self._output_limit = output_limit
|
|
65
72
|
|
|
66
|
-
async def __call__(self, process: asyncssh.SSHServerProcess[
|
|
73
|
+
async def __call__(self, process: asyncssh.SSHServerProcess[bytes]) -> None:
|
|
67
74
|
session_id = self._session_factory.open()
|
|
75
|
+
reusable = False
|
|
68
76
|
try:
|
|
69
|
-
await self._run(process, session_id)
|
|
77
|
+
reusable = await self._run(process, session_id)
|
|
78
|
+
except asyncio.CancelledError:
|
|
79
|
+
self._set_exit_status(process, 255)
|
|
80
|
+
return
|
|
70
81
|
finally:
|
|
71
|
-
self._session_factory.close(session_id)
|
|
82
|
+
self._session_factory.close(session_id, reusable=reusable)
|
|
72
83
|
|
|
73
|
-
async def _run(self, process: asyncssh.SSHServerProcess[
|
|
84
|
+
async def _run(self, process: asyncssh.SSHServerProcess[bytes], session_id: str) -> bool:
|
|
74
85
|
command = process.command
|
|
75
86
|
if not command:
|
|
76
87
|
await self._run_shell(process, session_id)
|
|
77
|
-
return
|
|
88
|
+
return False
|
|
78
89
|
|
|
79
90
|
loop = asyncio.get_running_loop()
|
|
80
91
|
try:
|
|
81
|
-
stdin_data = await self._read_exec_stdin(process)
|
|
92
|
+
stdin_data = await self._read_exec_stdin(process, command)
|
|
82
93
|
result = await loop.run_in_executor(
|
|
83
94
|
None,
|
|
84
95
|
lambda: self._client.exec(
|
|
@@ -91,27 +102,39 @@ class HostBridgeSSHProcess:
|
|
|
91
102
|
),
|
|
92
103
|
)
|
|
93
104
|
if result.stdout:
|
|
94
|
-
|
|
95
|
-
process.stdout.write(output)
|
|
96
|
-
if not output.endswith("\n"):
|
|
97
|
-
process.stdout.write("\n")
|
|
105
|
+
process.stdout.write(result.stdout)
|
|
98
106
|
if result.stderr:
|
|
99
|
-
process.stderr.write(result.stderr
|
|
100
|
-
|
|
107
|
+
process.stderr.write(result.stderr)
|
|
108
|
+
self._set_exit_status(process, 124 if result.timed_out else int(result.exit_code or 0))
|
|
109
|
+
return not result.timed_out
|
|
101
110
|
except Exception as exc:
|
|
102
111
|
print(f"hostbridge mock-ssh exec failed: {exc}", file=sys.stderr, flush=True)
|
|
103
|
-
|
|
104
|
-
|
|
112
|
+
with suppress(Exception):
|
|
113
|
+
process.stderr.write(f"hostbridge mock-ssh error: {exc}\n".encode("utf-8", errors="replace"))
|
|
114
|
+
status = 124 if isinstance(exc, HostBridgeError) and exc.code == "timeout" else 1
|
|
115
|
+
self._set_exit_status(process, status)
|
|
116
|
+
return False
|
|
105
117
|
|
|
106
|
-
|
|
118
|
+
@staticmethod
|
|
119
|
+
def _set_exit_status(process: asyncssh.SSHServerProcess[bytes], status: int) -> None:
|
|
120
|
+
with suppress(Exception):
|
|
121
|
+
process.exit(status)
|
|
122
|
+
|
|
123
|
+
async def _read_exec_stdin(self, process: asyncssh.SSHServerProcess[bytes], command: str) -> bytes | None:
|
|
124
|
+
waits_for_eof = _command_reads_stdin(command)
|
|
107
125
|
if process.stdin.at_eof():
|
|
108
|
-
return b""
|
|
126
|
+
return b"" if waits_for_eof else None
|
|
109
127
|
try:
|
|
110
|
-
first_chunk = await asyncio.wait_for(
|
|
128
|
+
first_chunk = await asyncio.wait_for(
|
|
129
|
+
process.stdin.read(4096),
|
|
130
|
+
timeout=self._timeout if waits_for_eof else EXEC_STDIN_PROBE_TIMEOUT,
|
|
131
|
+
)
|
|
111
132
|
except TimeoutError:
|
|
133
|
+
if waits_for_eof:
|
|
134
|
+
raise HostBridgeError("timeout", "timed out waiting for mock-ssh exec stdin") from None
|
|
112
135
|
return None
|
|
113
136
|
if not first_chunk:
|
|
114
|
-
return b""
|
|
137
|
+
return b"" if waits_for_eof else None
|
|
115
138
|
|
|
116
139
|
first_chunk_bytes = _to_bytes(first_chunk)
|
|
117
140
|
total = len(first_chunk_bytes)
|
|
@@ -129,7 +152,7 @@ class HostBridgeSSHProcess:
|
|
|
129
152
|
chunks.append(chunk_bytes)
|
|
130
153
|
return b"".join(chunks)
|
|
131
154
|
|
|
132
|
-
async def _run_shell(self, process: asyncssh.SSHServerProcess[
|
|
155
|
+
async def _run_shell(self, process: asyncssh.SSHServerProcess[bytes], session_id: str) -> None:
|
|
133
156
|
loop = asyncio.get_running_loop()
|
|
134
157
|
stop = asyncio.Event()
|
|
135
158
|
input_done = asyncio.Event()
|
|
@@ -167,7 +190,7 @@ class HostBridgeSSHProcess:
|
|
|
167
190
|
)
|
|
168
191
|
if result.data:
|
|
169
192
|
idle_after_input = 0
|
|
170
|
-
process.stdout.write(result.data
|
|
193
|
+
process.stdout.write(result.data)
|
|
171
194
|
elif input_done.is_set():
|
|
172
195
|
idle_after_input += 1
|
|
173
196
|
if idle_after_input >= 5:
|
|
@@ -178,7 +201,8 @@ class HostBridgeSSHProcess:
|
|
|
178
201
|
break
|
|
179
202
|
except Exception as exc:
|
|
180
203
|
print(f"hostbridge mock-ssh shell failed: {exc}", file=sys.stderr, flush=True)
|
|
181
|
-
|
|
204
|
+
with suppress(Exception):
|
|
205
|
+
process.stderr.write(f"hostbridge mock-ssh shell error: {exc}\n".encode("utf-8", errors="replace"))
|
|
182
206
|
stop.set()
|
|
183
207
|
|
|
184
208
|
tasks = [asyncio.create_task(pump_input()), asyncio.create_task(pump_output())]
|
|
@@ -186,7 +210,7 @@ class HostBridgeSSHProcess:
|
|
|
186
210
|
for task in tasks:
|
|
187
211
|
task.cancel()
|
|
188
212
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
189
|
-
|
|
213
|
+
self._set_exit_status(process, 0)
|
|
190
214
|
|
|
191
215
|
|
|
192
216
|
@dataclass
|
|
@@ -211,14 +235,74 @@ class HostBridgeSessionFactory:
|
|
|
211
235
|
self._client = client
|
|
212
236
|
self._host_id = host_id
|
|
213
237
|
self._connect_timeout = connect_timeout
|
|
238
|
+
self._lock = threading.Lock()
|
|
239
|
+
self._idle: list[str] = []
|
|
240
|
+
self._leased: set[str] = set()
|
|
241
|
+
self._shutdown = False
|
|
214
242
|
|
|
215
243
|
def open(self) -> str:
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
244
|
+
with self._lock:
|
|
245
|
+
if self._shutdown:
|
|
246
|
+
raise RuntimeError("mock-ssh session factory is shut down")
|
|
247
|
+
if self._idle:
|
|
248
|
+
session_id = self._idle.pop()
|
|
249
|
+
self._leased.add(session_id)
|
|
250
|
+
return session_id
|
|
251
|
+
session = self._client.open_session(
|
|
252
|
+
self._host_id,
|
|
253
|
+
owner="mock-ssh",
|
|
254
|
+
timeout=self._connect_timeout + 5,
|
|
255
|
+
)
|
|
256
|
+
session_id = session.session_id
|
|
257
|
+
with self._lock:
|
|
258
|
+
if not self._shutdown:
|
|
259
|
+
self._leased.add(session_id)
|
|
260
|
+
return session_id
|
|
261
|
+
self._close_backend(session_id)
|
|
262
|
+
raise RuntimeError("mock-ssh session factory is shut down")
|
|
263
|
+
|
|
264
|
+
def close(self, session_id: str, *, reusable: bool = True) -> None:
|
|
265
|
+
should_close = False
|
|
266
|
+
with self._lock:
|
|
267
|
+
if session_id not in self._leased:
|
|
268
|
+
return
|
|
269
|
+
self._leased.remove(session_id)
|
|
270
|
+
if reusable and not self._shutdown and len(self._idle) < MAX_IDLE_BACKEND_SESSIONS:
|
|
271
|
+
self._idle.append(session_id)
|
|
272
|
+
else:
|
|
273
|
+
should_close = True
|
|
274
|
+
if not should_close:
|
|
275
|
+
return
|
|
276
|
+
self._close_backend(session_id)
|
|
277
|
+
|
|
278
|
+
def shutdown(self) -> None:
|
|
279
|
+
with self._lock:
|
|
280
|
+
self._shutdown = True
|
|
281
|
+
session_ids = [*self._idle, *self._leased]
|
|
282
|
+
self._idle.clear()
|
|
283
|
+
self._leased.clear()
|
|
284
|
+
for session_id in session_ids:
|
|
285
|
+
self._close_backend(session_id)
|
|
286
|
+
|
|
287
|
+
def _close_backend(self, session_id: str) -> None:
|
|
288
|
+
last_error: Exception | None = None
|
|
289
|
+
for attempt in range(2):
|
|
290
|
+
try:
|
|
291
|
+
self._client.close_session(session_id, owner="mock-ssh", force=True)
|
|
292
|
+
return
|
|
293
|
+
except HostBridgeError as exc:
|
|
294
|
+
if exc.code == "not_found":
|
|
295
|
+
return
|
|
296
|
+
last_error = exc
|
|
297
|
+
except Exception as exc:
|
|
298
|
+
last_error = exc
|
|
299
|
+
if attempt == 0:
|
|
300
|
+
time.sleep(0.05)
|
|
301
|
+
print(
|
|
302
|
+
f"hostbridge mock-ssh failed to close backend session {session_id}: {last_error}",
|
|
303
|
+
file=sys.stderr,
|
|
304
|
+
flush=True,
|
|
305
|
+
)
|
|
222
306
|
|
|
223
307
|
|
|
224
308
|
class HostBridgeSFTPServer(asyncssh.SFTPServer):
|
|
@@ -476,17 +560,19 @@ print(json.dumps(items))
|
|
|
476
560
|
except HostBridgeError as exc:
|
|
477
561
|
raise self._sftp_error(exc) from exc
|
|
478
562
|
output = result.stdout.decode("utf-8", errors="replace")
|
|
563
|
+
stderr = result.stderr.decode("utf-8", errors="replace")
|
|
479
564
|
if result.timed_out:
|
|
480
565
|
print(f"hostbridge mock-ssh sftp command timed out: {command}", file=sys.stderr, flush=True)
|
|
481
566
|
raise asyncssh.SFTPFailure("remote command timed out")
|
|
482
567
|
if result.exit_code not in (0, None):
|
|
568
|
+
error_output = "\n".join(part for part in (output, stderr) if part).strip()
|
|
483
569
|
error = self._sftp_error(
|
|
484
|
-
HostBridgeError("remote_error",
|
|
570
|
+
HostBridgeError("remote_error", error_output or f"remote command exited {result.exit_code}")
|
|
485
571
|
)
|
|
486
572
|
if isinstance(error, asyncssh.SFTPNoSuchFile):
|
|
487
573
|
raise error
|
|
488
574
|
print(
|
|
489
|
-
f"hostbridge mock-ssh sftp command failed exit={result.exit_code}: {command}\n{
|
|
575
|
+
f"hostbridge mock-ssh sftp command failed exit={result.exit_code}: {command}\n{error_output}",
|
|
490
576
|
file=sys.stderr,
|
|
491
577
|
flush=True,
|
|
492
578
|
)
|
|
@@ -568,6 +654,7 @@ async def create_server_on_available_port(
|
|
|
568
654
|
process_factory=process_factory,
|
|
569
655
|
sftp_factory=sftp_factory,
|
|
570
656
|
allow_scp=True,
|
|
657
|
+
encoding=None,
|
|
571
658
|
)
|
|
572
659
|
return server, int(candidate)
|
|
573
660
|
except OSError as exc:
|
|
@@ -718,8 +805,11 @@ async def serve_mock_ssh(
|
|
|
718
805
|
print(f"ssh config: {installed_config_path}", flush=True)
|
|
719
806
|
print(f"identity file: {client_key_path}", flush=True)
|
|
720
807
|
print("supports SSH exec, shell, SFTP, and SCP; press Ctrl-C to stop", flush=True)
|
|
721
|
-
|
|
722
|
-
|
|
808
|
+
try:
|
|
809
|
+
await server.wait_closed()
|
|
810
|
+
return 0
|
|
811
|
+
finally:
|
|
812
|
+
session_factory.shutdown()
|
|
723
813
|
|
|
724
814
|
|
|
725
815
|
def run_mock_ssh(**kwargs: object) -> int:
|
|
@@ -43,6 +43,14 @@ def encrypt_secret(value: str) -> dict[str, str]:
|
|
|
43
43
|
def decrypt_secret(payload: Any) -> str:
|
|
44
44
|
if not isinstance(payload, dict):
|
|
45
45
|
raise ValueError("encrypted secret must be an object")
|
|
46
|
+
if set(payload) == {"env"}:
|
|
47
|
+
variable = payload.get("env")
|
|
48
|
+
if not isinstance(variable, str) or not variable:
|
|
49
|
+
raise ValueError("secret environment variable name must not be empty")
|
|
50
|
+
value = os.environ.get(variable)
|
|
51
|
+
if value is None:
|
|
52
|
+
raise ValueError(f"secret environment variable is not set: {variable}")
|
|
53
|
+
return value
|
|
46
54
|
if payload.get("scheme") != SCHEME:
|
|
47
55
|
raise ValueError(f"unsupported secret scheme: {payload.get('scheme')}")
|
|
48
56
|
ciphertext = payload.get("ciphertext")
|
|
@@ -20,6 +20,7 @@ from ..hosts import HostConfig
|
|
|
20
20
|
from .base import ExecResult, ShellReadResult, TransferResult, TransportBusy, TransportCapabilities, TransportError
|
|
21
21
|
|
|
22
22
|
_REMOTE_BASE64_DECODE = "{ base64 --decode 2>/dev/null || base64 -d 2>/dev/null || base64 -D; }"
|
|
23
|
+
PTY_BASE64_LINE_LENGTH = 3072
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
class ChildProcess(Protocol):
|
|
@@ -60,6 +61,10 @@ def _default_child_factory(command: str, args: list[str], timeout: int | float)
|
|
|
60
61
|
|
|
61
62
|
|
|
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
|
+
|
|
63
68
|
remainder = b""
|
|
64
69
|
for chunk in chunks:
|
|
65
70
|
if not isinstance(chunk, bytes):
|
|
@@ -67,10 +72,10 @@ def _iter_base64(chunks: Iterable[bytes]) -> Iterator[str]:
|
|
|
67
72
|
data = remainder + chunk
|
|
68
73
|
complete = len(data) - (len(data) % 3)
|
|
69
74
|
if complete:
|
|
70
|
-
yield base64.b64encode(data[:complete]).decode("ascii")
|
|
75
|
+
yield from split_line(base64.b64encode(data[:complete]).decode("ascii"))
|
|
71
76
|
remainder = data[complete:]
|
|
72
77
|
if remainder:
|
|
73
|
-
yield base64.b64encode(remainder).decode("ascii")
|
|
78
|
+
yield from split_line(base64.b64encode(remainder).decode("ascii"))
|
|
74
79
|
|
|
75
80
|
|
|
76
81
|
def _marker_text(value: object) -> str:
|
|
@@ -158,13 +163,12 @@ class PtyTransport:
|
|
|
158
163
|
raise TransportError("PTY transport is not alive")
|
|
159
164
|
if stdin is not None:
|
|
160
165
|
stdin_path = self._stage_stdin_locked(stdin, timeout=timeout)
|
|
161
|
-
|
|
162
|
-
return self._exec_locked(command, timeout=timeout, output_limit=output_limit)
|
|
166
|
+
return self._exec_locked(command, timeout=timeout, output_limit=output_limit, stdin_path=stdin_path)
|
|
163
167
|
finally:
|
|
164
168
|
if stdin_path is not None and self.child.isalive():
|
|
165
169
|
with suppress(Exception):
|
|
166
170
|
self._exec_locked(
|
|
167
|
-
f"rm -f -- {shlex.quote(stdin_path)}",
|
|
171
|
+
f"trap - EXIT HUP INT TERM; rm -f -- {shlex.quote(stdin_path)} {shlex.quote(f'{stdin_path}.b64')}",
|
|
168
172
|
timeout=min(timeout, 10),
|
|
169
173
|
output_limit=1024,
|
|
170
174
|
)
|
|
@@ -173,32 +177,50 @@ class PtyTransport:
|
|
|
173
177
|
def _stage_stdin_locked(self, chunks: Iterable[bytes], *, timeout: float) -> str:
|
|
174
178
|
token = self._token_factory()
|
|
175
179
|
path = f"/tmp/hostbridge.stdin.{token}"
|
|
180
|
+
base64_path = f"{path}.b64"
|
|
176
181
|
heredoc = f"__{self._marker_prefix}_STDIN_{token}__"
|
|
177
182
|
done_pattern = re.compile(rf"__{self._marker_prefix}_STDIN_DONE_{re.escape(token)}__:(\d+)")
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
self.child.send(
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
|
188
202
|
match = re.search(r":(\d+)", _marker_text(self.child.after))
|
|
189
203
|
if match is None or int(match.group(1)) != 0:
|
|
190
204
|
raise TransportError("failed to stage PTY stdin")
|
|
191
205
|
return path
|
|
192
206
|
|
|
193
|
-
def _exec_locked(
|
|
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:
|
|
194
215
|
token = self._token_factory()
|
|
195
216
|
start_marker = f"__{self._marker_prefix}_START_{token}__"
|
|
196
217
|
exit_pattern = re.compile(rf"__{self._marker_prefix}_EXIT_{re.escape(token)}__:(\d+)")
|
|
197
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 ""
|
|
198
220
|
remote_line = (
|
|
199
221
|
f"__scm_cmd=$(printf %s {shlex.quote(encoded_command)} | {_REMOTE_BASE64_DECODE}); "
|
|
200
222
|
f"printf '\n{start_marker}\n'; "
|
|
201
|
-
f"{self.remote_shell} -lc \"$__scm_cmd\"; "
|
|
223
|
+
f"{self.remote_shell} -lc \"$__scm_cmd\"{stdin_redirect}; "
|
|
202
224
|
"__scm_status=$?; "
|
|
203
225
|
f"printf '\n__{self._marker_prefix}_EXIT_{token}__:%s\n' \"$__scm_status\""
|
|
204
226
|
)
|
|
@@ -321,13 +343,20 @@ class PtyTransport:
|
|
|
321
343
|
raise TransportError("PTY transport is not alive")
|
|
322
344
|
remote_q = shlex.quote(remote_path)
|
|
323
345
|
metadata = (
|
|
324
|
-
f"target={remote_q};
|
|
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 ' '); "
|
|
325
350
|
"if command -v sha256sum >/dev/null 2>&1; then sha=$(sha256sum \"$target\" | awk '{print $1}'); "
|
|
326
351
|
"else sha=$(shasum -a 256 \"$target\" | awk '{print $1}'); fi; "
|
|
327
352
|
'printf "BYTES=%s\\nSHA256=%s\\n" "$bytes" "$sha"'
|
|
328
353
|
)
|
|
329
354
|
result = self._exec_locked(metadata, timeout=60, output_limit=4096)
|
|
330
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}")
|
|
331
360
|
raise TransportError(f"failed to inspect remote file {remote_path}")
|
|
332
361
|
fields = self._metadata_fields(result.stdout)
|
|
333
362
|
byte_count = int(fields.get("BYTES", "0"))
|