@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
|
@@ -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
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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()
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
261
|
+
if args:
|
|
229
262
|
return cli.main(args)
|
|
230
263
|
return mcp.run(transport="stdio")
|
|
231
264
|
|