@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
@@ -1,12 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
- from contextlib import suppress
5
4
  from pathlib import Path
6
5
  from typing import Any
7
6
 
8
7
  from cryptography.fernet import Fernet, InvalidToken
9
8
 
9
+ from .secure_files import create_private_file, validate_private_file
10
+
10
11
  DEFAULT_KEY_FILE = Path("~/.hostbridge/key").expanduser()
11
12
  LEGACY_KEY_FILE = Path("~/.server_control_mcp/key").expanduser()
12
13
  SCHEME = "fernet.v1"
@@ -24,13 +25,12 @@ def key_file_path() -> Path:
24
25
  def _load_or_create_key() -> bytes:
25
26
  path = key_file_path()
26
27
  if path.exists():
28
+ validate_private_file(path)
27
29
  return path.read_bytes().strip()
28
- path.parent.mkdir(parents=True, exist_ok=True)
29
30
  key = Fernet.generate_key()
30
- path.write_bytes(key + b"\n")
31
- with suppress(OSError):
32
- path.chmod(0o600)
33
- return key
31
+ if create_private_file(path, key + b"\n"):
32
+ return key
33
+ return path.read_bytes().strip()
34
34
 
35
35
 
36
36
  def encrypt_secret(value: str) -> dict[str, str]:
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ import errno
4
+ import os
5
+ import stat
6
+ import tempfile
7
+ from contextlib import suppress
8
+ from pathlib import Path
9
+
10
+
11
+ def ensure_private_directory(path: Path) -> None:
12
+ path.parent.mkdir(parents=True, exist_ok=True)
13
+ with suppress(FileExistsError):
14
+ os.mkdir(path, 0o700)
15
+ metadata = path.lstat()
16
+ if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
17
+ raise RuntimeError(f"secure directory is not a real directory: {path}")
18
+ if metadata.st_uid != os.getuid():
19
+ raise PermissionError(f"secure directory is not owned by the current user: {path}")
20
+ path.chmod(0o700)
21
+
22
+
23
+ def validate_private_file(path: Path) -> None:
24
+ metadata = path.lstat()
25
+ if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode):
26
+ raise RuntimeError(f"private file is not a regular file: {path}")
27
+ if metadata.st_uid != os.getuid():
28
+ raise PermissionError(f"private file is not owned by the current user: {path}")
29
+ path.chmod(0o600)
30
+
31
+
32
+ def read_private_text(path: Path) -> str:
33
+ nofollow = getattr(os, "O_NOFOLLOW", None)
34
+ if nofollow is None:
35
+ raise RuntimeError("secure private file reads require O_NOFOLLOW")
36
+ flags = os.O_RDONLY | nofollow
37
+ if hasattr(os, "O_CLOEXEC"):
38
+ flags |= os.O_CLOEXEC
39
+ try:
40
+ descriptor = os.open(path, flags)
41
+ except OSError as exc:
42
+ if exc.errno == errno.ELOOP:
43
+ raise RuntimeError(f"private file is not a regular file: {path}") from exc
44
+ raise
45
+ try:
46
+ metadata = os.fstat(descriptor)
47
+ if not stat.S_ISREG(metadata.st_mode):
48
+ raise RuntimeError(f"private file is not a regular file: {path}")
49
+ if metadata.st_uid != os.getuid():
50
+ raise PermissionError(f"private file is not owned by the current user: {path}")
51
+ os.fchmod(descriptor, 0o600)
52
+ with os.fdopen(descriptor, "r", encoding="utf-8") as handle:
53
+ descriptor = -1
54
+ return handle.read()
55
+ finally:
56
+ if descriptor >= 0:
57
+ os.close(descriptor)
58
+
59
+
60
+ def create_private_file(path: Path, data: bytes) -> bool:
61
+ ensure_private_directory(path.parent)
62
+ descriptor, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
63
+ temporary = Path(name)
64
+ try:
65
+ os.fchmod(descriptor, 0o600)
66
+ _write_all(descriptor, data)
67
+ os.fsync(descriptor)
68
+ os.close(descriptor)
69
+ descriptor = -1
70
+ try:
71
+ os.link(temporary, path)
72
+ except FileExistsError:
73
+ validate_private_file(path)
74
+ return False
75
+ validate_private_file(path)
76
+ return True
77
+ finally:
78
+ if descriptor >= 0:
79
+ os.close(descriptor)
80
+ temporary.unlink(missing_ok=True)
81
+
82
+
83
+ def replace_private_file(path: Path, data: bytes) -> None:
84
+ ensure_private_directory(path.parent)
85
+ descriptor, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
86
+ temporary = Path(name)
87
+ try:
88
+ os.fchmod(descriptor, 0o600)
89
+ _write_all(descriptor, data)
90
+ os.fsync(descriptor)
91
+ os.close(descriptor)
92
+ descriptor = -1
93
+ temporary.replace(path)
94
+ validate_private_file(path)
95
+ finally:
96
+ if descriptor >= 0:
97
+ os.close(descriptor)
98
+ temporary.unlink(missing_ok=True)
99
+
100
+
101
+ def open_private_append(path: Path):
102
+ ensure_private_directory(path.parent)
103
+ flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT
104
+ if hasattr(os, "O_NOFOLLOW"):
105
+ flags |= os.O_NOFOLLOW
106
+ descriptor = os.open(path, flags, 0o600)
107
+ metadata = os.fstat(descriptor)
108
+ if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != os.getuid():
109
+ os.close(descriptor)
110
+ raise PermissionError(f"private log is not owned by the current user: {path}")
111
+ os.fchmod(descriptor, 0o600)
112
+ return os.fdopen(descriptor, "a", encoding="utf-8")
113
+
114
+
115
+ def _write_all(descriptor: int, data: bytes) -> None:
116
+ remaining = memoryview(data)
117
+ while remaining:
118
+ written = os.write(descriptor, remaining)
119
+ if written <= 0:
120
+ raise OSError("private file write made no progress")
121
+ remaining = remaining[written:]
@@ -1,9 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  import os
4
5
  import sys
5
- from collections.abc import Callable
6
+ import threading
7
+ from collections.abc import AsyncIterator, Callable
8
+ from contextlib import asynccontextmanager
6
9
  from dataclasses import asdict
10
+ from pathlib import Path
7
11
  from typing import Any, TypeVar
8
12
 
9
13
  from mcp.server.fastmcp import FastMCP
@@ -16,12 +20,39 @@ DEFAULT_CONNECT_TIMEOUT = 30
16
20
  DEFAULT_MAX_TRANSFER_BYTES = 100 * 1024 * 1024
17
21
  MAX_TRANSFER_BYTES = 1024 * 1024 * 1024
18
22
 
19
- mcp = FastMCP("hostbridge")
23
+ _client_instance: HostBridgeClient | None = None
24
+ _client_lock = threading.Lock()
25
+
26
+
27
+ @asynccontextmanager
28
+ async def _mcp_lifespan(_server: FastMCP[Any]) -> AsyncIterator[None]:
29
+ try:
30
+ yield None
31
+ finally:
32
+ await asyncio.to_thread(_close_client)
33
+
34
+
35
+ mcp = FastMCP("hostbridge", lifespan=_mcp_lifespan)
20
36
  T = TypeVar("T")
21
37
 
22
38
 
23
39
  def _client() -> HostBridgeClient:
24
- return HostBridgeClient()
40
+ global _client_instance
41
+ if daemon.start_background(quiet=True) != 0:
42
+ raise RuntimeError(f"hostbridge daemon failed to start; see {daemon.log_file()}")
43
+ with _client_lock:
44
+ if _client_instance is None or _client_instance.closed:
45
+ _client_instance = HostBridgeClient()
46
+ return _client_instance
47
+
48
+
49
+ def _close_client() -> None:
50
+ global _client_instance
51
+ with _client_lock:
52
+ client = _client_instance
53
+ _client_instance = None
54
+ if client is not None:
55
+ client.close()
25
56
 
26
57
 
27
58
  def _owner() -> str | None:
@@ -48,14 +79,7 @@ def _request(
48
79
  owner = _owner()
49
80
  if owner is not None:
50
81
  payload.setdefault("owner", owner)
51
- try:
52
- return _client().request(method, payload, timeout=timeout)
53
- except HostBridgeError as exc:
54
- if exc.code != "connection_lost" or os.environ.get("HOSTBRIDGE_NPM_LAUNCHER"):
55
- raise
56
- if daemon.start_background(quiet=True) != 0:
57
- raise RuntimeError(f"hostbridge daemon failed to start; see {daemon.log_file()}") from exc
58
- return _client().request(method, payload, timeout=timeout)
82
+ return _client().request(method, payload, timeout=timeout)
59
83
 
60
84
 
61
85
  def _mode(value: str | int) -> int:
@@ -78,9 +102,9 @@ def hosts_list() -> dict[str, object]:
78
102
  @mcp.tool()
79
103
  def session_open(host_id: str, connect_timeout: int = DEFAULT_CONNECT_TIMEOUT) -> dict[str, object]:
80
104
  """Open a persistent session for an allowlisted host."""
81
- _ = max(1, min(int(connect_timeout), 180))
105
+ safe_timeout = max(1, min(int(connect_timeout), 180))
82
106
  return _call(
83
- lambda: _client().open_session(host_id, owner=_owner()),
107
+ lambda: _client().open_session(host_id, owner=_owner(), connect_timeout=safe_timeout),
84
108
  lambda value: {"ok": True, "session": asdict(value)},
85
109
  )
86
110
 
@@ -92,10 +116,10 @@ def sessions_list() -> dict[str, object]:
92
116
 
93
117
 
94
118
  @mcp.tool()
95
- def session_close(session_id: str, force: bool = False) -> dict[str, object]:
119
+ def session_close(session_id: str) -> dict[str, object]:
96
120
  """Close a session."""
97
121
  return _call(
98
- lambda: _client().close_session(session_id, owner=_owner(), force=bool(force)),
122
+ lambda: _client().close_session(session_id, owner=_owner()),
99
123
  lambda _: {"ok": True, "session_id": session_id, "closed": True},
100
124
  )
101
125
 
@@ -204,9 +228,18 @@ def file_upload(
204
228
  session_id: str, local_path: str, remote_path: str, mode: str = "0644", max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES
205
229
  ) -> dict[str, object]:
206
230
  """Stream a local file to the remote host."""
207
- del max_bytes
231
+ safe_limit = _limit(max_bytes)
232
+ source = Path(local_path)
233
+ try:
234
+ source_size = source.stat().st_size
235
+ except OSError as exc:
236
+ return _error(exc)
237
+ if source_size > safe_limit:
238
+ return _error(HostBridgeError("resource_limit", f"upload exceeds {safe_limit} bytes"))
208
239
  return _call(
209
- lambda: _client().upload_file(session_id, local_path, remote_path, mode=_mode(mode), owner=_owner()),
240
+ lambda: _client().upload_file(
241
+ session_id, source, remote_path, mode=_mode(mode), owner=_owner(), max_bytes=safe_limit
242
+ ),
210
243
  lambda value: {"ok": True, "transfer": asdict(value)},
211
244
  )
212
245
 
@@ -216,16 +249,16 @@ def file_download(
216
249
  session_id: str, remote_path: str, local_path: str, max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES
217
250
  ) -> dict[str, object]:
218
251
  """Stream a remote file to a local path."""
219
- del max_bytes
252
+ safe_limit = _limit(max_bytes)
220
253
  return _call(
221
- lambda: _client().download_file(session_id, remote_path, local_path, owner=_owner()),
254
+ lambda: _client().download_file(session_id, remote_path, local_path, owner=_owner(), max_bytes=safe_limit),
222
255
  lambda value: {"ok": True, "transfer": asdict(value)},
223
256
  )
224
257
 
225
258
 
226
259
  def main(argv: list[str] | None = None) -> Any:
227
260
  args = sys.argv[1:] if argv is None else argv
228
- if args and (args[0] in cli.SETUP_COMMANDS or args[0] in {"-h", "--help"}):
261
+ if args:
229
262
  return cli.main(args)
230
263
  return mcp.run(transport="stdio")
231
264