@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
|
@@ -4,15 +4,62 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import hashlib
|
|
7
|
+
import math
|
|
7
8
|
import uuid
|
|
8
|
-
from collections
|
|
9
|
+
from collections import deque
|
|
10
|
+
from collections.abc import AsyncGenerator, Sequence
|
|
9
11
|
from contextlib import suppress
|
|
10
12
|
from dataclasses import dataclass
|
|
11
|
-
from pathlib import Path
|
|
12
13
|
|
|
13
14
|
import asyncssh
|
|
14
15
|
|
|
15
|
-
from
|
|
16
|
+
from ..async_lifecycle import (
|
|
17
|
+
finish_task_before_cancellation,
|
|
18
|
+
finish_task_despite_cancellation,
|
|
19
|
+
stop_input_after_peer_eof,
|
|
20
|
+
)
|
|
21
|
+
from .base import (
|
|
22
|
+
ByteStream,
|
|
23
|
+
ExecResult,
|
|
24
|
+
ShellAttachment,
|
|
25
|
+
ShellReadResult,
|
|
26
|
+
ShellTerminalResult,
|
|
27
|
+
TransferResult,
|
|
28
|
+
TransportCapabilities,
|
|
29
|
+
TransportError,
|
|
30
|
+
iterate_byte_stream,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _CombinedOutputBuffer:
|
|
35
|
+
def __init__(self, limit: int) -> None:
|
|
36
|
+
self._limit = limit
|
|
37
|
+
self._segments: deque[tuple[bool, bytes]] = deque()
|
|
38
|
+
self._size = 0
|
|
39
|
+
|
|
40
|
+
def append(self, chunk: bytes, *, stderr: bool) -> None:
|
|
41
|
+
if not chunk:
|
|
42
|
+
return
|
|
43
|
+
self._segments.append((stderr, bytes(chunk)))
|
|
44
|
+
self._size += len(chunk)
|
|
45
|
+
excess = self._size - self._limit
|
|
46
|
+
while excess > 0:
|
|
47
|
+
segment_stderr, segment = self._segments[0]
|
|
48
|
+
if len(segment) <= excess:
|
|
49
|
+
self._segments.popleft()
|
|
50
|
+
self._size -= len(segment)
|
|
51
|
+
excess -= len(segment)
|
|
52
|
+
else:
|
|
53
|
+
self._segments[0] = (segment_stderr, segment[excess:])
|
|
54
|
+
self._size -= excess
|
|
55
|
+
excess = 0
|
|
56
|
+
|
|
57
|
+
def outputs(self) -> tuple[bytes, bytes]:
|
|
58
|
+
stdout = bytearray()
|
|
59
|
+
stderr = bytearray()
|
|
60
|
+
for is_stderr, segment in self._segments:
|
|
61
|
+
(stderr if is_stderr else stdout).extend(segment)
|
|
62
|
+
return bytes(stdout), bytes(stderr)
|
|
16
63
|
|
|
17
64
|
|
|
18
65
|
@dataclass(frozen=True, slots=True)
|
|
@@ -21,21 +68,41 @@ class SshConnectionConfig:
|
|
|
21
68
|
port: int = 22
|
|
22
69
|
username: str | None = None
|
|
23
70
|
client_keys: Sequence[str] = ()
|
|
24
|
-
known_hosts: str | None =
|
|
71
|
+
known_hosts: str | None | tuple[()] = ()
|
|
72
|
+
tunnel: str | None = None
|
|
73
|
+
password: str | None = None
|
|
74
|
+
|
|
25
75
|
|
|
76
|
+
DEFAULT_SFTP_CLEANUP_TIMEOUT_SECONDS = 5.0
|
|
26
77
|
|
|
27
|
-
|
|
78
|
+
|
|
79
|
+
class AsyncSshConnection:
|
|
28
80
|
capabilities = TransportCapabilities(
|
|
29
81
|
separate_stderr=True,
|
|
30
82
|
native_binary_transfer=True,
|
|
31
83
|
parallel_channels=8,
|
|
32
84
|
)
|
|
33
85
|
|
|
34
|
-
def __init__(
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
connection: asyncssh.SSHClientConnection,
|
|
89
|
+
*,
|
|
90
|
+
sftp_cleanup_timeout: float = DEFAULT_SFTP_CLEANUP_TIMEOUT_SECONDS,
|
|
91
|
+
) -> None:
|
|
92
|
+
if (
|
|
93
|
+
not isinstance(sftp_cleanup_timeout, (int, float))
|
|
94
|
+
or isinstance(sftp_cleanup_timeout, bool)
|
|
95
|
+
or not math.isfinite(sftp_cleanup_timeout)
|
|
96
|
+
or sftp_cleanup_timeout <= 0
|
|
97
|
+
):
|
|
98
|
+
raise ValueError("sftp_cleanup_timeout must be a finite positive number")
|
|
35
99
|
self._connection = connection
|
|
100
|
+
self._sftp_cleanup_timeout = float(sftp_cleanup_timeout)
|
|
101
|
+
self._shells: dict[str, asyncssh.SSHClientProcess[bytes]] = {}
|
|
102
|
+
self._shell_creation_lock = asyncio.Lock()
|
|
36
103
|
|
|
37
104
|
@classmethod
|
|
38
|
-
async def connect(cls, config: SshConnectionConfig) ->
|
|
105
|
+
async def connect(cls, config: SshConnectionConfig, *, connect_timeout: float | None = None) -> AsyncSshConnection:
|
|
39
106
|
if not config.host.strip():
|
|
40
107
|
raise ValueError("SSH host must not be empty")
|
|
41
108
|
if not 1 <= config.port <= 65535:
|
|
@@ -46,6 +113,9 @@ class SshTransport:
|
|
|
46
113
|
username=config.username,
|
|
47
114
|
client_keys=list(config.client_keys),
|
|
48
115
|
known_hosts=config.known_hosts,
|
|
116
|
+
tunnel=config.tunnel,
|
|
117
|
+
password=config.password,
|
|
118
|
+
connect_timeout=connect_timeout,
|
|
49
119
|
encoding=None,
|
|
50
120
|
)
|
|
51
121
|
return cls(connection)
|
|
@@ -54,7 +124,7 @@ class SshTransport:
|
|
|
54
124
|
self,
|
|
55
125
|
command: str,
|
|
56
126
|
*,
|
|
57
|
-
stdin:
|
|
127
|
+
stdin: ByteStream | None,
|
|
58
128
|
timeout: float,
|
|
59
129
|
output_limit: int,
|
|
60
130
|
) -> ExecResult:
|
|
@@ -64,121 +134,181 @@ class SshTransport:
|
|
|
64
134
|
raise ValueError("timeout must be positive")
|
|
65
135
|
if output_limit <= 0:
|
|
66
136
|
raise ValueError("output_limit must be positive")
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
137
|
+
open_task: asyncio.Future[asyncssh.SSHClientProcess[bytes]] = asyncio.ensure_future(
|
|
138
|
+
self._connection.create_process(command, encoding=None)
|
|
139
|
+
)
|
|
140
|
+
try:
|
|
141
|
+
process = await open_task
|
|
142
|
+
except asyncio.CancelledError:
|
|
143
|
+
if not open_task.done():
|
|
144
|
+
open_task.cancel()
|
|
145
|
+
await asyncio.gather(open_task, return_exceptions=True)
|
|
146
|
+
raise
|
|
147
|
+
output = _CombinedOutputBuffer(output_limit)
|
|
148
|
+
stdout_task = asyncio.create_task(self._read_stream(process.stdout, output, stderr=False))
|
|
149
|
+
stderr_task = asyncio.create_task(self._read_stream(process.stderr, output, stderr=True))
|
|
70
150
|
input_task = asyncio.create_task(self._write_stdin(process, stdin))
|
|
151
|
+
wait_task = asyncio.create_task(process.wait_closed())
|
|
152
|
+
tasks = (stdout_task, stderr_task, input_task, wait_task)
|
|
71
153
|
try:
|
|
72
|
-
await asyncio.wait_for(
|
|
73
|
-
await
|
|
74
|
-
stdout, stderr =
|
|
154
|
+
await asyncio.wait_for(asyncio.gather(asyncio.shield(wait_task), input_task), timeout=timeout)
|
|
155
|
+
await asyncio.gather(stdout_task, stderr_task)
|
|
156
|
+
stdout, stderr = output.outputs()
|
|
75
157
|
return ExecResult(process.exit_status, stdout, stderr)
|
|
76
158
|
except TimeoutError:
|
|
77
|
-
|
|
159
|
+
cleanup = asyncio.create_task(self._abort_process(process, tasks))
|
|
160
|
+
await finish_task_before_cancellation(cleanup)
|
|
161
|
+
stdout, stderr = output.outputs()
|
|
162
|
+
return ExecResult(None, stdout, stderr, timed_out=True)
|
|
163
|
+
except BaseException:
|
|
164
|
+
cleanup = asyncio.create_task(self._abort_process(process, tasks))
|
|
165
|
+
await finish_task_despite_cancellation(cleanup)
|
|
166
|
+
raise
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
async def _abort_process(
|
|
170
|
+
process: asyncssh.SSHClientProcess[bytes],
|
|
171
|
+
tasks: tuple[asyncio.Task[None], asyncio.Task[None], asyncio.Task[None], asyncio.Task[None]],
|
|
172
|
+
) -> None:
|
|
173
|
+
process.terminate()
|
|
174
|
+
process.close()
|
|
175
|
+
wait_task = tasks[-1]
|
|
176
|
+
try:
|
|
177
|
+
await asyncio.wait_for(asyncio.shield(wait_task), timeout=1)
|
|
178
|
+
except Exception:
|
|
179
|
+
process.kill()
|
|
180
|
+
process.close()
|
|
78
181
|
with suppress(Exception):
|
|
79
|
-
await
|
|
80
|
-
|
|
182
|
+
await asyncio.wait_for(asyncio.shield(wait_task), timeout=1)
|
|
183
|
+
for task in tasks:
|
|
184
|
+
if not task.done():
|
|
81
185
|
task.cancel()
|
|
82
|
-
|
|
83
|
-
return ExecResult(None, b"", b"", timed_out=True)
|
|
186
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
84
187
|
|
|
85
188
|
@staticmethod
|
|
86
|
-
async def _write_stdin(process: asyncssh.SSHClientProcess[bytes], stdin:
|
|
189
|
+
async def _write_stdin(process: asyncssh.SSHClientProcess[bytes], stdin: ByteStream | None) -> None:
|
|
87
190
|
if stdin is not None:
|
|
88
|
-
for chunk in stdin:
|
|
89
|
-
if not isinstance(chunk, bytes):
|
|
90
|
-
raise TypeError("stdin chunks must be bytes")
|
|
191
|
+
async for chunk in iterate_byte_stream(stdin, label="stdin"):
|
|
91
192
|
process.stdin.write(chunk)
|
|
92
193
|
await process.stdin.drain()
|
|
93
194
|
process.stdin.write_eof()
|
|
94
195
|
|
|
95
196
|
@staticmethod
|
|
96
|
-
async def _read_stream(stream,
|
|
97
|
-
output = bytearray()
|
|
197
|
+
async def _read_stream(stream, output: _CombinedOutputBuffer, *, stderr: bool) -> None:
|
|
98
198
|
while True:
|
|
99
199
|
chunk = await stream.read(64 * 1024)
|
|
100
200
|
if not chunk:
|
|
101
201
|
break
|
|
102
|
-
output.
|
|
103
|
-
if len(output) > output_limit:
|
|
104
|
-
del output[: len(output) - output_limit]
|
|
105
|
-
return bytes(output)
|
|
202
|
+
output.append(bytes(chunk), stderr=stderr)
|
|
106
203
|
|
|
107
|
-
async def
|
|
204
|
+
async def upload(
|
|
108
205
|
self,
|
|
109
|
-
|
|
206
|
+
chunks: ByteStream,
|
|
110
207
|
remote_path: str,
|
|
111
208
|
*,
|
|
112
|
-
|
|
113
|
-
|
|
209
|
+
mode: int,
|
|
210
|
+
expected_size: int | None,
|
|
211
|
+
expected_sha256: str | None,
|
|
114
212
|
) -> TransferResult:
|
|
115
|
-
source = Path(local_path)
|
|
116
|
-
self._validate_chunk_size(chunk_size)
|
|
117
213
|
self._validate_remote_path(remote_path)
|
|
118
214
|
temporary = f"{remote_path}.hostbridge-{uuid.uuid4().hex}.tmp"
|
|
119
215
|
digest = hashlib.sha256()
|
|
120
216
|
transferred = 0
|
|
121
217
|
sftp = await self._connection.start_sftp_client()
|
|
122
218
|
try:
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
219
|
+
async with sftp.open(temporary, "wb", encoding=None) as remote_file:
|
|
220
|
+
async for chunk in iterate_byte_stream(chunks, label="upload"):
|
|
221
|
+
await remote_file.write(chunk)
|
|
222
|
+
digest.update(chunk)
|
|
223
|
+
transferred += len(chunk)
|
|
224
|
+
if expected_size is not None and transferred != expected_size:
|
|
225
|
+
raise TransportError(f"upload size mismatch: expected {expected_size}, received {transferred}")
|
|
226
|
+
actual_sha256 = digest.hexdigest()
|
|
227
|
+
if expected_sha256 is not None and actual_sha256.lower() != expected_sha256.lower():
|
|
228
|
+
raise TransportError(
|
|
229
|
+
f"upload SHA-256 mismatch: expected {expected_sha256.lower()}, received {actual_sha256}"
|
|
230
|
+
)
|
|
135
231
|
await sftp.chmod(temporary, mode)
|
|
136
232
|
await sftp.rename(temporary, remote_path)
|
|
137
|
-
except
|
|
138
|
-
|
|
139
|
-
|
|
233
|
+
except BaseException as primary_error:
|
|
234
|
+
cleanup = asyncio.create_task(self._finish_sftp(sftp, temporary=temporary))
|
|
235
|
+
try:
|
|
236
|
+
await finish_task_despite_cancellation(cleanup)
|
|
237
|
+
except BaseException as cleanup_error:
|
|
238
|
+
primary_error.add_note(f"SFTP temporary cleanup failed: {cleanup_error}")
|
|
140
239
|
raise
|
|
141
|
-
|
|
142
|
-
|
|
240
|
+
cleanup = asyncio.create_task(self._finish_sftp(sftp))
|
|
241
|
+
await finish_task_before_cancellation(cleanup)
|
|
143
242
|
return TransferResult(transferred, digest.hexdigest())
|
|
144
243
|
|
|
145
|
-
async def
|
|
146
|
-
self,
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
244
|
+
async def _finish_sftp(self, sftp: asyncssh.SFTPClient, *, temporary: str | None = None) -> None:
|
|
245
|
+
removed = await self._close_sftp_channel(sftp, temporary=temporary)
|
|
246
|
+
if temporary is None or removed:
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
replacement: asyncssh.SFTPClient | None = None
|
|
250
|
+
try:
|
|
251
|
+
async with asyncio.timeout(self._sftp_cleanup_timeout):
|
|
252
|
+
replacement = await self._connection.start_sftp_client()
|
|
253
|
+
except Exception as exc:
|
|
254
|
+
self._abort_connection()
|
|
255
|
+
raise TransportError(f"could not reopen SFTP to remove temporary file {temporary!r}") from exc
|
|
256
|
+
|
|
257
|
+
if await self._close_sftp_channel(replacement, temporary=temporary):
|
|
258
|
+
return
|
|
259
|
+
self._abort_connection()
|
|
260
|
+
raise TransportError(f"could not remove SFTP temporary file {temporary!r}")
|
|
261
|
+
|
|
262
|
+
async def _close_sftp_channel(self, sftp: asyncssh.SFTPClient, *, temporary: str | None) -> bool:
|
|
263
|
+
exit_sent = False
|
|
264
|
+
removed = temporary is None
|
|
265
|
+
try:
|
|
266
|
+
async with asyncio.timeout(self._sftp_cleanup_timeout):
|
|
267
|
+
if temporary is not None:
|
|
268
|
+
try:
|
|
269
|
+
await sftp.remove(temporary)
|
|
270
|
+
except asyncssh.SFTPNoSuchFile:
|
|
271
|
+
removed = True
|
|
272
|
+
else:
|
|
273
|
+
removed = True
|
|
274
|
+
sftp.exit()
|
|
275
|
+
exit_sent = True
|
|
276
|
+
with suppress(Exception):
|
|
277
|
+
await sftp.wait_closed()
|
|
278
|
+
except Exception:
|
|
279
|
+
pass
|
|
280
|
+
finally:
|
|
281
|
+
if not exit_sent:
|
|
282
|
+
with suppress(Exception):
|
|
283
|
+
sftp.exit()
|
|
284
|
+
return removed
|
|
285
|
+
|
|
286
|
+
def _abort_connection(self) -> None:
|
|
287
|
+
try:
|
|
288
|
+
self._connection.abort()
|
|
289
|
+
except Exception as exc:
|
|
290
|
+
try:
|
|
291
|
+
self._connection.close()
|
|
292
|
+
except Exception as fallback_error:
|
|
293
|
+
fallback_error.add_note(f"SSH connection abort failed: {exc}")
|
|
294
|
+
raise TransportError(
|
|
295
|
+
"could not terminate SSH connection after SFTP cleanup failure"
|
|
296
|
+
) from fallback_error
|
|
297
|
+
|
|
298
|
+
async def download(self, remote_path: str, *, chunk_size: int) -> AsyncGenerator[bytes, None]:
|
|
153
299
|
self._validate_chunk_size(chunk_size)
|
|
154
300
|
self._validate_remote_path(remote_path)
|
|
155
|
-
temporary = destination.with_name(f".{destination.name}.hostbridge-{uuid.uuid4().hex}.tmp")
|
|
156
|
-
digest = hashlib.sha256()
|
|
157
|
-
transferred = 0
|
|
158
301
|
sftp = await self._connection.start_sftp_client()
|
|
159
302
|
try:
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if not raw_chunk:
|
|
167
|
-
break
|
|
168
|
-
chunk = raw_chunk.encode("utf-8") if isinstance(raw_chunk, str) else bytes(raw_chunk)
|
|
169
|
-
await asyncio.to_thread(local_file.write, chunk)
|
|
170
|
-
digest.update(chunk)
|
|
171
|
-
transferred += len(chunk)
|
|
172
|
-
finally:
|
|
173
|
-
local_file.close()
|
|
174
|
-
temporary.chmod(0o600)
|
|
175
|
-
temporary.replace(destination)
|
|
176
|
-
except Exception:
|
|
177
|
-
temporary.unlink(missing_ok=True)
|
|
178
|
-
raise
|
|
303
|
+
async with sftp.open(remote_path, "rb", encoding=None) as remote_file:
|
|
304
|
+
while True:
|
|
305
|
+
raw_chunk = await remote_file.read(chunk_size)
|
|
306
|
+
if not raw_chunk:
|
|
307
|
+
break
|
|
308
|
+
yield raw_chunk.encode("utf-8") if isinstance(raw_chunk, str) else bytes(raw_chunk)
|
|
179
309
|
finally:
|
|
180
|
-
|
|
181
|
-
|
|
310
|
+
cleanup = asyncio.create_task(self._finish_sftp(sftp))
|
|
311
|
+
await finish_task_before_cancellation(cleanup)
|
|
182
312
|
|
|
183
313
|
@staticmethod
|
|
184
314
|
def _validate_chunk_size(chunk_size: int) -> None:
|
|
@@ -190,6 +320,107 @@ class SshTransport:
|
|
|
190
320
|
if not isinstance(remote_path, str) or not remote_path.strip() or "\x00" in remote_path:
|
|
191
321
|
raise ValueError("remote_path must be a non-empty path without NUL bytes")
|
|
192
322
|
|
|
323
|
+
async def shell_write(self, shell_id: str, data: bytes) -> None:
|
|
324
|
+
if not isinstance(data, bytes):
|
|
325
|
+
raise TypeError("shell data must be bytes")
|
|
326
|
+
process = await self._ensure_shell(shell_id)
|
|
327
|
+
process.stdin.write(data)
|
|
328
|
+
await process.stdin.drain()
|
|
329
|
+
|
|
330
|
+
async def shell_read(self, shell_id: str, *, timeout: float | None, max_bytes: int) -> ShellReadResult:
|
|
331
|
+
if timeout is not None and timeout < 0:
|
|
332
|
+
raise ValueError("timeout must be non-negative")
|
|
333
|
+
if max_bytes <= 0:
|
|
334
|
+
raise ValueError("max_bytes must be positive")
|
|
335
|
+
process = await self._ensure_shell(shell_id)
|
|
336
|
+
if timeout is None:
|
|
337
|
+
data = await process.stdout.read(max_bytes)
|
|
338
|
+
else:
|
|
339
|
+
try:
|
|
340
|
+
data = await asyncio.wait_for(process.stdout.read(max_bytes), timeout=timeout)
|
|
341
|
+
except TimeoutError:
|
|
342
|
+
data = b""
|
|
343
|
+
return ShellReadResult(bytes(data), process.exit_status is None and not self._connection.is_closed())
|
|
344
|
+
|
|
345
|
+
async def attach_shell(self, shell_id: str, stdin: ByteStream, *, max_bytes: int) -> ShellAttachment:
|
|
346
|
+
process = await self._ensure_shell(shell_id)
|
|
347
|
+
terminal = asyncio.get_running_loop().create_future()
|
|
348
|
+
|
|
349
|
+
async def send_input() -> None:
|
|
350
|
+
async for chunk in iterate_byte_stream(stdin, label="shell input"):
|
|
351
|
+
if len(chunk) > max_bytes:
|
|
352
|
+
raise TransportError(f"shell input chunk exceeds {max_bytes} bytes")
|
|
353
|
+
process.stdin.write(chunk)
|
|
354
|
+
await process.stdin.drain()
|
|
355
|
+
process.stdin.write_eof()
|
|
356
|
+
|
|
357
|
+
async def output() -> AsyncGenerator[bytes, None]:
|
|
358
|
+
input_task = asyncio.create_task(send_input(), name=f"hostbridge-native-shell-input-{shell_id}")
|
|
359
|
+
try:
|
|
360
|
+
while data := await process.stdout.read(max_bytes):
|
|
361
|
+
yield bytes(data)
|
|
362
|
+
await stop_input_after_peer_eof(input_task)
|
|
363
|
+
await process.wait_closed()
|
|
364
|
+
terminal.set_result(_ssh_shell_terminal(process))
|
|
365
|
+
except BaseException as exc:
|
|
366
|
+
if not terminal.done():
|
|
367
|
+
terminal.set_exception(exc)
|
|
368
|
+
terminal.exception()
|
|
369
|
+
raise
|
|
370
|
+
finally:
|
|
371
|
+
if not input_task.done():
|
|
372
|
+
input_task.cancel()
|
|
373
|
+
cleanup = asyncio.gather(input_task, return_exceptions=True)
|
|
374
|
+
await finish_task_before_cancellation(cleanup)
|
|
375
|
+
|
|
376
|
+
return ShellAttachment(output(), terminal)
|
|
377
|
+
|
|
378
|
+
async def _ensure_shell(self, shell_id: str) -> asyncssh.SSHClientProcess[bytes]:
|
|
379
|
+
if not shell_id:
|
|
380
|
+
raise ValueError("shell_id must not be empty")
|
|
381
|
+
async with self._shell_creation_lock:
|
|
382
|
+
shell = self._shells.get(shell_id)
|
|
383
|
+
if shell is None or shell.exit_status is not None:
|
|
384
|
+
shell = await self._connection.create_process(term_type="xterm", encoding=None)
|
|
385
|
+
self._shells[shell_id] = shell
|
|
386
|
+
return shell
|
|
387
|
+
|
|
388
|
+
async def shell_close(self, shell_id: str) -> None:
|
|
389
|
+
async with self._shell_creation_lock:
|
|
390
|
+
shell = self._shells.pop(shell_id, None)
|
|
391
|
+
if shell is None:
|
|
392
|
+
return
|
|
393
|
+
cleanup = asyncio.create_task(self._close_shell_process(shell))
|
|
394
|
+
await finish_task_before_cancellation(cleanup)
|
|
395
|
+
|
|
396
|
+
@staticmethod
|
|
397
|
+
async def _close_shell_process(shell: asyncssh.SSHClientProcess[bytes]) -> None:
|
|
398
|
+
shell.terminate()
|
|
399
|
+
shell.close()
|
|
400
|
+
try:
|
|
401
|
+
await asyncio.wait_for(shell.wait_closed(), timeout=1)
|
|
402
|
+
except TimeoutError:
|
|
403
|
+
shell.kill()
|
|
404
|
+
shell.close()
|
|
405
|
+
with suppress(Exception):
|
|
406
|
+
await asyncio.wait_for(shell.wait_closed(), timeout=1)
|
|
407
|
+
except Exception:
|
|
408
|
+
pass
|
|
409
|
+
|
|
193
410
|
async def close(self) -> None:
|
|
411
|
+
await asyncio.gather(*(self.shell_close(shell_id) for shell_id in tuple(self._shells)), return_exceptions=True)
|
|
194
412
|
self._connection.close()
|
|
195
413
|
await self._connection.wait_closed()
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _ssh_shell_terminal(process: asyncssh.SSHClientProcess[bytes]) -> ShellTerminalResult:
|
|
417
|
+
exit_status = process.exit_status
|
|
418
|
+
raw_signal = process.exit_signal
|
|
419
|
+
signal_name: str | None = None
|
|
420
|
+
if isinstance(raw_signal, tuple) and raw_signal and isinstance(raw_signal[0], str):
|
|
421
|
+
signal_name = raw_signal[0]
|
|
422
|
+
elif isinstance(raw_signal, str):
|
|
423
|
+
signal_name = raw_signal
|
|
424
|
+
if signal_name:
|
|
425
|
+
return ShellTerminalResult(None, signal_name)
|
|
426
|
+
return ShellTerminalResult(exit_status, None)
|