@neoline/hostbridge 2.0.4 → 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 +11 -9
- package/docs/ARCHITECTURE.md +62 -0
- package/package.json +2 -1
- package/pyproject.toml +1 -1
- package/src/server_control_mcp/__init__.py +4 -3
- package/src/server_control_mcp/async_lifecycle.py +41 -0
- package/src/server_control_mcp/cli.py +1 -1
- package/src/server_control_mcp/client.py +302 -287
- package/src/server_control_mcp/config.py +2 -1
- package/src/server_control_mcp/daemon.py +233 -526
- package/src/server_control_mcp/doctor.py +34 -2
- package/src/server_control_mcp/hosts.py +136 -2
- package/src/server_control_mcp/mock_ssh.py +52 -14
- package/src/server_control_mcp/mock_ssh_exec.py +147 -58
- package/src/server_control_mcp/mock_ssh_session.py +3 -2
- package/src/server_control_mcp/mock_ssh_sftp.py +126 -28
- package/src/server_control_mcp/mock_ssh_tunnel.py +141 -444
- 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/remote_agent_bundle.py +56 -0
- package/src/server_control_mcp/remote_mux_agent.py +769 -0
- package/src/server_control_mcp/runtime_services.py +862 -0
- package/src/server_control_mcp/server.py +33 -18
- package/src/server_control_mcp/services.py +2 -666
- 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 +206 -259
- 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 -363
- package/src/server_control_mcp/remote_tunnel_agent.py +0 -143
- package/src/server_control_mcp/transports/pty.py +0 -515
|
@@ -56,7 +56,7 @@ class HostBridgeSessionFactory:
|
|
|
56
56
|
last_error: Exception | None = None
|
|
57
57
|
for _ in range(2):
|
|
58
58
|
try:
|
|
59
|
-
self._client.
|
|
59
|
+
self._client.release_mock_ssh_session(session_id)
|
|
60
60
|
return
|
|
61
61
|
except HostBridgeError as exc:
|
|
62
62
|
if exc.code == "not_found":
|
|
@@ -64,5 +64,6 @@ class HostBridgeSessionFactory:
|
|
|
64
64
|
last_error = exc
|
|
65
65
|
except Exception as exc:
|
|
66
66
|
last_error = exc
|
|
67
|
-
|
|
67
|
+
if last_error is None:
|
|
68
|
+
raise RuntimeError("backend close failed without an error")
|
|
68
69
|
raise last_error
|
|
@@ -32,6 +32,8 @@ class HostBridgeSFTPHandle:
|
|
|
32
32
|
atime: int | None = None
|
|
33
33
|
mtime: int | None = None
|
|
34
34
|
closed: bool = False
|
|
35
|
+
reserved_bytes: int = 0
|
|
36
|
+
slot_reserved: bool = False
|
|
35
37
|
|
|
36
38
|
|
|
37
39
|
@dataclass(frozen=True, slots=True)
|
|
@@ -49,7 +51,15 @@ class HostBridgeSFTPServer(asyncssh.SFTPServer):
|
|
|
49
51
|
session_factory: HostBridgeSessionFactory,
|
|
50
52
|
timeout: int,
|
|
51
53
|
max_bytes: int,
|
|
54
|
+
max_open_handles: int,
|
|
55
|
+
max_temp_bytes: int,
|
|
52
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")
|
|
53
63
|
super().__init__(chan)
|
|
54
64
|
self._client = client
|
|
55
65
|
self._session_factory = session_factory
|
|
@@ -58,6 +68,11 @@ class HostBridgeSFTPServer(asyncssh.SFTPServer):
|
|
|
58
68
|
self._operation_lock = asyncio.Lock()
|
|
59
69
|
self._timeout = timeout
|
|
60
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
|
|
61
76
|
self._handles: list[HostBridgeSFTPHandle] = []
|
|
62
77
|
|
|
63
78
|
async def _run_blocking(self, function, /, *args, **kwargs):
|
|
@@ -92,6 +107,9 @@ class HostBridgeSFTPServer(asyncssh.SFTPServer):
|
|
|
92
107
|
return await self._run_blocking(self._open_sync, path, pflags, attrs)
|
|
93
108
|
|
|
94
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
|
|
95
113
|
remote_path = self._path(path)
|
|
96
114
|
readable = bool(pflags & asyncssh.FXF_READ)
|
|
97
115
|
writable = bool(pflags & asyncssh.FXF_WRITE)
|
|
@@ -99,26 +117,35 @@ class HostBridgeSFTPServer(asyncssh.SFTPServer):
|
|
|
99
117
|
truncate = bool(pflags & asyncssh.FXF_TRUNC)
|
|
100
118
|
exclusive = bool(pflags & asyncssh.FXF_EXCL)
|
|
101
119
|
append = bool(pflags & asyncssh.FXF_APPEND)
|
|
102
|
-
if not readable and not writable:
|
|
103
|
-
raise asyncssh.SFTPFailure("file must be opened for reading or writing")
|
|
104
|
-
if (truncate or append) and not writable:
|
|
105
|
-
raise asyncssh.SFTPPermissionDenied("write flags require write access")
|
|
106
|
-
if create:
|
|
107
|
-
create_mode = 0o644 if attrs.permissions is None else attrs.permissions
|
|
108
|
-
self._create_remote(remote_path, mode=create_mode, exclusive=exclusive)
|
|
109
|
-
remote_attrs = self._stat(remote_path, follow=True)
|
|
110
|
-
if remote_attrs is not None and remote_attrs.type == asyncssh.FILEXFER_TYPE_DIRECTORY:
|
|
111
|
-
raise asyncssh.SFTPFileIsADirectory(remote_path)
|
|
112
|
-
mode = attrs.permissions if attrs.permissions is not None else 0o644
|
|
113
|
-
if remote_attrs is not None and remote_attrs.permissions is not None and attrs.permissions is None:
|
|
114
|
-
mode = remote_attrs.permissions
|
|
115
|
-
temporary_path = self._new_temporary_path()
|
|
116
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()
|
|
117
135
|
if truncate:
|
|
118
136
|
self._truncate_remote(remote_path)
|
|
119
137
|
temporary_path.touch(mode=0o600)
|
|
120
138
|
else:
|
|
121
|
-
self.
|
|
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)
|
|
122
149
|
size = temporary_path.stat().st_size
|
|
123
150
|
handle = HostBridgeSFTPHandle(
|
|
124
151
|
remote_path,
|
|
@@ -129,11 +156,16 @@ class HostBridgeSFTPServer(asyncssh.SFTPServer):
|
|
|
129
156
|
mode=mode,
|
|
130
157
|
size=size,
|
|
131
158
|
dirty=False,
|
|
159
|
+
slot_reserved=True,
|
|
132
160
|
)
|
|
133
|
-
self.
|
|
161
|
+
self._register_handle(handle)
|
|
162
|
+
registered = True
|
|
134
163
|
return handle
|
|
135
164
|
except BaseException:
|
|
136
|
-
temporary_path
|
|
165
|
+
if temporary_path is not None:
|
|
166
|
+
temporary_path.unlink(missing_ok=True)
|
|
167
|
+
if not registered:
|
|
168
|
+
self._release_handle_slot()
|
|
137
169
|
raise
|
|
138
170
|
|
|
139
171
|
async def read(self, file_obj: object, offset: int, size: int) -> bytes:
|
|
@@ -163,9 +195,15 @@ class HostBridgeSFTPServer(asyncssh.SFTPServer):
|
|
|
163
195
|
final_size = max(handle.size, write_offset + len(payload))
|
|
164
196
|
if final_size > self._max_bytes:
|
|
165
197
|
raise asyncssh.SFTPFailure(f"file exceeds mock-ssh limit ({self._max_bytes} bytes)")
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
169
207
|
handle.size = final_size
|
|
170
208
|
handle.dirty = True
|
|
171
209
|
return len(payload)
|
|
@@ -185,8 +223,7 @@ class HostBridgeSFTPServer(asyncssh.SFTPServer):
|
|
|
185
223
|
finally:
|
|
186
224
|
handle.closed = True
|
|
187
225
|
handle.temporary_path.unlink(missing_ok=True)
|
|
188
|
-
|
|
189
|
-
self._handles.remove(handle)
|
|
226
|
+
self._release_handle(handle)
|
|
190
227
|
|
|
191
228
|
async def stat(self, path: bytes) -> asyncssh.SFTPAttrs:
|
|
192
229
|
return await self._run_blocking(self._stat, self._path(path), follow=True)
|
|
@@ -218,8 +255,16 @@ class HostBridgeSFTPServer(asyncssh.SFTPServer):
|
|
|
218
255
|
if attrs.size is not None:
|
|
219
256
|
if attrs.size < 0 or attrs.size > self._max_bytes:
|
|
220
257
|
raise asyncssh.SFTPFailure(f"file exceeds mock-ssh limit ({self._max_bytes} bytes)")
|
|
221
|
-
|
|
222
|
-
|
|
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)
|
|
223
268
|
handle.size = attrs.size
|
|
224
269
|
handle.dirty = True
|
|
225
270
|
handle.atime = attrs.atime if attrs.atime is not None else handle.atime
|
|
@@ -291,7 +336,7 @@ class HostBridgeSFTPServer(asyncssh.SFTPServer):
|
|
|
291
336
|
mtime = int(attrs.mtime if attrs.mtime is not None else current.mtime or time.time())
|
|
292
337
|
self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)} {atime} {mtime}")
|
|
293
338
|
|
|
294
|
-
def _download_to(self, remote_path: str, temporary_path: Path) -> None:
|
|
339
|
+
def _download_to(self, remote_path: str, temporary_path: Path, *, max_bytes: int) -> None:
|
|
295
340
|
try:
|
|
296
341
|
result = self._client.download_file(
|
|
297
342
|
self._backend_session_id(),
|
|
@@ -299,12 +344,12 @@ class HostBridgeSFTPServer(asyncssh.SFTPServer):
|
|
|
299
344
|
temporary_path,
|
|
300
345
|
timeout=self._timeout,
|
|
301
346
|
owner="mock-ssh",
|
|
302
|
-
max_bytes=self._max_bytes,
|
|
347
|
+
max_bytes=min(self._max_bytes, max_bytes),
|
|
303
348
|
)
|
|
304
|
-
if result.bytes_transferred > self._max_bytes:
|
|
349
|
+
if result.bytes_transferred > min(self._max_bytes, max_bytes):
|
|
305
350
|
raise HostBridgeError(
|
|
306
351
|
"resource_limit",
|
|
307
|
-
f"file exceeds mock-ssh limit ({
|
|
352
|
+
f"file exceeds mock-ssh SFTP temporary storage limit ({max_bytes} bytes)",
|
|
308
353
|
)
|
|
309
354
|
except HostBridgeError as exc:
|
|
310
355
|
raise self._sftp_error(exc) from exc
|
|
@@ -474,6 +519,59 @@ print(json.dumps(items))
|
|
|
474
519
|
self._session_id = self._session_factory.open()
|
|
475
520
|
return self._session_id
|
|
476
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
|
+
|
|
477
575
|
@staticmethod
|
|
478
576
|
def _path(path: bytes) -> str:
|
|
479
577
|
text = path.decode("utf-8", "surrogateescape")
|