@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.
Files changed (40) hide show
  1. package/README.md +11 -9
  2. package/docs/ARCHITECTURE.md +62 -0
  3. package/package.json +2 -1
  4. package/pyproject.toml +1 -1
  5. package/src/server_control_mcp/__init__.py +4 -3
  6. package/src/server_control_mcp/async_lifecycle.py +41 -0
  7. package/src/server_control_mcp/cli.py +1 -1
  8. package/src/server_control_mcp/client.py +302 -287
  9. package/src/server_control_mcp/config.py +2 -1
  10. package/src/server_control_mcp/daemon.py +233 -526
  11. package/src/server_control_mcp/doctor.py +34 -2
  12. package/src/server_control_mcp/hosts.py +136 -2
  13. package/src/server_control_mcp/mock_ssh.py +52 -14
  14. package/src/server_control_mcp/mock_ssh_exec.py +147 -58
  15. package/src/server_control_mcp/mock_ssh_session.py +3 -2
  16. package/src/server_control_mcp/mock_ssh_sftp.py +126 -28
  17. package/src/server_control_mcp/mock_ssh_tunnel.py +141 -444
  18. package/src/server_control_mcp/mux_connection.py +326 -0
  19. package/src/server_control_mcp/mux_daemon.py +186 -0
  20. package/src/server_control_mcp/mux_protocol.py +279 -0
  21. package/src/server_control_mcp/mux_records.py +71 -0
  22. package/src/server_control_mcp/mux_rpc.py +1779 -0
  23. package/src/server_control_mcp/mux_service.py +506 -0
  24. package/src/server_control_mcp/mux_stream.py +283 -0
  25. package/src/server_control_mcp/mux_sync_client.py +224 -0
  26. package/src/server_control_mcp/remote_agent_bundle.py +56 -0
  27. package/src/server_control_mcp/remote_mux_agent.py +769 -0
  28. package/src/server_control_mcp/runtime_services.py +862 -0
  29. package/src/server_control_mcp/server.py +33 -18
  30. package/src/server_control_mcp/services.py +2 -666
  31. package/src/server_control_mcp/transports/__init__.py +4 -8
  32. package/src/server_control_mcp/transports/base.py +60 -38
  33. package/src/server_control_mcp/transports/ssh.py +206 -259
  34. package/src/server_control_mcp/tunnel_manager.py +1245 -0
  35. package/src/server_control_mcp/tunnel_native_ssh.py +454 -0
  36. package/src/server_control_mcp/tunnel_providers.py +20 -0
  37. package/src/server_control_mcp/tunnel_pty_agent.py +909 -0
  38. package/src/server_control_mcp/protocol.py +0 -363
  39. package/src/server_control_mcp/remote_tunnel_agent.py +0 -143
  40. 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.close_session(session_id, owner="mock-ssh")
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
- assert last_error is not None
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._download_to(remote_path, temporary_path)
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._handles.append(handle)
161
+ self._register_handle(handle)
162
+ registered = True
134
163
  return handle
135
164
  except BaseException:
136
- temporary_path.unlink(missing_ok=True)
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
- with handle.temporary_path.open("r+b") as stream:
167
- stream.seek(write_offset)
168
- stream.write(payload)
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
- with suppress(ValueError):
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
- with handle.temporary_path.open("r+b") as stream:
222
- stream.truncate(attrs.size)
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 ({self._max_bytes} bytes)",
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")