@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.
Files changed (48) hide show
  1. package/README.md +24 -15
  2. package/bin/hostbridge.js +80 -9
  3. package/docs/ARCHITECTURE.md +62 -0
  4. package/examples/claude_desktop_config.json +1 -1
  5. package/examples/codex.config.toml +1 -1
  6. package/examples/hosts.example.json +1 -0
  7. package/package.json +2 -1
  8. package/pyproject.toml +5 -4
  9. package/src/server_control_mcp/__init__.py +60 -24
  10. package/src/server_control_mcp/async_lifecycle.py +41 -0
  11. package/src/server_control_mcp/cli.py +2 -2
  12. package/src/server_control_mcp/client.py +345 -238
  13. package/src/server_control_mcp/config.py +18 -6
  14. package/src/server_control_mcp/daemon.py +309 -412
  15. package/src/server_control_mcp/doctor.py +41 -5
  16. package/src/server_control_mcp/hosts.py +252 -24
  17. package/src/server_control_mcp/mock_ssh.py +97 -960
  18. package/src/server_control_mcp/mock_ssh_exec.py +299 -0
  19. package/src/server_control_mcp/mock_ssh_session.py +69 -0
  20. package/src/server_control_mcp/mock_ssh_sftp.py +604 -0
  21. package/src/server_control_mcp/mock_ssh_tunnel.py +289 -0
  22. package/src/server_control_mcp/mux_connection.py +326 -0
  23. package/src/server_control_mcp/mux_daemon.py +186 -0
  24. package/src/server_control_mcp/mux_protocol.py +279 -0
  25. package/src/server_control_mcp/mux_records.py +71 -0
  26. package/src/server_control_mcp/mux_rpc.py +1779 -0
  27. package/src/server_control_mcp/mux_service.py +506 -0
  28. package/src/server_control_mcp/mux_stream.py +283 -0
  29. package/src/server_control_mcp/mux_sync_client.py +224 -0
  30. package/src/server_control_mcp/policy.py +86 -14
  31. package/src/server_control_mcp/remote_agent_bundle.py +56 -0
  32. package/src/server_control_mcp/remote_mux_agent.py +769 -0
  33. package/src/server_control_mcp/remote_task_agent.py +102 -0
  34. package/src/server_control_mcp/runtime.py +3 -2
  35. package/src/server_control_mcp/runtime_services.py +862 -0
  36. package/src/server_control_mcp/secrets.py +6 -6
  37. package/src/server_control_mcp/secure_files.py +121 -0
  38. package/src/server_control_mcp/server.py +53 -20
  39. package/src/server_control_mcp/services.py +3 -469
  40. package/src/server_control_mcp/transports/__init__.py +4 -8
  41. package/src/server_control_mcp/transports/base.py +60 -38
  42. package/src/server_control_mcp/transports/ssh.py +315 -84
  43. package/src/server_control_mcp/tunnel_manager.py +1245 -0
  44. package/src/server_control_mcp/tunnel_native_ssh.py +454 -0
  45. package/src/server_control_mcp/tunnel_providers.py +20 -0
  46. package/src/server_control_mcp/tunnel_pty_agent.py +909 -0
  47. package/src/server_control_mcp/protocol.py +0 -362
  48. 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)