@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
package/README.md
CHANGED
|
@@ -18,13 +18,13 @@ This repo is portable: it ships with no personal server paths or built-in hosts.
|
|
|
18
18
|
- `session_read(session_id, timeout=1.0, max_chars=20000)` — read available raw output after interactive writes.
|
|
19
19
|
- `file_write_text(session_id, remote_path, content)` — write a UTF-8 text file through the active shell, up to 100MiB.
|
|
20
20
|
- `file_read_text(session_id, remote_path)` — read a UTF-8 text file through the active shell, up to 100MiB.
|
|
21
|
-
- `file_upload(session_id, local_path, remote_path)` —
|
|
22
|
-
- `file_download(session_id, remote_path, local_path)` —
|
|
21
|
+
- `file_upload(session_id, local_path, remote_path)` — stream a local file as binary data, up to 100MiB.
|
|
22
|
+
- `file_download(session_id, remote_path, local_path)` — stream a remote file as binary data, up to 100MiB.
|
|
23
23
|
- `session_close(session_id)` — close a persistent session.
|
|
24
24
|
|
|
25
25
|
Every tool returns a structured payload with a stable lowercase `error_code`, such as `not_found`, `denied`, `remote_error`, `timeout`, or `internal`.
|
|
26
26
|
|
|
27
|
-
## Install and configure
|
|
27
|
+
## Install and configure
|
|
28
28
|
|
|
29
29
|
Install from npm or Python, then create a schema-v1 configuration and start the daemon:
|
|
30
30
|
|
|
@@ -47,14 +47,14 @@ hostbridge hosts check gpu
|
|
|
47
47
|
|
|
48
48
|
## File transfer model
|
|
49
49
|
|
|
50
|
-
HostBridge file
|
|
50
|
+
HostBridge streams file data as raw bytes over its bounded multiplexed tunnel. Direct SSH targets use SFTP on the host's shared AsyncSSH connection. JumpServer, menu, wrapper, and `docker exec` paths use the persistent PTY remote agent, which implements the same binary upload/download protocol without Base64 expansion. Both providers verify SHA-256 and size before atomically replacing the destination. Transfers are limited to 100MiB by default:
|
|
51
51
|
|
|
52
52
|
```bash
|
|
53
53
|
file_upload(session_id, "/absolute/local/script.py", "/remote/work/script.py")
|
|
54
54
|
file_download(session_id, "/remote/work/result.log", "/absolute/local/result.log")
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
-
Give file tools local and remote paths
|
|
57
|
+
Give file tools local and remote paths. Files larger than 100MiB are deliberately rejected with guidance to use remote pull instead, for example `curl`, `wget`, `git clone`, `modelscope download`, or another remote-native downloader. The optional `mock-ssh` bridge below exposes SFTP/SCP protocol compatibility on top of the same binary transfer service for clients which require SSH file-transfer channels.
|
|
58
58
|
|
|
59
59
|
## Mock SSH bridge
|
|
60
60
|
|
|
@@ -90,9 +90,9 @@ The bridge supports:
|
|
|
90
90
|
- OpenSSH `scp` in its default SFTP-backed mode.
|
|
91
91
|
- Legacy OpenSSH `scp -O` mode through AsyncSSH's SCP server.
|
|
92
92
|
|
|
93
|
-
Each SSH channel
|
|
93
|
+
Each SSH channel owns a HostBridge runtime lease and closes it when the channel ends, while all leases for a host reuse the daemon's single provider connection. An interactive shell `exit` therefore does not kill later exec or file-transfer requests. SFTP/SCP uses the same binary transfer service and works across JumpServer/menu/wrapper login flows where direct remote SFTP is unavailable. It inherits the same transfer size limit (100MiB by default); for larger files, start a remote-native pull from the target host.
|
|
94
94
|
|
|
95
|
-
Mock SSH exec channels are byte-oriented. Binary stdin is
|
|
95
|
+
Mock SSH exec channels are byte-oriented. Binary stdin is streamed with one total deadline, client EOF is propagated to the remote command, and disconnect, timeout, cancellation, or a missing backend exit status closes the HostBridge session. Output remains bytes end to end and is bounded before returning to the client.
|
|
96
96
|
|
|
97
97
|
## Daemon lifecycle
|
|
98
98
|
|
|
@@ -104,13 +104,13 @@ hostbridge daemon status
|
|
|
104
104
|
hostbridge daemon stop
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
-
The daemon uses a Unix domain socket with a
|
|
107
|
+
The daemon uses a Unix domain socket with a versioned binary multiplexing protocol, not HTTP and not a TCP port:
|
|
108
108
|
|
|
109
109
|
```text
|
|
110
110
|
~/.hostbridge/daemon.sock
|
|
111
111
|
```
|
|
112
112
|
|
|
113
|
-
Every client validates both protocol version and the exact HostBridge runtime version. When an older or incompatible daemon owns the configured socket, the launcher shuts it down and starts the current runtime before use.
|
|
113
|
+
Every client validates both protocol version and the exact HostBridge runtime version. When an older or incompatible daemon owns the configured socket, the launcher shuts it down and starts the current runtime before use. Each control connection has bounded pending requests, concurrent handlers, receive windows, and per-stream flow-control credit. Business handlers execute on the daemon event loop; there is no synchronous service worker pool.
|
|
114
114
|
|
|
115
115
|
When launched through the npm wrapper on macOS, HostBridge sets `HOSTBRIDGE_DAEMON_SOCKET` to `/private/tmp/hostbridge-$USER/daemon.sock` so sandboxed MCP launchers do not accidentally isolate the daemon under a rewritten `HOME`. Python-only launches keep the `~/.hostbridge/daemon.sock` default unless you set `HOSTBRIDGE_DAEMON_SOCKET` or `HOSTBRIDGE_DAEMON_DIR`.
|
|
116
116
|
|
|
@@ -132,6 +132,8 @@ For MCP clients, prefer setting the environment variable instead of relying on s
|
|
|
132
132
|
|
|
133
133
|
Sessions can outlive a single agent client process. HostBridge records an optional `HOSTBRIDGE_AGENT_ID` owner on sessions and refuses cross-owner operations unless an internal cleanup path force-closes them. Configure `limits.max_sessions` per host to cap concurrent live sessions (default `16`).
|
|
134
134
|
|
|
135
|
+
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for runtime ownership, cancellation, and provider invariants.
|
|
136
|
+
|
|
135
137
|
## Configuration lifecycle
|
|
136
138
|
|
|
137
139
|
`hostbridge setup` creates an empty schema-v1 file with mode `0600`. It does not run an interactive login recorder. Use `hostbridge hosts add` for new entries and `hostbridge migrate OLD NEW` for legacy files. The daemon loads one validated config at startup; restart it after configuration changes.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# HostBridge Runtime Architecture
|
|
2
|
+
|
|
3
|
+
HostBridge has one asynchronous runtime owned by the local daemon. CLI, MCP, and Mock SSH clients use the same versioned binary Mux protocol over a private Unix socket.
|
|
4
|
+
|
|
5
|
+
## Ownership
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
CLI / MCP / Mock SSH
|
|
9
|
+
|
|
|
10
|
+
v
|
|
11
|
+
MuxDaemonServer
|
|
12
|
+
|
|
|
13
|
+
+-- AsyncHostBridgeServices -- RuntimeServiceSession -- HostRuntimeLease
|
|
14
|
+
|
|
|
15
|
+
+-- forwarding stream registry ------------------------+
|
|
16
|
+
|
|
|
17
|
+
v
|
|
18
|
+
HostTunnelManager
|
|
19
|
+
|
|
|
20
|
+
one HostRuntime per host
|
|
21
|
+
/ \
|
|
22
|
+
Native AsyncSSH PTY agent
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`HostTunnelManager` owns exactly one active provider generation per host. Session leases and forwarding stream handles share that provider. A Native SSH provider owns one AsyncSSH connection; a PTY provider owns one local login process and one persistent remote Mux agent.
|
|
26
|
+
|
|
27
|
+
## Data Plane
|
|
28
|
+
|
|
29
|
+
- Exec stdin, stdout, and stderr are bytes.
|
|
30
|
+
- Upload, download, forwarding, and shell traffic use binary Mux streams.
|
|
31
|
+
- Connection and stream credit provide bounded memory and backpressure.
|
|
32
|
+
- Uploads write a private temporary file, validate expected size and SHA-256, fsync, chmod, and atomically replace the destination.
|
|
33
|
+
- No host data path uses JSONL, Base64, shell line framing, or periodic polling.
|
|
34
|
+
|
|
35
|
+
## Session Lifecycle
|
|
36
|
+
|
|
37
|
+
```text
|
|
38
|
+
OPENING -> ACTIVE -> DRAINING -> CLOSED
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Opening reserves host session capacity before acquiring a runtime lease. Cancellation or rejection after acquisition closes the lease before releasing the reservation.
|
|
42
|
+
|
|
43
|
+
Closing marks the session as draining before releasing the registry lock. Normal close rejects active operations. Force close cancels and bounds active operation shutdown, terminates registered remote jobs, deletes their job directories, closes the lease-owned shell, releases the lease, and removes all session metadata. Finalization runs in its own shielded task, so caller cancellation cannot interrupt resource cleanup.
|
|
44
|
+
|
|
45
|
+
Idle expiry uses one asyncio task on the daemon event loop. Service audit events cover session, command, transfer, and remote-task lifecycle transitions.
|
|
46
|
+
|
|
47
|
+
## Provider Isolation
|
|
48
|
+
|
|
49
|
+
- Shell identity is the internal lease token, never a client-provided shell ID.
|
|
50
|
+
- Shell creation and removal are atomic per provider, preventing duplicate processes during concurrent first access.
|
|
51
|
+
- Cancelling a Native SSH exec channel does not abort the shared host connection.
|
|
52
|
+
- PTY exec and shell processes run in dedicated process groups and use TERM/KILL cleanup deadlines.
|
|
53
|
+
- Provider generation changes invalidate old leases and stream handles without attaching them to a replacement connection.
|
|
54
|
+
|
|
55
|
+
## Shutdown Order
|
|
56
|
+
|
|
57
|
+
1. Stop accepting Mux work and drain or cancel request handlers.
|
|
58
|
+
2. Force-close service sessions and their operations, jobs, and shells.
|
|
59
|
+
3. Close forwarding streams and host providers.
|
|
60
|
+
4. Remove daemon socket and PID files.
|
|
61
|
+
|
|
62
|
+
There is no synchronous service implementation or daemon worker executor. `HostBridgeClient` remains a thread-safe synchronous facade for current CLI, MCP, and Mock SSH callers, but it communicates with the same asynchronous daemon runtime and does not create per-host connections.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neoline/hostbridge",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.5",
|
|
4
4
|
"description": "HostBridge MCP server for persistent allowlisted SSH and shell sessions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"src/server_control_mcp/**/*.py",
|
|
26
26
|
"pyproject.toml",
|
|
27
27
|
"README.md",
|
|
28
|
+
"docs/ARCHITECTURE.md",
|
|
28
29
|
"LICENSE",
|
|
29
30
|
"examples/"
|
|
30
31
|
],
|
package/pyproject.toml
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
|
-
import subprocess
|
|
6
|
+
import subprocess # nosec B404
|
|
7
7
|
import sys
|
|
8
8
|
from importlib import metadata
|
|
9
9
|
from typing import NoReturn
|
|
10
10
|
|
|
11
11
|
__all__ = ["__version__", "ensure_runtime"]
|
|
12
|
-
__version__ = "2.0.
|
|
12
|
+
__version__ = "2.0.5"
|
|
13
13
|
|
|
14
14
|
# (import_name, requirement) pairs for runtime dependency probing.
|
|
15
15
|
_REQUIRED_DEPENDENCIES = (
|
|
@@ -102,7 +102,8 @@ def _auto_install_enabled() -> bool:
|
|
|
102
102
|
|
|
103
103
|
def _install_dependencies(requirements: list[str]) -> bool:
|
|
104
104
|
print(f"hostbridge: installing Python dependencies: {', '.join(requirements)}", file=sys.stderr)
|
|
105
|
-
|
|
105
|
+
# The executable and pip arguments are package-owned constants.
|
|
106
|
+
result = subprocess.run( # nosec B603
|
|
106
107
|
[sys.executable, "-m", "pip", "install", *requirements], stdout=sys.stderr, stderr=sys.stderr, check=False
|
|
107
108
|
)
|
|
108
109
|
return result.returncode == 0
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Shared lifecycle helpers for paired asynchronous pumps."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import TypeVar
|
|
7
|
+
|
|
8
|
+
T = TypeVar("T")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def finish_task_despite_cancellation(task: asyncio.Future[T]) -> T:
|
|
12
|
+
"""Wait for cleanup without allowing repeated caller cancellation to detach it."""
|
|
13
|
+
while True:
|
|
14
|
+
try:
|
|
15
|
+
return await asyncio.shield(task)
|
|
16
|
+
except asyncio.CancelledError:
|
|
17
|
+
if task.done():
|
|
18
|
+
return task.result()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def finish_task_before_cancellation(task: asyncio.Future[T]) -> T:
|
|
22
|
+
"""Finish an owned task before propagating cancellation from its waiter."""
|
|
23
|
+
try:
|
|
24
|
+
return await asyncio.shield(task)
|
|
25
|
+
except asyncio.CancelledError:
|
|
26
|
+
await finish_task_despite_cancellation(task)
|
|
27
|
+
raise
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def stop_input_after_peer_eof(task: asyncio.Task[None]) -> None:
|
|
31
|
+
"""Stop pending input after peer EOF while preserving completed failures."""
|
|
32
|
+
if task.done():
|
|
33
|
+
await task
|
|
34
|
+
return
|
|
35
|
+
task.cancel()
|
|
36
|
+
results = await asyncio.shield(asyncio.gather(task, return_exceptions=True))
|
|
37
|
+
result = results[0]
|
|
38
|
+
if isinstance(result, asyncio.CancelledError):
|
|
39
|
+
return
|
|
40
|
+
if isinstance(result, BaseException):
|
|
41
|
+
raise result
|
|
@@ -78,7 +78,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
78
78
|
check.add_argument("host_id")
|
|
79
79
|
check.add_argument("--timeout", type=int, default=30)
|
|
80
80
|
|
|
81
|
-
daemon = commands.add_parser("daemon", help="manage the
|
|
81
|
+
daemon = commands.add_parser("daemon", help="manage the binary Mux daemon")
|
|
82
82
|
_add_config_argument(daemon)
|
|
83
83
|
daemon_commands = daemon.add_subparsers(dest="daemon_command", required=True)
|
|
84
84
|
start = daemon_commands.add_parser("start")
|