@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
package/README.md CHANGED
@@ -6,26 +6,25 @@ This repo is portable: it ships with no personal server paths or built-in hosts.
6
6
 
7
7
  ## Tools
8
8
 
9
- - `hosts_list` — reload and show configured connection targets and local script availability when applicable.
10
- - `hosts_reload` — explicitly reload configured connection targets without restarting the MCP server.
9
+ - `hosts_list` — show the connection targets loaded by the daemon.
11
10
  - `session_open(host_id, connect_timeout=30)` — open a persistent interactive shell for one configured host.
12
- - `sessions_list()` — list live sessions in this MCP process.
11
+ - `sessions_list()` — list live sessions in the daemon.
13
12
  - `sessions_close_all()` — close every live session in one call; useful for shutdown and cleanup.
14
13
  - `command_run(session_id, command, timeout=60)` — run a foreground command and wait for completion.
15
- - `task_start(session_id, command)` — start a remote background job under `$HOME/.hostbridge/jobs/<task_id>`.
14
+ - `task_start(session_id, command)` — start a remote background job under `$HOME/.hostbridge/jobs/<task_id>` with private permissions.
16
15
  - `task_status(session_id, task_id, tail_lines=80)` — poll job state, exit code, and log tail.
17
16
  - `task_stop(session_id, task_id)` — terminate a running background job and reap its process.
18
17
  - `session_write(session_id, text)` — send a raw line for interactive edge cases.
19
18
  - `session_read(session_id, timeout=1.0, max_chars=20000)` — read available raw output after interactive writes.
20
19
  - `file_write_text(session_id, remote_path, content)` — write a UTF-8 text file through the active shell, up to 100MiB.
21
20
  - `file_read_text(session_id, remote_path)` — read a UTF-8 text file through the active shell, up to 100MiB.
22
- - `file_upload(session_id, local_path, remote_path)` — upload a local file with chunked shell/base64 transfer, up to 100MiB.
23
- - `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.
24
23
  - `session_close(session_id)` — close a persistent session.
25
24
 
26
- Every tool returns a structured payload with a stable `error_code` on failure (`HOST_NOT_FOUND`, `SESSION_NOT_FOUND`, `POLICY_DENIED`, `TASK_NOT_FOUND`, `TIMEOUT`, `INTERNAL`).
25
+ Every tool returns a structured payload with a stable lowercase `error_code`, such as `not_found`, `denied`, `remote_error`, `timeout`, or `internal`.
27
26
 
28
- ## Install and configure v1
27
+ ## Install and configure
29
28
 
30
29
  Install from npm or Python, then create a schema-v1 configuration and start the daemon:
31
30
 
@@ -48,14 +47,14 @@ hostbridge hosts check gpu
48
47
 
49
48
  ## File transfer model
50
49
 
51
- 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:
52
51
 
53
52
  ```bash
54
53
  file_upload(session_id, "/absolute/local/script.py", "/remote/work/script.py")
55
54
  file_download(session_id, "/remote/work/result.log", "/absolute/local/result.log")
56
55
  ```
57
56
 
58
- 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.
59
58
 
60
59
  ## Mock SSH bridge
61
60
 
@@ -91,7 +90,9 @@ The bridge supports:
91
90
  - OpenSSH `scp` in its default SFTP-backed mode.
92
91
  - Legacy OpenSSH `scp -O` mode through AsyncSSH's SCP server.
93
92
 
94
- 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
+
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.
95
96
 
96
97
  ## Daemon lifecycle
97
98
 
@@ -103,12 +104,14 @@ hostbridge daemon status
103
104
  hostbridge daemon stop
104
105
  ```
105
106
 
106
- 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:
107
108
 
108
109
  ```text
109
110
  ~/.hostbridge/daemon.sock
110
111
  ```
111
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. 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
+
112
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`.
113
116
 
114
117
  This avoids fixed port conflicts, removes the local HTTP surface, and keeps the control API local to the current machine. The daemon directory should be private to the local user. To point daemon management commands at a custom socket path:
@@ -127,7 +130,9 @@ For MCP clients, prefer setting the environment variable instead of relying on s
127
130
  }
128
131
  ```
129
132
 
130
- Sessions can outlive a single agent client process. HostBridge records an optional `HOSTBRIDGE_AGENT_ID` owner on sessions and refuses cross-owner operations unless a force-close path is used. Set `HOSTBRIDGE_MAX_SESSIONS_PER_HOST` to cap concurrent live sessions per configured host (default `0`, unlimited).
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
+
135
+ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for runtime ownership, cancellation, and provider invariants.
131
136
 
132
137
  ## Configuration lifecycle
133
138
 
@@ -259,7 +264,7 @@ Claude Desktop style config:
259
264
  "mcpServers": {
260
265
  "hostbridge": {
261
266
  "command": "npx",
262
- "args": ["-y", "hostbridge"],
267
+ "args": ["-y", "@neoline/hostbridge"],
263
268
  "env": {"HOSTBRIDGE_AGENT_ID": "claude-code"}
264
269
  }
265
270
  }
@@ -288,6 +293,8 @@ Environment variables:
288
293
  | `HOSTBRIDGE_DENYLIST=pat1:pat2` | Append extra deny regex patterns (colon-separated). |
289
294
  | `HOSTBRIDGE_IDLE_TIMEOUT=900` | Idle timeout in seconds before an inactive session is reaped (default 1800). |
290
295
  | `HOSTBRIDGE_AUDIT_LOG=/path/to/audit.jsonl` | Override audit log path (default `~/.hostbridge/audit.jsonl`). |
296
+ | `HOSTBRIDGE_AUDIT_MAX_BYTES=10485760` | Rotate the current audit log before it exceeds this size (default 10MiB). |
297
+ | `HOSTBRIDGE_AUDIT_BACKUP_COUNT=3` | Number of rotated audit logs to retain (default 3; `0` retains none). |
291
298
 
292
299
  Default denylist blocks obviously destructive commands (`rm -rf /`, `mkfs`, `dd of=/dev/sd*`, fork bombs, `shutdown`, `reboot`). A denied command returns `error_code=POLICY_DENIED` with the matched pattern, and is recorded in the audit log alongside session/task lifecycle events.
293
300
 
@@ -298,7 +305,9 @@ Policy config file (optional):
298
305
  "idle_timeout_seconds": 1800,
299
306
  "use_default_denylist": true,
300
307
  "command_denylist": ["\\bmytool\\b"],
301
- "audit_log_path": "~/.hostbridge/audit.jsonl"
308
+ "audit_log_path": "~/.hostbridge/audit.jsonl",
309
+ "audit_max_bytes": 10485760,
310
+ "audit_backup_count": 3
302
311
  }
303
312
  ```
304
313
 
package/bin/hostbridge.js CHANGED
@@ -1,22 +1,65 @@
1
1
  #!/usr/bin/env node
2
- import { spawn } from 'node:child_process';
3
- import { existsSync } from 'node:fs';
2
+ import { spawn, spawnSync } from 'node:child_process';
3
+ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
4
4
  import { tmpdir, userInfo } from 'node:os';
5
5
  import { dirname, join, delimiter } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
 
8
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
9
  const packageRoot = dirname(__dirname);
10
- const pythonPath = process.env.HOSTBRIDGE_PYTHON || process.env.SERVER_CONTROL_MCP_PYTHON || 'python3';
11
10
  const bundledSrc = join(packageRoot, 'src');
12
11
  const moduleDir = join(bundledSrc, 'server_control_mcp');
13
12
  const docsUrl = 'https://www.npmjs.com/package/@neoline/hostbridge';
13
+ const packageVersion = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8')).version;
14
14
 
15
15
  function hostbridgeRuntimeDir(username) {
16
16
  const baseTmp = process.platform === 'darwin' ? '/private/tmp' : tmpdir();
17
17
  return join(baseTmp, `hostbridge-${username}`);
18
18
  }
19
19
 
20
+ function ensurePrivateDirectory(path) {
21
+ mkdirSync(path, { recursive: true, mode: 0o700 });
22
+ const metadata = lstatSync(path);
23
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
24
+ throw new Error(`refusing unsafe npm runtime path: ${path}`);
25
+ }
26
+ if (typeof process.getuid === 'function' && metadata.uid !== process.getuid()) {
27
+ throw new Error(`npm runtime path is not owned by the current user: ${path}`);
28
+ }
29
+ chmodSync(path, 0o700);
30
+ }
31
+
32
+ function acquireBootstrapLock(path) {
33
+ const deadline = Date.now() + 120_000;
34
+ const sleeper = new Int32Array(new SharedArrayBuffer(4));
35
+ while (Date.now() < deadline) {
36
+ try {
37
+ mkdirSync(path, { mode: 0o700 });
38
+ return;
39
+ } catch (error) {
40
+ if (error.code !== 'EEXIST') throw error;
41
+ const metadata = lstatSync(path);
42
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
43
+ throw new Error(`refusing unsafe npm bootstrap lock: ${path}`);
44
+ }
45
+ if (Date.now() - metadata.mtimeMs > 300_000) {
46
+ rmSync(path, { recursive: true });
47
+ continue;
48
+ }
49
+ Atomics.wait(sleeper, 0, 0, 50);
50
+ }
51
+ }
52
+ throw new Error(`timed out waiting for npm runtime bootstrap: ${path}`);
53
+ }
54
+
55
+ function runBootstrap(python, args, env) {
56
+ const result = spawnSync(python, args, { stdio: 'inherit', env });
57
+ if (result.error) throw result.error;
58
+ if (result.signal || result.status !== 0) {
59
+ throw new Error(`Python runtime bootstrap failed (${result.signal || `exit ${result.status}`})`);
60
+ }
61
+ }
62
+
20
63
  if (!existsSync(moduleDir)) {
21
64
  console.error(`hostbridge: bundled Python sources not found at ${moduleDir}`);
22
65
  console.error(` This npm package wraps a Python MCP core. See: ${docsUrl}`);
@@ -27,7 +70,40 @@ const env = { ...process.env };
27
70
  env.PYTHONPATH = env.PYTHONPATH ? `${bundledSrc}${delimiter}${env.PYTHONPATH}` : bundledSrc;
28
71
  env.HOSTBRIDGE_DEFAULT_MCP_COMMAND = env.HOSTBRIDGE_DEFAULT_MCP_COMMAND || 'npx';
29
72
  env.HOSTBRIDGE_DEFAULT_MCP_ARGS = env.HOSTBRIDGE_DEFAULT_MCP_ARGS || JSON.stringify(['-y', '@neoline/hostbridge']);
30
- env.HOSTBRIDGE_NPM_LAUNCHER = env.HOSTBRIDGE_NPM_LAUNCHER || '1';
73
+ const configuredPython = env.HOSTBRIDGE_PYTHON || env.SERVER_CONTROL_MCP_PYTHON;
74
+ let pythonPath = configuredPython;
75
+ if (!pythonPath) {
76
+ const username = env.USER || env.LOGNAME || userInfo().username || 'user';
77
+ const npmRuntimeRoot = env.HOSTBRIDGE_NPM_RUNTIME_DIR || join(hostbridgeRuntimeDir(username), 'npm-runtime');
78
+ const environmentDir = join(npmRuntimeRoot, packageVersion);
79
+ const scriptsDir = process.platform === 'win32' ? 'Scripts' : 'bin';
80
+ const environmentPython = join(environmentDir, scriptsDir, process.platform === 'win32' ? 'python.exe' : 'python');
81
+ const lock = join(npmRuntimeRoot, `${packageVersion}.bootstrap.lock`);
82
+ try {
83
+ ensurePrivateDirectory(npmRuntimeRoot);
84
+ acquireBootstrapLock(lock);
85
+ try {
86
+ if (!existsSync(environmentPython)) {
87
+ runBootstrap('python3', ['-m', 'venv', environmentDir], env);
88
+ }
89
+ runBootstrap(
90
+ environmentPython,
91
+ ['-c', 'import server_control_mcp; server_control_mcp.ensure_runtime()'],
92
+ { ...env, HOSTBRIDGE_NPM_LAUNCHER: '1' },
93
+ );
94
+ } finally {
95
+ rmSync(lock, { recursive: true, force: true });
96
+ }
97
+ } catch (error) {
98
+ console.error(`hostbridge: failed to prepare isolated Python runtime: ${error.message}`);
99
+ console.error(` See: ${docsUrl}`);
100
+ process.exit(1);
101
+ }
102
+ pythonPath = environmentPython;
103
+ env.HOSTBRIDGE_NPM_LAUNCHER = '0';
104
+ } else {
105
+ env.HOSTBRIDGE_NPM_LAUNCHER = env.HOSTBRIDGE_NPM_LAUNCHER || '1';
106
+ }
31
107
  if (!env.HOSTBRIDGE_DAEMON_SOCKET && !env.HOSTBRIDGE_DAEMON_DIR) {
32
108
  const username = env.USER || env.LOGNAME || userInfo().username || 'user';
33
109
  env.HOSTBRIDGE_DAEMON_SOCKET = join(hostbridgeRuntimeDir(username), 'daemon.sock');
@@ -55,10 +131,5 @@ child.on('exit', (code, signal) => {
55
131
  process.kill(process.pid, signal);
56
132
  return;
57
133
  }
58
- if (code === 2) {
59
- // exit code 2 is reserved by the Python runtime guard for missing dependencies.
60
- // The actionable install command was already printed to stderr by Python.
61
- console.error(`\nhostbridge: runtime dependency check failed. See: ${docsUrl}`);
62
- }
63
134
  process.exit(code ?? 0);
64
135
  });
@@ -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.
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "hostbridge": {
4
4
  "command": "npx",
5
- "args": ["-y", "hostbridge"],
5
+ "args": ["-y", "@neoline/hostbridge"],
6
6
  "env": {
7
7
  "HOSTBRIDGE_AGENT_ID": "claude-code"
8
8
  }
@@ -1,6 +1,6 @@
1
1
  [mcp_servers.hostbridge]
2
2
  command = "npx"
3
- args = ["-y", "hostbridge"]
3
+ args = ["-y", "@neoline/hostbridge"]
4
4
  [mcp_servers.hostbridge.env]
5
5
  HOSTBRIDGE_AGENT_ID = "codex"
6
6
  # Optional: use a project-specific host file instead of ~/.hostbridge/hosts.json
@@ -1,4 +1,5 @@
1
1
  {
2
+ "schema_version": 1,
2
3
  "hosts": [
3
4
  {
4
5
  "id": "gpu",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neoline/hostbridge",
3
- "version": "2.0.3",
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.3"
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"
@@ -36,10 +36,11 @@ classifiers = [
36
36
  "Topic :: Software Development :: Libraries",
37
37
  ]
38
38
  dependencies = [
39
- "mcp>=1.0.0",
39
+ "packaging>=24.0",
40
+ "mcp>=1.28.1",
40
41
  "pexpect>=4.9.0",
41
- "cryptography>=42.0.0",
42
- "asyncssh>=2.14.0",
42
+ "cryptography>=48.0.1",
43
+ "asyncssh>=2.14.2",
43
44
  ]
44
45
 
45
46
  [project.optional-dependencies]
@@ -3,32 +3,53 @@
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
+ from importlib import metadata
8
9
  from typing import NoReturn
9
10
 
10
11
  __all__ = ["__version__", "ensure_runtime"]
11
- __version__ = "2.0.3"
12
+ __version__ = "2.0.5"
12
13
 
13
- # (import_name, pip_name) pairs for runtime dependency probing.
14
+ # (import_name, requirement) pairs for runtime dependency probing.
14
15
  _REQUIRED_DEPENDENCIES = (
15
- ("mcp", "mcp"),
16
- ("pexpect", "pexpect"),
17
- ("cryptography", "cryptography"),
18
- ("asyncssh", "asyncssh"),
16
+ ("packaging", "packaging>=24.0"),
17
+ ("mcp", "mcp>=1.28.1"),
18
+ ("pexpect", "pexpect>=4.9.0"),
19
+ ("cryptography", "cryptography>=48.0.1"),
20
+ ("asyncssh", "asyncssh>=2.14.2"),
19
21
  )
20
22
 
21
23
 
22
- def _missing_dependencies() -> list[str]:
23
- """Return pip names of runtime dependencies that cannot be imported."""
24
+ def _unsatisfied_dependencies() -> list[str]:
25
+ """Return requirements which are missing or below their supported version."""
24
26
 
25
- missing: list[str] = []
26
- for import_name, pip_name in _REQUIRED_DEPENDENCIES:
27
+ try:
28
+ from packaging.requirements import Requirement
29
+ from packaging.version import InvalidVersion
30
+ except ImportError:
31
+ return ["packaging>=24.0"]
32
+
33
+ unsatisfied: list[str] = []
34
+ for import_name, requirement_text in _REQUIRED_DEPENDENCIES:
27
35
  try:
28
36
  __import__(import_name)
29
37
  except ImportError:
30
- missing.append(pip_name)
31
- return missing
38
+ unsatisfied.append(requirement_text)
39
+ continue
40
+ requirement = Requirement(requirement_text)
41
+ try:
42
+ installed_version = metadata.version(requirement.name)
43
+ except metadata.PackageNotFoundError:
44
+ unsatisfied.append(requirement_text)
45
+ continue
46
+ try:
47
+ supported = requirement.specifier.contains(installed_version, prereleases=True)
48
+ except InvalidVersion:
49
+ supported = False
50
+ if not supported:
51
+ unsatisfied.append(requirement_text)
52
+ return unsatisfied
32
53
 
33
54
 
34
55
  def ensure_runtime() -> None:
@@ -38,15 +59,25 @@ def ensure_runtime() -> None:
38
59
  single, copy-pasteable fix instead of a deep ImportError traceback.
39
60
  """
40
61
 
41
- missing = _missing_dependencies()
42
- if not missing:
62
+ unsatisfied = _unsatisfied_dependencies()
63
+ if not unsatisfied:
43
64
  return
44
- if _auto_install_enabled() and _install_missing_dependencies(missing) and not _missing_dependencies():
45
- return
46
-
47
- install_command = "pip install " + " ".join(missing)
65
+ if _auto_install_enabled():
66
+ attempted_states: set[tuple[str, ...]] = set()
67
+ for _ in range(len(_REQUIRED_DEPENDENCIES) + 1):
68
+ state = tuple(unsatisfied)
69
+ if state in attempted_states:
70
+ break
71
+ attempted_states.add(state)
72
+ if not _install_dependencies(unsatisfied):
73
+ break
74
+ unsatisfied = _unsatisfied_dependencies()
75
+ if not unsatisfied:
76
+ return
77
+
78
+ install_command = "pip install " + " ".join(unsatisfied)
48
79
  message = (
49
- f"hostbridge: missing Python dependencies: {', '.join(missing)}\n"
80
+ f"hostbridge: missing or unsupported Python dependencies: {', '.join(unsatisfied)}\n"
50
81
  f" Install them with: {install_command}\n"
51
82
  " Or reinstall the package: pip install --force-reinstall hostbridge"
52
83
  )
@@ -60,14 +91,19 @@ def _abort_with_error(message: str) -> NoReturn:
60
91
 
61
92
 
62
93
  def _auto_install_enabled() -> bool:
63
- return (os.environ.get("HOSTBRIDGE_AUTO_INSTALL_DEPS") or os.environ.get("HOSTBRIDGE_NPM_LAUNCHER") or "").lower() in {
94
+ return (
95
+ os.environ.get("HOSTBRIDGE_AUTO_INSTALL_DEPS") or os.environ.get("HOSTBRIDGE_NPM_LAUNCHER") or ""
96
+ ).lower() in {
64
97
  "1",
65
98
  "true",
66
99
  "yes",
67
100
  }
68
101
 
69
102
 
70
- def _install_missing_dependencies(missing: list[str]) -> bool:
71
- print(f"hostbridge: installing missing Python dependencies: {', '.join(missing)}", file=sys.stderr)
72
- result = subprocess.run([sys.executable, "-m", "pip", "install", *missing], stdout=sys.stderr, stderr=sys.stderr, check=False)
103
+ def _install_dependencies(requirements: list[str]) -> bool:
104
+ print(f"hostbridge: installing Python dependencies: {', '.join(requirements)}", file=sys.stderr)
105
+ # The executable and pip arguments are package-owned constants.
106
+ result = subprocess.run( # nosec B603
107
+ [sys.executable, "-m", "pip", "install", *requirements], stdout=sys.stderr, stderr=sys.stderr, check=False
108
+ )
73
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")
@@ -153,7 +153,7 @@ def _run_hosts(args: argparse.Namespace) -> int:
153
153
  try:
154
154
  result = client.exec(session.session_id, "printf hostbridge-ok", timeout=args.timeout, owner="cli-check")
155
155
  finally:
156
- client.close_session(session.session_id, owner="cli-check", force=True)
156
+ client.close_session(session.session_id, owner="cli-check")
157
157
  if result.exit_code != 0 or result.stdout != b"hostbridge-ok":
158
158
  raise HostBridgeError("remote_error", "host check command failed")
159
159
  print(f"{args.host_id}: ok")