@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
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import posixpath
|
|
7
|
+
import shlex
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from contextlib import suppress
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import asyncssh
|
|
17
|
+
|
|
18
|
+
from .client import HostBridgeClient, HostBridgeError
|
|
19
|
+
from .mock_ssh_session import HostBridgeSessionFactory
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class HostBridgeSFTPHandle:
|
|
24
|
+
path: str
|
|
25
|
+
temporary_path: Path
|
|
26
|
+
readable: bool = False
|
|
27
|
+
writable: bool = False
|
|
28
|
+
append: bool = False
|
|
29
|
+
mode: int = 0o644
|
|
30
|
+
size: int = 0
|
|
31
|
+
dirty: bool = False
|
|
32
|
+
atime: int | None = None
|
|
33
|
+
mtime: int | None = None
|
|
34
|
+
closed: bool = False
|
|
35
|
+
reserved_bytes: int = 0
|
|
36
|
+
slot_reserved: bool = False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class _TextCommandResult:
|
|
41
|
+
output: str
|
|
42
|
+
exit_code: int | None
|
|
43
|
+
timed_out: bool
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class HostBridgeSFTPServer(asyncssh.SFTPServer):
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
chan: asyncssh.SSHServerChannel,
|
|
50
|
+
client: HostBridgeClient,
|
|
51
|
+
session_factory: HostBridgeSessionFactory,
|
|
52
|
+
timeout: int,
|
|
53
|
+
max_bytes: int,
|
|
54
|
+
max_open_handles: int,
|
|
55
|
+
max_temp_bytes: int,
|
|
56
|
+
) -> None:
|
|
57
|
+
if not isinstance(max_bytes, int) or isinstance(max_bytes, bool) or max_bytes <= 0:
|
|
58
|
+
raise ValueError("max_bytes must be a positive integer")
|
|
59
|
+
if not isinstance(max_open_handles, int) or isinstance(max_open_handles, bool) or max_open_handles <= 0:
|
|
60
|
+
raise ValueError("max_open_handles must be a positive integer")
|
|
61
|
+
if not isinstance(max_temp_bytes, int) or isinstance(max_temp_bytes, bool) or max_temp_bytes <= 0:
|
|
62
|
+
raise ValueError("max_temp_bytes must be a positive integer")
|
|
63
|
+
super().__init__(chan)
|
|
64
|
+
self._client = client
|
|
65
|
+
self._session_factory = session_factory
|
|
66
|
+
self._session_id: str | None = None
|
|
67
|
+
self._session_lock = threading.Lock()
|
|
68
|
+
self._operation_lock = asyncio.Lock()
|
|
69
|
+
self._timeout = timeout
|
|
70
|
+
self._max_bytes = max_bytes
|
|
71
|
+
self._max_open_handles = max_open_handles
|
|
72
|
+
self._max_temp_bytes = max_temp_bytes
|
|
73
|
+
self._resource_lock = threading.Lock()
|
|
74
|
+
self._open_handle_count = 0
|
|
75
|
+
self._retained_bytes = 0
|
|
76
|
+
self._handles: list[HostBridgeSFTPHandle] = []
|
|
77
|
+
|
|
78
|
+
async def _run_blocking(self, function, /, *args, **kwargs):
|
|
79
|
+
async with self._operation_lock:
|
|
80
|
+
worker = asyncio.create_task(asyncio.to_thread(function, *args, **kwargs))
|
|
81
|
+
try:
|
|
82
|
+
return await asyncio.shield(worker)
|
|
83
|
+
except asyncio.CancelledError:
|
|
84
|
+
while not worker.done():
|
|
85
|
+
try:
|
|
86
|
+
await asyncio.shield(worker)
|
|
87
|
+
except asyncio.CancelledError:
|
|
88
|
+
continue
|
|
89
|
+
except BaseException:
|
|
90
|
+
break
|
|
91
|
+
with suppress(BaseException):
|
|
92
|
+
worker.result()
|
|
93
|
+
raise
|
|
94
|
+
|
|
95
|
+
async def exit(self) -> None:
|
|
96
|
+
await self._run_blocking(self._exit_sync)
|
|
97
|
+
|
|
98
|
+
def _exit_sync(self) -> None:
|
|
99
|
+
for handle in list(self._handles):
|
|
100
|
+
with suppress(Exception):
|
|
101
|
+
self._close_sync(handle)
|
|
102
|
+
if self._session_id is not None:
|
|
103
|
+
self._session_factory.close(self._session_id, reusable=False)
|
|
104
|
+
self._session_id = None
|
|
105
|
+
|
|
106
|
+
async def open(self, path: bytes, pflags: int, attrs: asyncssh.SFTPAttrs) -> HostBridgeSFTPHandle:
|
|
107
|
+
return await self._run_blocking(self._open_sync, path, pflags, attrs)
|
|
108
|
+
|
|
109
|
+
def _open_sync(self, path: bytes, pflags: int, attrs: asyncssh.SFTPAttrs) -> HostBridgeSFTPHandle:
|
|
110
|
+
self._reserve_handle_slot()
|
|
111
|
+
registered = False
|
|
112
|
+
temporary_path: Path | None = None
|
|
113
|
+
remote_path = self._path(path)
|
|
114
|
+
readable = bool(pflags & asyncssh.FXF_READ)
|
|
115
|
+
writable = bool(pflags & asyncssh.FXF_WRITE)
|
|
116
|
+
create = bool(pflags & asyncssh.FXF_CREAT)
|
|
117
|
+
truncate = bool(pflags & asyncssh.FXF_TRUNC)
|
|
118
|
+
exclusive = bool(pflags & asyncssh.FXF_EXCL)
|
|
119
|
+
append = bool(pflags & asyncssh.FXF_APPEND)
|
|
120
|
+
try:
|
|
121
|
+
if not readable and not writable:
|
|
122
|
+
raise asyncssh.SFTPFailure("file must be opened for reading or writing")
|
|
123
|
+
if (truncate or append) and not writable:
|
|
124
|
+
raise asyncssh.SFTPPermissionDenied("write flags require write access")
|
|
125
|
+
if create:
|
|
126
|
+
create_mode = 0o644 if attrs.permissions is None else attrs.permissions
|
|
127
|
+
self._create_remote(remote_path, mode=create_mode, exclusive=exclusive)
|
|
128
|
+
remote_attrs = self._stat(remote_path, follow=True)
|
|
129
|
+
if remote_attrs is not None and remote_attrs.type == asyncssh.FILEXFER_TYPE_DIRECTORY:
|
|
130
|
+
raise asyncssh.SFTPFileIsADirectory(remote_path)
|
|
131
|
+
mode = attrs.permissions if attrs.permissions is not None else 0o644
|
|
132
|
+
if remote_attrs is not None and remote_attrs.permissions is not None and attrs.permissions is None:
|
|
133
|
+
mode = remote_attrs.permissions
|
|
134
|
+
temporary_path = self._new_temporary_path()
|
|
135
|
+
if truncate:
|
|
136
|
+
self._truncate_remote(remote_path)
|
|
137
|
+
temporary_path.touch(mode=0o600)
|
|
138
|
+
else:
|
|
139
|
+
available = self._available_temp_bytes()
|
|
140
|
+
declared_size = 0 if remote_attrs.size is None else int(remote_attrs.size)
|
|
141
|
+
if declared_size < 0 or declared_size > available:
|
|
142
|
+
raise asyncssh.SFTPFailure(
|
|
143
|
+
f"mock-ssh SFTP temporary storage limit exceeded ({self._max_temp_bytes} bytes)"
|
|
144
|
+
)
|
|
145
|
+
if available == 0:
|
|
146
|
+
temporary_path.touch(mode=0o600)
|
|
147
|
+
else:
|
|
148
|
+
self._download_to(remote_path, temporary_path, max_bytes=available)
|
|
149
|
+
size = temporary_path.stat().st_size
|
|
150
|
+
handle = HostBridgeSFTPHandle(
|
|
151
|
+
remote_path,
|
|
152
|
+
temporary_path,
|
|
153
|
+
readable=readable,
|
|
154
|
+
writable=writable,
|
|
155
|
+
append=append,
|
|
156
|
+
mode=mode,
|
|
157
|
+
size=size,
|
|
158
|
+
dirty=False,
|
|
159
|
+
slot_reserved=True,
|
|
160
|
+
)
|
|
161
|
+
self._register_handle(handle)
|
|
162
|
+
registered = True
|
|
163
|
+
return handle
|
|
164
|
+
except BaseException:
|
|
165
|
+
if temporary_path is not None:
|
|
166
|
+
temporary_path.unlink(missing_ok=True)
|
|
167
|
+
if not registered:
|
|
168
|
+
self._release_handle_slot()
|
|
169
|
+
raise
|
|
170
|
+
|
|
171
|
+
async def read(self, file_obj: object, offset: int, size: int) -> bytes:
|
|
172
|
+
return await self._run_blocking(self._read_sync, file_obj, offset, size)
|
|
173
|
+
|
|
174
|
+
def _read_sync(self, file_obj: object, offset: int, size: int) -> bytes:
|
|
175
|
+
handle = self._handle(file_obj)
|
|
176
|
+
if not handle.readable:
|
|
177
|
+
raise asyncssh.SFTPPermissionDenied("file is not open for reading")
|
|
178
|
+
if offset < 0 or size < 0:
|
|
179
|
+
raise asyncssh.SFTPFailure("read offset and size must be non-negative")
|
|
180
|
+
with handle.temporary_path.open("rb") as stream:
|
|
181
|
+
stream.seek(offset)
|
|
182
|
+
return stream.read(size)
|
|
183
|
+
|
|
184
|
+
async def write(self, file_obj: object, offset: int, data: bytes) -> int:
|
|
185
|
+
return await self._run_blocking(self._write_sync, file_obj, offset, data)
|
|
186
|
+
|
|
187
|
+
def _write_sync(self, file_obj: object, offset: int, data: bytes) -> int:
|
|
188
|
+
handle = self._handle(file_obj)
|
|
189
|
+
if not handle.writable:
|
|
190
|
+
raise asyncssh.SFTPPermissionDenied("file is not open for writing")
|
|
191
|
+
if offset < 0:
|
|
192
|
+
raise asyncssh.SFTPFailure("write offset must be non-negative")
|
|
193
|
+
payload = bytes(data)
|
|
194
|
+
write_offset = handle.size if handle.append else int(offset)
|
|
195
|
+
final_size = max(handle.size, write_offset + len(payload))
|
|
196
|
+
if final_size > self._max_bytes:
|
|
197
|
+
raise asyncssh.SFTPFailure(f"file exceeds mock-ssh limit ({self._max_bytes} bytes)")
|
|
198
|
+
growth = final_size - handle.size
|
|
199
|
+
self._reserve_growth(handle, growth)
|
|
200
|
+
try:
|
|
201
|
+
with handle.temporary_path.open("r+b") as stream:
|
|
202
|
+
stream.seek(write_offset)
|
|
203
|
+
stream.write(payload)
|
|
204
|
+
except BaseException:
|
|
205
|
+
self._release_growth(handle, growth)
|
|
206
|
+
raise
|
|
207
|
+
handle.size = final_size
|
|
208
|
+
handle.dirty = True
|
|
209
|
+
return len(payload)
|
|
210
|
+
|
|
211
|
+
async def close(self, file_obj: object) -> None:
|
|
212
|
+
await self._run_blocking(self._close_sync, file_obj)
|
|
213
|
+
|
|
214
|
+
def _close_sync(self, file_obj: object) -> None:
|
|
215
|
+
handle = self._handle(file_obj)
|
|
216
|
+
if handle.closed:
|
|
217
|
+
return
|
|
218
|
+
try:
|
|
219
|
+
if handle.writable and handle.dirty:
|
|
220
|
+
self._upload(handle)
|
|
221
|
+
elif handle.atime is not None or handle.mtime is not None:
|
|
222
|
+
self._apply_times(handle)
|
|
223
|
+
finally:
|
|
224
|
+
handle.closed = True
|
|
225
|
+
handle.temporary_path.unlink(missing_ok=True)
|
|
226
|
+
self._release_handle(handle)
|
|
227
|
+
|
|
228
|
+
async def stat(self, path: bytes) -> asyncssh.SFTPAttrs:
|
|
229
|
+
return await self._run_blocking(self._stat, self._path(path), follow=True)
|
|
230
|
+
|
|
231
|
+
async def lstat(self, path: bytes) -> asyncssh.SFTPAttrs:
|
|
232
|
+
return await self._run_blocking(self._stat, self._path(path), follow=False)
|
|
233
|
+
|
|
234
|
+
async def fstat(self, file_obj: object) -> asyncssh.SFTPAttrs:
|
|
235
|
+
return await self._run_blocking(self._fstat_sync, file_obj)
|
|
236
|
+
|
|
237
|
+
def _fstat_sync(self, file_obj: object) -> asyncssh.SFTPAttrs:
|
|
238
|
+
handle = self._handle(file_obj)
|
|
239
|
+
return asyncssh.SFTPAttrs(
|
|
240
|
+
size=handle.size,
|
|
241
|
+
permissions=handle.mode,
|
|
242
|
+
type=asyncssh.FILEXFER_TYPE_REGULAR,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
async def fsetstat(self, file_obj: object, attrs: asyncssh.SFTPAttrs) -> None:
|
|
246
|
+
await self._run_blocking(self._fsetstat_sync, file_obj, attrs)
|
|
247
|
+
|
|
248
|
+
def _fsetstat_sync(self, file_obj: object, attrs: asyncssh.SFTPAttrs) -> None:
|
|
249
|
+
handle = self._handle(file_obj)
|
|
250
|
+
if not handle.writable:
|
|
251
|
+
raise asyncssh.SFTPPermissionDenied("file is not open for writing")
|
|
252
|
+
if attrs.permissions is not None:
|
|
253
|
+
handle.mode = attrs.permissions
|
|
254
|
+
handle.dirty = True
|
|
255
|
+
if attrs.size is not None:
|
|
256
|
+
if attrs.size < 0 or attrs.size > self._max_bytes:
|
|
257
|
+
raise asyncssh.SFTPFailure(f"file exceeds mock-ssh limit ({self._max_bytes} bytes)")
|
|
258
|
+
growth = max(0, attrs.size - handle.size)
|
|
259
|
+
shrink = max(0, handle.size - attrs.size)
|
|
260
|
+
self._reserve_growth(handle, growth)
|
|
261
|
+
try:
|
|
262
|
+
with handle.temporary_path.open("r+b") as stream:
|
|
263
|
+
stream.truncate(attrs.size)
|
|
264
|
+
except BaseException:
|
|
265
|
+
self._release_growth(handle, growth)
|
|
266
|
+
raise
|
|
267
|
+
self._release_growth(handle, shrink)
|
|
268
|
+
handle.size = attrs.size
|
|
269
|
+
handle.dirty = True
|
|
270
|
+
handle.atime = attrs.atime if attrs.atime is not None else handle.atime
|
|
271
|
+
handle.mtime = attrs.mtime if attrs.mtime is not None else handle.mtime
|
|
272
|
+
|
|
273
|
+
async def scandir(self, path: bytes):
|
|
274
|
+
items = await self._run_blocking(self._listdir, self._path(path))
|
|
275
|
+
for item in items:
|
|
276
|
+
yield item
|
|
277
|
+
|
|
278
|
+
async def mkdir(self, path: bytes, attrs: asyncssh.SFTPAttrs) -> None:
|
|
279
|
+
await self._run_blocking(self._mkdir_sync, path, attrs)
|
|
280
|
+
|
|
281
|
+
def _mkdir_sync(self, path: bytes, attrs: asyncssh.SFTPAttrs) -> None:
|
|
282
|
+
mode = attrs.permissions if attrs.permissions is not None else 0o755
|
|
283
|
+
self._checked_run(f"mkdir -m {shlex.quote(format(mode & 0o7777, 'o'))} -- {shlex.quote(self._path(path))}")
|
|
284
|
+
|
|
285
|
+
async def rmdir(self, path: bytes) -> None:
|
|
286
|
+
await self._run_blocking(self._rmdir_sync, path)
|
|
287
|
+
|
|
288
|
+
def _rmdir_sync(self, path: bytes) -> None:
|
|
289
|
+
self._checked_run(f"rmdir -- {shlex.quote(self._path(path))}")
|
|
290
|
+
|
|
291
|
+
async def remove(self, path: bytes) -> None:
|
|
292
|
+
await self._run_blocking(self._remove_sync, path)
|
|
293
|
+
|
|
294
|
+
def _remove_sync(self, path: bytes) -> None:
|
|
295
|
+
self._checked_run(f"rm -- {shlex.quote(self._path(path))}")
|
|
296
|
+
|
|
297
|
+
async def rename(self, oldpath: bytes, newpath: bytes) -> None:
|
|
298
|
+
await self._run_blocking(self._rename_sync, oldpath, newpath)
|
|
299
|
+
|
|
300
|
+
def _rename_sync(self, oldpath: bytes, newpath: bytes) -> None:
|
|
301
|
+
self._checked_run(f"mv -- {shlex.quote(self._path(oldpath))} {shlex.quote(self._path(newpath))}")
|
|
302
|
+
|
|
303
|
+
async def realpath(self, path: bytes) -> bytes:
|
|
304
|
+
return await self._run_blocking(self._realpath_sync, path)
|
|
305
|
+
|
|
306
|
+
def _realpath_sync(self, path: bytes) -> bytes:
|
|
307
|
+
script = (
|
|
308
|
+
"import os,sys; "
|
|
309
|
+
"path=sys.argv[1]; "
|
|
310
|
+
"parent=os.path.dirname(path) or '/'; "
|
|
311
|
+
"base=os.path.basename(path); "
|
|
312
|
+
"print(os.path.join(os.path.realpath(parent), base) if not os.path.exists(path) else os.path.realpath(path))"
|
|
313
|
+
)
|
|
314
|
+
command = "python3 -c " + shlex.quote(script) + " " + shlex.quote(self._path(path))
|
|
315
|
+
result = self._checked_run(command)
|
|
316
|
+
return (result.output.strip() or self._path(path)).encode("utf-8")
|
|
317
|
+
|
|
318
|
+
async def setstat(self, path: bytes, attrs: asyncssh.SFTPAttrs) -> None:
|
|
319
|
+
await self._run_blocking(self._setstat_sync, path, attrs)
|
|
320
|
+
|
|
321
|
+
def _setstat_sync(self, path: bytes, attrs: asyncssh.SFTPAttrs) -> None:
|
|
322
|
+
remote_path = self._path(path)
|
|
323
|
+
if attrs.size is not None:
|
|
324
|
+
if attrs.size < 0 or attrs.size > self._max_bytes:
|
|
325
|
+
raise asyncssh.SFTPFailure(f"file size must be between 0 and {self._max_bytes} bytes")
|
|
326
|
+
script = "import os,sys; os.truncate(sys.argv[1], int(sys.argv[2]))"
|
|
327
|
+
self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)} {attrs.size}")
|
|
328
|
+
if attrs.permissions is not None:
|
|
329
|
+
self._checked_run(
|
|
330
|
+
f"chmod {shlex.quote(format(attrs.permissions & 0o7777, 'o'))} -- {shlex.quote(remote_path)}"
|
|
331
|
+
)
|
|
332
|
+
if attrs.atime is not None or attrs.mtime is not None:
|
|
333
|
+
script = "import os,sys; os.utime(sys.argv[1], (int(sys.argv[2]), int(sys.argv[3])))"
|
|
334
|
+
current = self._stat(remote_path, follow=True)
|
|
335
|
+
atime = int(attrs.atime if attrs.atime is not None else current.atime or time.time())
|
|
336
|
+
mtime = int(attrs.mtime if attrs.mtime is not None else current.mtime or time.time())
|
|
337
|
+
self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)} {atime} {mtime}")
|
|
338
|
+
|
|
339
|
+
def _download_to(self, remote_path: str, temporary_path: Path, *, max_bytes: int) -> None:
|
|
340
|
+
try:
|
|
341
|
+
result = self._client.download_file(
|
|
342
|
+
self._backend_session_id(),
|
|
343
|
+
remote_path,
|
|
344
|
+
temporary_path,
|
|
345
|
+
timeout=self._timeout,
|
|
346
|
+
owner="mock-ssh",
|
|
347
|
+
max_bytes=min(self._max_bytes, max_bytes),
|
|
348
|
+
)
|
|
349
|
+
if result.bytes_transferred > min(self._max_bytes, max_bytes):
|
|
350
|
+
raise HostBridgeError(
|
|
351
|
+
"resource_limit",
|
|
352
|
+
f"file exceeds mock-ssh SFTP temporary storage limit ({max_bytes} bytes)",
|
|
353
|
+
)
|
|
354
|
+
except HostBridgeError as exc:
|
|
355
|
+
raise self._sftp_error(exc) from exc
|
|
356
|
+
|
|
357
|
+
def _upload(self, handle: HostBridgeSFTPHandle) -> None:
|
|
358
|
+
if handle.size > self._max_bytes:
|
|
359
|
+
raise asyncssh.SFTPFailure(f"file exceeds mock-ssh limit ({self._max_bytes} bytes)")
|
|
360
|
+
try:
|
|
361
|
+
self._client.upload_file(
|
|
362
|
+
self._backend_session_id(),
|
|
363
|
+
handle.temporary_path,
|
|
364
|
+
handle.path,
|
|
365
|
+
mode=handle.mode & 0o7777,
|
|
366
|
+
timeout=self._timeout,
|
|
367
|
+
owner="mock-ssh",
|
|
368
|
+
max_bytes=self._max_bytes,
|
|
369
|
+
)
|
|
370
|
+
self._apply_times(handle)
|
|
371
|
+
except HostBridgeError as exc:
|
|
372
|
+
print(f"hostbridge mock-ssh sftp upload failed: {exc}", file=sys.stderr, flush=True)
|
|
373
|
+
raise self._sftp_error(exc) from exc
|
|
374
|
+
|
|
375
|
+
def _apply_times(self, handle: HostBridgeSFTPHandle) -> None:
|
|
376
|
+
if handle.atime is None and handle.mtime is None:
|
|
377
|
+
return
|
|
378
|
+
current = self._stat(handle.path, follow=True)
|
|
379
|
+
atime = int(handle.atime if handle.atime is not None else current.atime or time.time())
|
|
380
|
+
mtime = int(handle.mtime if handle.mtime is not None else current.mtime or time.time())
|
|
381
|
+
script = "import os,sys; os.utime(sys.argv[1], (int(sys.argv[2]), int(sys.argv[3])))"
|
|
382
|
+
self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(handle.path)} {atime} {mtime}")
|
|
383
|
+
|
|
384
|
+
def _create_remote(self, remote_path: str, *, mode: int, exclusive: bool) -> None:
|
|
385
|
+
script = (
|
|
386
|
+
"import os,sys; "
|
|
387
|
+
"flags=os.O_WRONLY|os.O_CREAT|(os.O_EXCL if sys.argv[3]=='1' else 0); "
|
|
388
|
+
"fd=os.open(sys.argv[1],flags,int(sys.argv[2],8)); os.close(fd)"
|
|
389
|
+
)
|
|
390
|
+
self._checked_run(
|
|
391
|
+
f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)} "
|
|
392
|
+
f"{shlex.quote(format(mode & 0o7777, 'o'))} {'1' if exclusive else '0'}"
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
def _truncate_remote(self, remote_path: str) -> None:
|
|
396
|
+
script = "import os,sys; fd=os.open(sys.argv[1],os.O_WRONLY|os.O_TRUNC); os.close(fd)"
|
|
397
|
+
self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)}")
|
|
398
|
+
|
|
399
|
+
@staticmethod
|
|
400
|
+
def _new_temporary_path() -> Path:
|
|
401
|
+
descriptor, name = tempfile.mkstemp(prefix="hostbridge-sftp-")
|
|
402
|
+
os.close(descriptor)
|
|
403
|
+
return Path(name)
|
|
404
|
+
|
|
405
|
+
def _stat(self, remote_path: str, *, follow: bool) -> asyncssh.SFTPAttrs:
|
|
406
|
+
script = r"""
|
|
407
|
+
import json, os, stat, sys
|
|
408
|
+
path = sys.argv[1]
|
|
409
|
+
st = os.stat(path) if sys.argv[2] == "1" else os.lstat(path)
|
|
410
|
+
print(json.dumps({
|
|
411
|
+
"size": st.st_size,
|
|
412
|
+
"permissions": st.st_mode,
|
|
413
|
+
"uid": st.st_uid,
|
|
414
|
+
"gid": st.st_gid,
|
|
415
|
+
"atime": int(st.st_atime),
|
|
416
|
+
"mtime": int(st.st_mtime),
|
|
417
|
+
"type": "dir" if stat.S_ISDIR(st.st_mode) else "link" if stat.S_ISLNK(st.st_mode) else "file",
|
|
418
|
+
}))
|
|
419
|
+
"""
|
|
420
|
+
result = self._checked_run(
|
|
421
|
+
f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)} {'1' if follow else '0'}"
|
|
422
|
+
)
|
|
423
|
+
return self._attrs(json.loads(result.output))
|
|
424
|
+
|
|
425
|
+
def _listdir(self, remote_path: str) -> list[asyncssh.SFTPName]:
|
|
426
|
+
script = r"""
|
|
427
|
+
import json, os, stat, sys, time
|
|
428
|
+
path = sys.argv[1]
|
|
429
|
+
items = []
|
|
430
|
+
for name in os.listdir(path):
|
|
431
|
+
full = os.path.join(path, name)
|
|
432
|
+
st = os.lstat(full)
|
|
433
|
+
item_type = "dir" if stat.S_ISDIR(st.st_mode) else "link" if stat.S_ISLNK(st.st_mode) else "file"
|
|
434
|
+
items.append({
|
|
435
|
+
"filename": name,
|
|
436
|
+
"longname": name,
|
|
437
|
+
"attrs": {
|
|
438
|
+
"size": st.st_size,
|
|
439
|
+
"permissions": st.st_mode,
|
|
440
|
+
"uid": st.st_uid,
|
|
441
|
+
"gid": st.st_gid,
|
|
442
|
+
"atime": int(st.st_atime),
|
|
443
|
+
"mtime": int(st.st_mtime),
|
|
444
|
+
"type": item_type,
|
|
445
|
+
},
|
|
446
|
+
})
|
|
447
|
+
print(json.dumps(items))
|
|
448
|
+
"""
|
|
449
|
+
result = self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)}")
|
|
450
|
+
return [
|
|
451
|
+
asyncssh.SFTPName(item["filename"], item["longname"], self._attrs(item["attrs"]))
|
|
452
|
+
for item in json.loads(result.output or "[]")
|
|
453
|
+
]
|
|
454
|
+
|
|
455
|
+
def _attrs(self, data: dict[str, object]) -> asyncssh.SFTPAttrs:
|
|
456
|
+
permissions = self._int_value(data.get("permissions"), 0)
|
|
457
|
+
raw_type = data.get("type")
|
|
458
|
+
file_type = {
|
|
459
|
+
"file": asyncssh.FILEXFER_TYPE_REGULAR,
|
|
460
|
+
"dir": asyncssh.FILEXFER_TYPE_DIRECTORY,
|
|
461
|
+
"link": asyncssh.FILEXFER_TYPE_SYMLINK,
|
|
462
|
+
}.get(str(raw_type), asyncssh.FILEXFER_TYPE_UNKNOWN)
|
|
463
|
+
return asyncssh.SFTPAttrs(
|
|
464
|
+
type=file_type,
|
|
465
|
+
size=self._int_value(data.get("size"), 0),
|
|
466
|
+
uid=self._int_value(data.get("uid"), 0),
|
|
467
|
+
gid=self._int_value(data.get("gid"), 0),
|
|
468
|
+
permissions=permissions,
|
|
469
|
+
atime=self._int_value(data.get("atime"), int(time.time())),
|
|
470
|
+
mtime=self._int_value(data.get("mtime"), int(time.time())),
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
@staticmethod
|
|
474
|
+
def _int_value(value: object, default: int) -> int:
|
|
475
|
+
if value is None:
|
|
476
|
+
return default
|
|
477
|
+
if isinstance(value, bool) or not isinstance(value, int | float | str):
|
|
478
|
+
raise asyncssh.SFTPFailure("remote metadata contains a non-numeric field")
|
|
479
|
+
try:
|
|
480
|
+
return int(value)
|
|
481
|
+
except ValueError as exc:
|
|
482
|
+
raise asyncssh.SFTPFailure("remote metadata contains an invalid numeric field") from exc
|
|
483
|
+
|
|
484
|
+
def _checked_run(self, command: str) -> _TextCommandResult:
|
|
485
|
+
try:
|
|
486
|
+
result = self._client.exec(
|
|
487
|
+
self._backend_session_id(),
|
|
488
|
+
command,
|
|
489
|
+
timeout=self._timeout,
|
|
490
|
+
owner="mock-ssh",
|
|
491
|
+
)
|
|
492
|
+
except HostBridgeError as exc:
|
|
493
|
+
raise self._sftp_error(exc) from exc
|
|
494
|
+
output = result.stdout.decode("utf-8", errors="replace")
|
|
495
|
+
stderr = result.stderr.decode("utf-8", errors="replace")
|
|
496
|
+
if result.timed_out:
|
|
497
|
+
print(f"hostbridge mock-ssh sftp command timed out: {command}", file=sys.stderr, flush=True)
|
|
498
|
+
raise asyncssh.SFTPFailure("remote command timed out")
|
|
499
|
+
if result.exit_code is None:
|
|
500
|
+
raise asyncssh.SFTPFailure("remote command completed without an exit status")
|
|
501
|
+
if result.exit_code != 0:
|
|
502
|
+
error_output = "\n".join(part for part in (output, stderr) if part).strip()
|
|
503
|
+
error = self._sftp_error(
|
|
504
|
+
HostBridgeError("remote_error", error_output or f"remote command exited {result.exit_code}")
|
|
505
|
+
)
|
|
506
|
+
if isinstance(error, asyncssh.SFTPNoSuchFile):
|
|
507
|
+
raise error
|
|
508
|
+
print(
|
|
509
|
+
f"hostbridge mock-ssh sftp command failed exit={result.exit_code}: {command}\n{error_output}",
|
|
510
|
+
file=sys.stderr,
|
|
511
|
+
flush=True,
|
|
512
|
+
)
|
|
513
|
+
raise error
|
|
514
|
+
return _TextCommandResult(output, result.exit_code, result.timed_out)
|
|
515
|
+
|
|
516
|
+
def _backend_session_id(self) -> str:
|
|
517
|
+
with self._session_lock:
|
|
518
|
+
if self._session_id is None:
|
|
519
|
+
self._session_id = self._session_factory.open()
|
|
520
|
+
return self._session_id
|
|
521
|
+
|
|
522
|
+
def _reserve_handle_slot(self) -> None:
|
|
523
|
+
with self._resource_lock:
|
|
524
|
+
if self._open_handle_count >= self._max_open_handles:
|
|
525
|
+
raise asyncssh.SFTPFailure(f"mock-ssh SFTP open handle limit reached ({self._max_open_handles})")
|
|
526
|
+
self._open_handle_count += 1
|
|
527
|
+
|
|
528
|
+
def _release_handle_slot(self) -> None:
|
|
529
|
+
with self._resource_lock:
|
|
530
|
+
self._open_handle_count -= 1
|
|
531
|
+
|
|
532
|
+
def _available_temp_bytes(self) -> int:
|
|
533
|
+
with self._resource_lock:
|
|
534
|
+
return self._max_temp_bytes - self._retained_bytes
|
|
535
|
+
|
|
536
|
+
def _register_handle(self, handle: HostBridgeSFTPHandle) -> None:
|
|
537
|
+
with self._resource_lock:
|
|
538
|
+
if self._retained_bytes + handle.size > self._max_temp_bytes:
|
|
539
|
+
raise asyncssh.SFTPFailure(
|
|
540
|
+
f"mock-ssh SFTP temporary storage limit exceeded ({self._max_temp_bytes} bytes)"
|
|
541
|
+
)
|
|
542
|
+
handle.reserved_bytes = handle.size
|
|
543
|
+
self._retained_bytes += handle.size
|
|
544
|
+
self._handles.append(handle)
|
|
545
|
+
|
|
546
|
+
def _reserve_growth(self, handle: HostBridgeSFTPHandle, growth: int) -> None:
|
|
547
|
+
if growth <= 0:
|
|
548
|
+
return
|
|
549
|
+
with self._resource_lock:
|
|
550
|
+
if self._retained_bytes + growth > self._max_temp_bytes:
|
|
551
|
+
raise asyncssh.SFTPFailure(
|
|
552
|
+
f"mock-ssh SFTP temporary storage limit exceeded ({self._max_temp_bytes} bytes)"
|
|
553
|
+
)
|
|
554
|
+
self._retained_bytes += growth
|
|
555
|
+
handle.reserved_bytes += growth
|
|
556
|
+
|
|
557
|
+
def _release_growth(self, handle: HostBridgeSFTPHandle, released: int) -> None:
|
|
558
|
+
if released <= 0:
|
|
559
|
+
return
|
|
560
|
+
with self._resource_lock:
|
|
561
|
+
actual = min(released, handle.reserved_bytes)
|
|
562
|
+
handle.reserved_bytes -= actual
|
|
563
|
+
self._retained_bytes -= actual
|
|
564
|
+
|
|
565
|
+
def _release_handle(self, handle: HostBridgeSFTPHandle) -> None:
|
|
566
|
+
with self._resource_lock:
|
|
567
|
+
with suppress(ValueError):
|
|
568
|
+
self._handles.remove(handle)
|
|
569
|
+
self._retained_bytes -= handle.reserved_bytes
|
|
570
|
+
handle.reserved_bytes = 0
|
|
571
|
+
if handle.slot_reserved:
|
|
572
|
+
self._open_handle_count -= 1
|
|
573
|
+
handle.slot_reserved = False
|
|
574
|
+
|
|
575
|
+
@staticmethod
|
|
576
|
+
def _path(path: bytes) -> str:
|
|
577
|
+
text = path.decode("utf-8", "surrogateescape")
|
|
578
|
+
if not text:
|
|
579
|
+
return "."
|
|
580
|
+
return posixpath.normpath(text)
|
|
581
|
+
|
|
582
|
+
@staticmethod
|
|
583
|
+
def _handle(file_obj: object) -> HostBridgeSFTPHandle:
|
|
584
|
+
if not isinstance(file_obj, HostBridgeSFTPHandle):
|
|
585
|
+
raise asyncssh.SFTPInvalidHandle("invalid HostBridge SFTP handle")
|
|
586
|
+
return file_obj
|
|
587
|
+
|
|
588
|
+
@staticmethod
|
|
589
|
+
def _sftp_error(exc: HostBridgeError) -> Exception:
|
|
590
|
+
text = str(exc)
|
|
591
|
+
lowered = text.lower()
|
|
592
|
+
if "no such file" in lowered or "not found" in lowered or "filenotfounderror" in lowered:
|
|
593
|
+
return asyncssh.SFTPNoSuchFile(text)
|
|
594
|
+
if "permission denied" in lowered:
|
|
595
|
+
return asyncssh.SFTPPermissionDenied(text)
|
|
596
|
+
if "file exists" in lowered or "fileexistserror" in lowered:
|
|
597
|
+
return asyncssh.SFTPFileAlreadyExists(text)
|
|
598
|
+
if "directory not empty" in lowered:
|
|
599
|
+
return asyncssh.SFTPDirNotEmpty(text)
|
|
600
|
+
if "is a directory" in lowered:
|
|
601
|
+
return asyncssh.SFTPFileIsADirectory(text)
|
|
602
|
+
if "not a directory" in lowered:
|
|
603
|
+
return asyncssh.SFTPNotADirectory(text)
|
|
604
|
+
return asyncssh.SFTPFailure(text)
|