@neoline/hostbridge 1.4.4 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -137
- package/package.json +1 -1
- package/pyproject.toml +2 -1
- package/src/server_control_mcp/__init__.py +5 -5
- package/src/server_control_mcp/cli.py +229 -1231
- package/src/server_control_mcp/client.py +478 -0
- package/src/server_control_mcp/config.py +158 -0
- package/src/server_control_mcp/daemon.py +387 -306
- package/src/server_control_mcp/doctor.py +108 -0
- package/src/server_control_mcp/hosts.py +52 -7
- package/src/server_control_mcp/mock_ssh.py +279 -99
- package/src/server_control_mcp/policy.py +2 -0
- package/src/server_control_mcp/protocol.py +362 -0
- package/src/server_control_mcp/runtime.py +40 -0
- package/src/server_control_mcp/secrets.py +8 -0
- package/src/server_control_mcp/server.py +132 -188
- package/src/server_control_mcp/services.py +508 -0
- package/src/server_control_mcp/transports/__init__.py +17 -0
- package/src/server_control_mcp/transports/base.py +78 -0
- package/src/server_control_mcp/transports/pty.py +427 -0
- package/src/server_control_mcp/transports/ssh.py +195 -0
- package/src/server_control_mcp/manager.py +0 -925
|
@@ -3,19 +3,47 @@ from __future__ import annotations
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import json
|
|
5
5
|
import posixpath
|
|
6
|
+
import re
|
|
6
7
|
import shlex
|
|
7
8
|
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import threading
|
|
8
11
|
import time
|
|
9
12
|
from contextlib import suppress
|
|
10
13
|
from dataclasses import dataclass, field
|
|
14
|
+
from functools import partial
|
|
11
15
|
from pathlib import Path
|
|
16
|
+
from typing import Any, cast
|
|
12
17
|
|
|
13
18
|
import asyncssh
|
|
14
19
|
|
|
15
|
-
from .
|
|
16
|
-
from .manager import DEFAULT_COMMAND_TIMEOUT, DEFAULT_MAX_TRANSFER_BYTES, RemoteCommandError, SessionManager, _new_token
|
|
20
|
+
from .client import HostBridgeClient, HostBridgeError
|
|
17
21
|
|
|
18
22
|
DEFAULT_PORT_RANGE = range(2222, 2300)
|
|
23
|
+
DEFAULT_COMMAND_TIMEOUT = 60
|
|
24
|
+
DEFAULT_MAX_TRANSFER_BYTES = 1024 * 1024 * 1024
|
|
25
|
+
EXEC_STDIN_PROBE_TIMEOUT = 0.05
|
|
26
|
+
STDIN_READING_COMMANDS = [
|
|
27
|
+
re.compile(r"\bcat\s*(?:>|>>)"),
|
|
28
|
+
re.compile(r"\btee(?:\s|$)"),
|
|
29
|
+
re.compile(r"(?:^|\s)(?:/bin/)?(?:sh|bash|dash|zsh)\b[^\n;]*\s-s(?:\s|$)"),
|
|
30
|
+
re.compile(r"(?:^|\s)(?:python[\d.]*|node)(?:\s+\S+)*\s+-($|\s)"),
|
|
31
|
+
re.compile(r"(?:^|\s)tar\s+(?:-[A-Za-z]*x[A-Za-z]*|[A-Za-z]*x[A-Za-z]*)(?:\s|$)"),
|
|
32
|
+
re.compile(r"(?:^|\s)tar\s+.*(?:^|\s)f\s*-(?:\s|$)"),
|
|
33
|
+
re.compile(r"(?:^|\s)dd\s+.*\bof="),
|
|
34
|
+
re.compile(r"(?:^|\s)scp(?:\s+\S+)*\s+-[A-Za-z]*(?:t|f)[A-Za-z]*(?:\s|$)"),
|
|
35
|
+
]
|
|
36
|
+
MAX_IDLE_BACKEND_SESSIONS = 4
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _to_bytes(chunk: str | bytes) -> bytes:
|
|
40
|
+
if isinstance(chunk, bytes):
|
|
41
|
+
return chunk
|
|
42
|
+
return chunk.encode("utf-8")
|
|
43
|
+
|
|
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))
|
|
19
47
|
|
|
20
48
|
|
|
21
49
|
class HostBridgeSSHServer(asyncssh.SSHServer):
|
|
@@ -36,48 +64,95 @@ class HostBridgeSSHServer(asyncssh.SSHServer):
|
|
|
36
64
|
|
|
37
65
|
|
|
38
66
|
class HostBridgeSSHProcess:
|
|
39
|
-
def __init__(self,
|
|
40
|
-
self.
|
|
67
|
+
def __init__(self, client: HostBridgeClient, session_factory: HostBridgeSessionFactory, timeout: int, output_limit: int) -> None:
|
|
68
|
+
self._client = client
|
|
41
69
|
self._session_factory = session_factory
|
|
42
70
|
self._timeout = timeout
|
|
43
71
|
self._output_limit = output_limit
|
|
44
72
|
|
|
45
|
-
async def __call__(self, process: asyncssh.SSHServerProcess[
|
|
73
|
+
async def __call__(self, process: asyncssh.SSHServerProcess[bytes]) -> None:
|
|
46
74
|
session_id = self._session_factory.open()
|
|
75
|
+
reusable = False
|
|
47
76
|
try:
|
|
48
|
-
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
|
|
49
81
|
finally:
|
|
50
|
-
self._session_factory.close(session_id)
|
|
82
|
+
self._session_factory.close(session_id, reusable=reusable)
|
|
51
83
|
|
|
52
|
-
async def _run(self, process: asyncssh.SSHServerProcess[
|
|
84
|
+
async def _run(self, process: asyncssh.SSHServerProcess[bytes], session_id: str) -> bool:
|
|
53
85
|
command = process.command
|
|
54
86
|
if not command:
|
|
55
87
|
await self._run_shell(process, session_id)
|
|
56
|
-
return
|
|
88
|
+
return False
|
|
57
89
|
|
|
58
90
|
loop = asyncio.get_running_loop()
|
|
59
91
|
try:
|
|
92
|
+
stdin_data = await self._read_exec_stdin(process, command)
|
|
60
93
|
result = await loop.run_in_executor(
|
|
61
94
|
None,
|
|
62
|
-
lambda: self.
|
|
95
|
+
lambda: self._client.exec(
|
|
63
96
|
session_id,
|
|
64
97
|
command,
|
|
98
|
+
stdin=stdin_data,
|
|
65
99
|
timeout=self._timeout,
|
|
66
|
-
token_factory=_new_token,
|
|
67
100
|
output_limit=self._output_limit,
|
|
101
|
+
owner="mock-ssh",
|
|
68
102
|
),
|
|
69
103
|
)
|
|
70
|
-
if result.
|
|
71
|
-
process.stdout.write(result.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
104
|
+
if result.stdout:
|
|
105
|
+
process.stdout.write(result.stdout)
|
|
106
|
+
if result.stderr:
|
|
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
|
|
75
110
|
except Exception as exc:
|
|
76
111
|
print(f"hostbridge mock-ssh exec failed: {exc}", file=sys.stderr, flush=True)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
117
|
+
|
|
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
|
+
if process.stdin.at_eof():
|
|
125
|
+
return b""
|
|
126
|
+
waits_for_eof = _command_reads_stdin(command)
|
|
127
|
+
try:
|
|
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
|
+
)
|
|
132
|
+
except TimeoutError:
|
|
133
|
+
if waits_for_eof:
|
|
134
|
+
raise HostBridgeError("timeout", "timed out waiting for mock-ssh exec stdin") from None
|
|
135
|
+
return None
|
|
136
|
+
if not first_chunk:
|
|
137
|
+
return b""
|
|
138
|
+
|
|
139
|
+
first_chunk_bytes = _to_bytes(first_chunk)
|
|
140
|
+
total = len(first_chunk_bytes)
|
|
141
|
+
if total > self._output_limit:
|
|
142
|
+
raise HostBridgeError("resource_limit", f"mock-ssh exec stdin exceeds limit ({self._output_limit} bytes)")
|
|
143
|
+
chunks = [first_chunk_bytes]
|
|
144
|
+
while not process.stdin.at_eof():
|
|
145
|
+
chunk = await asyncio.wait_for(process.stdin.read(4096), timeout=self._timeout)
|
|
146
|
+
if not chunk:
|
|
147
|
+
break
|
|
148
|
+
chunk_bytes = _to_bytes(chunk)
|
|
149
|
+
total += len(chunk_bytes)
|
|
150
|
+
if total > self._output_limit:
|
|
151
|
+
raise HostBridgeError("resource_limit", f"mock-ssh exec stdin exceeds limit ({self._output_limit} bytes)")
|
|
152
|
+
chunks.append(chunk_bytes)
|
|
153
|
+
return b"".join(chunks)
|
|
154
|
+
|
|
155
|
+
async def _run_shell(self, process: asyncssh.SSHServerProcess[bytes], session_id: str) -> None:
|
|
81
156
|
loop = asyncio.get_running_loop()
|
|
82
157
|
stop = asyncio.Event()
|
|
83
158
|
input_done = asyncio.Event()
|
|
@@ -90,7 +165,12 @@ class HostBridgeSSHProcess:
|
|
|
90
165
|
break
|
|
91
166
|
await loop.run_in_executor(
|
|
92
167
|
None,
|
|
93
|
-
|
|
168
|
+
partial(
|
|
169
|
+
self._client.shell_write,
|
|
170
|
+
session_id,
|
|
171
|
+
_to_bytes(data),
|
|
172
|
+
owner="mock-ssh",
|
|
173
|
+
),
|
|
94
174
|
)
|
|
95
175
|
finally:
|
|
96
176
|
input_done.set()
|
|
@@ -101,28 +181,28 @@ class HostBridgeSSHProcess:
|
|
|
101
181
|
while not stop.is_set():
|
|
102
182
|
result = await loop.run_in_executor(
|
|
103
183
|
None,
|
|
104
|
-
lambda: self.
|
|
184
|
+
lambda: self._client.shell_read(
|
|
105
185
|
session_id,
|
|
106
186
|
timeout=0.2,
|
|
107
|
-
|
|
108
|
-
|
|
187
|
+
max_bytes=4096,
|
|
188
|
+
owner="mock-ssh",
|
|
109
189
|
),
|
|
110
190
|
)
|
|
111
|
-
|
|
112
|
-
if output:
|
|
191
|
+
if result.data:
|
|
113
192
|
idle_after_input = 0
|
|
114
|
-
process.stdout.write(
|
|
193
|
+
process.stdout.write(result.data)
|
|
115
194
|
elif input_done.is_set():
|
|
116
195
|
idle_after_input += 1
|
|
117
196
|
if idle_after_input >= 5:
|
|
118
197
|
stop.set()
|
|
119
198
|
break
|
|
120
|
-
if not result.
|
|
199
|
+
if not result.alive:
|
|
121
200
|
stop.set()
|
|
122
201
|
break
|
|
123
202
|
except Exception as exc:
|
|
124
203
|
print(f"hostbridge mock-ssh shell failed: {exc}", file=sys.stderr, flush=True)
|
|
125
|
-
|
|
204
|
+
with suppress(Exception):
|
|
205
|
+
process.stderr.write(f"hostbridge mock-ssh shell error: {exc}\n".encode("utf-8", errors="replace"))
|
|
126
206
|
stop.set()
|
|
127
207
|
|
|
128
208
|
tasks = [asyncio.create_task(pump_input()), asyncio.create_task(pump_output())]
|
|
@@ -130,7 +210,7 @@ class HostBridgeSSHProcess:
|
|
|
130
210
|
for task in tasks:
|
|
131
211
|
task.cancel()
|
|
132
212
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
133
|
-
|
|
213
|
+
self._set_exit_status(process, 0)
|
|
134
214
|
|
|
135
215
|
|
|
136
216
|
@dataclass
|
|
@@ -143,36 +223,99 @@ class HostBridgeSFTPHandle:
|
|
|
143
223
|
mode: int = 0o644
|
|
144
224
|
|
|
145
225
|
|
|
226
|
+
@dataclass(frozen=True, slots=True)
|
|
227
|
+
class _TextCommandResult:
|
|
228
|
+
output: str
|
|
229
|
+
exit_code: int | None
|
|
230
|
+
timed_out: bool
|
|
231
|
+
|
|
232
|
+
|
|
146
233
|
class HostBridgeSessionFactory:
|
|
147
|
-
def __init__(self,
|
|
148
|
-
self.
|
|
234
|
+
def __init__(self, client: HostBridgeClient, host_id: str, connect_timeout: int = 60) -> None:
|
|
235
|
+
self._client = client
|
|
149
236
|
self._host_id = host_id
|
|
150
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
|
|
151
242
|
|
|
152
243
|
def open(self) -> str:
|
|
153
|
-
|
|
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(
|
|
154
252
|
self._host_id,
|
|
155
|
-
|
|
156
|
-
|
|
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,
|
|
157
305
|
)
|
|
158
|
-
return session.id
|
|
159
|
-
|
|
160
|
-
def close(self, session_id: str) -> None:
|
|
161
|
-
with _suppress_error():
|
|
162
|
-
self._manager.close_session(session_id, owner_agent_id="mock-ssh", force=True)
|
|
163
306
|
|
|
164
307
|
|
|
165
308
|
class HostBridgeSFTPServer(asyncssh.SFTPServer):
|
|
166
309
|
def __init__(
|
|
167
310
|
self,
|
|
168
311
|
chan: asyncssh.SSHServerChannel,
|
|
169
|
-
|
|
312
|
+
client: HostBridgeClient,
|
|
170
313
|
session_factory: HostBridgeSessionFactory,
|
|
171
314
|
timeout: int,
|
|
172
315
|
max_bytes: int,
|
|
173
316
|
) -> None:
|
|
174
317
|
super().__init__(chan)
|
|
175
|
-
self.
|
|
318
|
+
self._client = client
|
|
176
319
|
self._session_factory = session_factory
|
|
177
320
|
self._session_id = session_factory.open()
|
|
178
321
|
self._timeout = timeout
|
|
@@ -278,17 +421,25 @@ class HostBridgeSFTPServer(asyncssh.SFTPServer):
|
|
|
278
421
|
self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)} {atime} {mtime}")
|
|
279
422
|
|
|
280
423
|
def _download(self, remote_path: str) -> bytes:
|
|
424
|
+
temporary_path: Path | None = None
|
|
281
425
|
try:
|
|
282
|
-
|
|
426
|
+
with tempfile.NamedTemporaryFile(prefix="hostbridge-sftp-", delete=False) as temporary:
|
|
427
|
+
temporary_path = Path(temporary.name)
|
|
428
|
+
result = self._client.download_file(
|
|
283
429
|
self._session_id,
|
|
284
430
|
remote_path,
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
431
|
+
temporary_path,
|
|
432
|
+
timeout=self._timeout,
|
|
433
|
+
owner="mock-ssh",
|
|
288
434
|
)
|
|
289
|
-
|
|
435
|
+
if result.bytes_transferred > self._max_bytes:
|
|
436
|
+
raise HostBridgeError("resource_limit", f"file exceeds mock-ssh limit ({self._max_bytes} bytes)")
|
|
437
|
+
return temporary_path.read_bytes()
|
|
438
|
+
except HostBridgeError as exc:
|
|
290
439
|
raise self._sftp_error(exc) from exc
|
|
291
|
-
|
|
440
|
+
finally:
|
|
441
|
+
if temporary_path is not None:
|
|
442
|
+
temporary_path.unlink(missing_ok=True)
|
|
292
443
|
|
|
293
444
|
def _upload(self, handle: HostBridgeSFTPHandle) -> None:
|
|
294
445
|
size = len(handle.data)
|
|
@@ -299,19 +450,27 @@ class HostBridgeSFTPServer(asyncssh.SFTPServer):
|
|
|
299
450
|
data.extend(b"\x00" * (size - len(data)))
|
|
300
451
|
for offset, chunk in sorted(handle.writes.items()):
|
|
301
452
|
data[offset : offset + len(chunk)] = chunk
|
|
453
|
+
if len(data) > self._max_bytes:
|
|
454
|
+
raise asyncssh.SFTPFailure(f"file exceeds mock-ssh limit ({self._max_bytes} bytes)")
|
|
455
|
+
temporary_path: Path | None = None
|
|
302
456
|
try:
|
|
303
|
-
|
|
457
|
+
with tempfile.NamedTemporaryFile(prefix="hostbridge-sftp-", delete=False) as temporary:
|
|
458
|
+
temporary.write(data)
|
|
459
|
+
temporary_path = Path(temporary.name)
|
|
460
|
+
self._client.upload_file(
|
|
304
461
|
self._session_id,
|
|
462
|
+
temporary_path,
|
|
305
463
|
handle.path,
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
token_factory=_new_token,
|
|
310
|
-
owner_agent_id="mock-ssh",
|
|
464
|
+
mode=handle.mode & 0o7777,
|
|
465
|
+
timeout=self._timeout,
|
|
466
|
+
owner="mock-ssh",
|
|
311
467
|
)
|
|
312
|
-
except
|
|
468
|
+
except HostBridgeError as exc:
|
|
313
469
|
print(f"hostbridge mock-ssh sftp upload failed: {exc}", file=sys.stderr, flush=True)
|
|
314
470
|
raise self._sftp_error(exc) from exc
|
|
471
|
+
finally:
|
|
472
|
+
if temporary_path is not None:
|
|
473
|
+
temporary_path.unlink(missing_ok=True)
|
|
315
474
|
|
|
316
475
|
def _stat(self, remote_path: str, *, follow: bool) -> asyncssh.SFTPAttrs:
|
|
317
476
|
script = r"""
|
|
@@ -362,7 +521,7 @@ print(json.dumps(items))
|
|
|
362
521
|
]
|
|
363
522
|
|
|
364
523
|
def _attrs(self, data: dict[str, object]) -> asyncssh.SFTPAttrs:
|
|
365
|
-
permissions =
|
|
524
|
+
permissions = self._int_value(data.get("permissions"), 0)
|
|
366
525
|
raw_type = data.get("type")
|
|
367
526
|
file_type = {
|
|
368
527
|
"file": asyncssh.FILEXFER_TYPE_REGULAR,
|
|
@@ -371,39 +530,54 @@ print(json.dumps(items))
|
|
|
371
530
|
}.get(str(raw_type), asyncssh.FILEXFER_TYPE_UNKNOWN)
|
|
372
531
|
return asyncssh.SFTPAttrs(
|
|
373
532
|
type=file_type,
|
|
374
|
-
size=
|
|
375
|
-
uid=
|
|
376
|
-
gid=
|
|
533
|
+
size=self._int_value(data.get("size"), 0),
|
|
534
|
+
uid=self._int_value(data.get("uid"), 0),
|
|
535
|
+
gid=self._int_value(data.get("gid"), 0),
|
|
377
536
|
permissions=permissions,
|
|
378
|
-
atime=
|
|
379
|
-
mtime=
|
|
537
|
+
atime=self._int_value(data.get("atime"), int(time.time())),
|
|
538
|
+
mtime=self._int_value(data.get("mtime"), int(time.time())),
|
|
380
539
|
)
|
|
381
540
|
|
|
541
|
+
@staticmethod
|
|
542
|
+
def _int_value(value: object, default: int) -> int:
|
|
543
|
+
if value is None:
|
|
544
|
+
return default
|
|
545
|
+
if isinstance(value, bool) or not isinstance(value, int | float | str):
|
|
546
|
+
raise asyncssh.SFTPFailure("remote metadata contains a non-numeric field")
|
|
547
|
+
try:
|
|
548
|
+
return int(value)
|
|
549
|
+
except ValueError as exc:
|
|
550
|
+
raise asyncssh.SFTPFailure("remote metadata contains an invalid numeric field") from exc
|
|
551
|
+
|
|
382
552
|
def _checked_run(self, command: str):
|
|
383
553
|
try:
|
|
384
|
-
result = self.
|
|
554
|
+
result = self._client.exec(
|
|
385
555
|
self._session_id,
|
|
386
556
|
command,
|
|
387
557
|
timeout=self._timeout,
|
|
388
|
-
|
|
389
|
-
owner_agent_id="mock-ssh",
|
|
558
|
+
owner="mock-ssh",
|
|
390
559
|
)
|
|
391
|
-
except
|
|
560
|
+
except HostBridgeError as exc:
|
|
392
561
|
raise self._sftp_error(exc) from exc
|
|
562
|
+
output = result.stdout.decode("utf-8", errors="replace")
|
|
563
|
+
stderr = result.stderr.decode("utf-8", errors="replace")
|
|
393
564
|
if result.timed_out:
|
|
394
565
|
print(f"hostbridge mock-ssh sftp command timed out: {command}", file=sys.stderr, flush=True)
|
|
395
566
|
raise asyncssh.SFTPFailure("remote command timed out")
|
|
396
567
|
if result.exit_code not in (0, None):
|
|
397
|
-
|
|
568
|
+
error_output = "\n".join(part for part in (output, stderr) if part).strip()
|
|
569
|
+
error = self._sftp_error(
|
|
570
|
+
HostBridgeError("remote_error", error_output or f"remote command exited {result.exit_code}")
|
|
571
|
+
)
|
|
398
572
|
if isinstance(error, asyncssh.SFTPNoSuchFile):
|
|
399
573
|
raise error
|
|
400
574
|
print(
|
|
401
|
-
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}",
|
|
402
576
|
file=sys.stderr,
|
|
403
577
|
flush=True,
|
|
404
578
|
)
|
|
405
579
|
raise error
|
|
406
|
-
return result
|
|
580
|
+
return _TextCommandResult(output, result.exit_code, result.timed_out)
|
|
407
581
|
|
|
408
582
|
def _path(self, path: bytes) -> str:
|
|
409
583
|
text = path.decode("utf-8", "surrogateescape")
|
|
@@ -416,7 +590,7 @@ print(json.dumps(items))
|
|
|
416
590
|
raise asyncssh.SFTPInvalidHandle("invalid HostBridge SFTP handle")
|
|
417
591
|
return file_obj
|
|
418
592
|
|
|
419
|
-
def _sftp_error(self, exc:
|
|
593
|
+
def _sftp_error(self, exc: HostBridgeError) -> Exception:
|
|
420
594
|
text = str(exc)
|
|
421
595
|
lowered = text.lower()
|
|
422
596
|
if "no such file" in lowered or "not found" in lowered or "filenotfounderror" in lowered:
|
|
@@ -480,6 +654,7 @@ async def create_server_on_available_port(
|
|
|
480
654
|
process_factory=process_factory,
|
|
481
655
|
sftp_factory=sftp_factory,
|
|
482
656
|
allow_scp=True,
|
|
657
|
+
encoding=None,
|
|
483
658
|
)
|
|
484
659
|
return server, int(candidate)
|
|
485
660
|
except OSError as exc:
|
|
@@ -595,49 +770,54 @@ async def serve_mock_ssh(
|
|
|
595
770
|
command_timeout: int = DEFAULT_COMMAND_TIMEOUT,
|
|
596
771
|
output_limit: int = DEFAULT_MAX_TRANSFER_BYTES,
|
|
597
772
|
) -> int:
|
|
773
|
+
if config_path is not None:
|
|
774
|
+
raise HostBridgeError(
|
|
775
|
+
"invalid_request",
|
|
776
|
+
"mock-ssh no longer accepts a separate config path; start the v1 daemon with the intended config",
|
|
777
|
+
)
|
|
598
778
|
key_dir = key_dir or default_key_dir(host_id)
|
|
599
779
|
ssh_host_alias = ssh_host_alias or default_ssh_host_alias(host_id)
|
|
600
780
|
host_key_path, client_key_path, public_key = ensure_keypair(key_dir)
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
781
|
+
client = HostBridgeClient()
|
|
782
|
+
client.hello()
|
|
783
|
+
session_factory = HostBridgeSessionFactory(client, host_id, connect_timeout)
|
|
784
|
+
server, selected_port = await create_server_on_available_port(
|
|
785
|
+
server_factory=lambda: HostBridgeSSHServer(public_key),
|
|
786
|
+
listen_host=listen_host,
|
|
787
|
+
port=port,
|
|
788
|
+
server_host_keys=[str(host_key_path)],
|
|
789
|
+
process_factory=HostBridgeSSHProcess(client, session_factory, command_timeout, output_limit),
|
|
790
|
+
sftp_factory=lambda chan: HostBridgeSFTPServer(chan, client, session_factory, command_timeout, output_limit),
|
|
791
|
+
)
|
|
792
|
+
installed_config_path: Path | None = None
|
|
793
|
+
if install_ssh_config:
|
|
794
|
+
installed_config_path = install_ssh_config_block(
|
|
795
|
+
host_alias=ssh_host_alias,
|
|
606
796
|
listen_host=listen_host,
|
|
607
|
-
port=
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
sftp_factory=lambda chan: HostBridgeSFTPServer(chan, manager, session_factory, command_timeout, output_limit),
|
|
797
|
+
port=selected_port,
|
|
798
|
+
client_key_path=client_key_path,
|
|
799
|
+
config_path=ssh_config_path,
|
|
611
800
|
)
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
)
|
|
621
|
-
print(f"hostbridge mock-ssh forwarding host '{host_id}'", flush=True)
|
|
622
|
-
print(f"listening on ssh://{listen_host}:{selected_port}", flush=True)
|
|
623
|
-
print(f"ssh host alias: {ssh_host_alias}", flush=True)
|
|
624
|
-
if installed_config_path is not None:
|
|
625
|
-
print(f"ssh config: {installed_config_path}", flush=True)
|
|
626
|
-
print(f"identity file: {client_key_path}", flush=True)
|
|
627
|
-
print("supports SSH exec, shell, SFTP, and SCP; press Ctrl-C to stop", flush=True)
|
|
801
|
+
print(f"hostbridge mock-ssh forwarding host '{host_id}'", flush=True)
|
|
802
|
+
print(f"listening on ssh://{listen_host}:{selected_port}", flush=True)
|
|
803
|
+
print(f"ssh host alias: {ssh_host_alias}", flush=True)
|
|
804
|
+
if installed_config_path is not None:
|
|
805
|
+
print(f"ssh config: {installed_config_path}", flush=True)
|
|
806
|
+
print(f"identity file: {client_key_path}", flush=True)
|
|
807
|
+
print("supports SSH exec, shell, SFTP, and SCP; press Ctrl-C to stop", flush=True)
|
|
808
|
+
try:
|
|
628
809
|
await server.wait_closed()
|
|
810
|
+
return 0
|
|
629
811
|
finally:
|
|
630
|
-
|
|
631
|
-
manager.stop_idle_reaper()
|
|
632
|
-
return 0
|
|
812
|
+
session_factory.shutdown()
|
|
633
813
|
|
|
634
814
|
|
|
635
815
|
def run_mock_ssh(**kwargs: object) -> int:
|
|
636
816
|
try:
|
|
637
|
-
return asyncio.run(serve_mock_ssh(**kwargs))
|
|
817
|
+
return asyncio.run(cast(Any, serve_mock_ssh)(**kwargs))
|
|
638
818
|
except KeyboardInterrupt:
|
|
639
819
|
return 130
|
|
640
|
-
except
|
|
820
|
+
except HostBridgeError as exc:
|
|
641
821
|
print(f"hostbridge mock-ssh failed: {exc}")
|
|
642
822
|
return 1
|
|
643
823
|
|
|
@@ -167,6 +167,7 @@ def audit_event(
|
|
|
167
167
|
event: str,
|
|
168
168
|
session_id: str | None = None,
|
|
169
169
|
host_id: str | None = None,
|
|
170
|
+
owner_agent_id: str | None = None,
|
|
170
171
|
command: str | None = None,
|
|
171
172
|
task_id: str | None = None,
|
|
172
173
|
outcome: str | None = None,
|
|
@@ -177,6 +178,7 @@ def audit_event(
|
|
|
177
178
|
for key, value in {
|
|
178
179
|
"session_id": session_id,
|
|
179
180
|
"host_id": host_id,
|
|
181
|
+
"owner_agent_id": owner_agent_id,
|
|
180
182
|
"command": command,
|
|
181
183
|
"task_id": task_id,
|
|
182
184
|
"outcome": outcome,
|