@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
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)` — upload a local file with chunked shell/base64 transfer, up to 100MiB.
22
- - `file_download(session_id, remote_path, local_path)` — download a remote file with chunked shell/base64 transfer, up to 100MiB.
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 v1
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 transfer is shell-based because recorded login paths may be JumpServer menus, OTP flows, wrapper scripts, or `docker exec` sessions rather than standard SSH/SFTP connections. The first-class transfer tools stream chunked base64 through the already-open shell session, verify hashes, and work for text or binary artifacts up to 100MiB:
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; do not paste base64 into tool arguments. 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 MCP file tools intentionally keep this shell/base64 backend for non-standard login flows. The optional `mock-ssh` bridge below exposes SFTP/SCP protocol compatibility on top of the same backend for clients which require SSH file-transfer channels.
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 opens its own HostBridge backend session and closes it when the channel ends, so an interactive shell `exit` does not kill later exec or file-transfer requests. The SFTP/SCP implementation is a compatibility layer backed by HostBridge's shell/base64 transfer primitives, so it keeps working 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.
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 staged 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. PTY command output is tail-bounded before base64 encoding, so arbitrary bytes are preserved without accumulating unbounded local output.
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 small JSON-line RPC protocol by default, not HTTP and not a TCP port:
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. The daemon accepts at most 64 concurrent control connections; an idle control connection is closed after 30 seconds so abandoned clients cannot consume unbounded worker threads.
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.4",
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hostbridge"
7
- version = "2.0.4"
7
+ version = "2.0.5"
8
8
  description = "HostBridge MCP server for persistent allowlisted SSH and shell sessions."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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.4"
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
- result = subprocess.run(
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 protocol-v1 daemon")
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")