@neoline/hostbridge 1.1.0 → 1.4.0

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 CHANGED
@@ -103,12 +103,24 @@ The daemon uses a Unix domain socket with a small JSON-line RPC protocol by defa
103
103
  ~/.hostbridge/daemon.sock
104
104
  ```
105
105
 
106
- This avoids fixed port conflicts, removes the local HTTP surface, and keeps the control API local to the current machine. The `~/.hostbridge` directory should be private to the local user. To point daemon management commands at a custom socket path:
106
+ 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`.
107
+
108
+ 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:
107
109
 
108
110
  ```bash
109
111
  hostbridge daemon start --socket /tmp/hostbridge.sock
110
112
  ```
111
113
 
114
+ For MCP clients, prefer setting the environment variable instead of relying on symlinks:
115
+
116
+ ```json
117
+ {
118
+ "env": {
119
+ "HOSTBRIDGE_DAEMON_SOCKET": "/private/tmp/hostbridge-shengzhoukong/daemon.sock"
120
+ }
121
+ }
122
+ ```
123
+
112
124
  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).
113
125
 
114
126
  ## Guided setup wizard
package/bin/hostbridge.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from 'node:child_process';
3
3
  import { existsSync } from 'node:fs';
4
+ import { tmpdir, userInfo } from 'node:os';
4
5
  import { dirname, join, delimiter } from 'node:path';
5
6
  import { fileURLToPath } from 'node:url';
6
7
 
@@ -11,6 +12,11 @@ const bundledSrc = join(packageRoot, 'src');
11
12
  const moduleDir = join(bundledSrc, 'server_control_mcp');
12
13
  const docsUrl = 'https://www.npmjs.com/package/@neoline/hostbridge';
13
14
 
15
+ function hostbridgeRuntimeDir(username) {
16
+ const baseTmp = process.platform === 'darwin' ? '/private/tmp' : tmpdir();
17
+ return join(baseTmp, `hostbridge-${username}`);
18
+ }
19
+
14
20
  if (!existsSync(moduleDir)) {
15
21
  console.error(`hostbridge: bundled Python sources not found at ${moduleDir}`);
16
22
  console.error(` This npm package wraps a Python MCP core. See: ${docsUrl}`);
@@ -21,6 +27,11 @@ const env = { ...process.env };
21
27
  env.PYTHONPATH = env.PYTHONPATH ? `${bundledSrc}${delimiter}${env.PYTHONPATH}` : bundledSrc;
22
28
  env.HOSTBRIDGE_DEFAULT_MCP_COMMAND = env.HOSTBRIDGE_DEFAULT_MCP_COMMAND || 'npx';
23
29
  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';
31
+ if (!env.HOSTBRIDGE_DAEMON_SOCKET && !env.HOSTBRIDGE_DAEMON_DIR) {
32
+ const username = env.USER || env.LOGNAME || userInfo().username || 'user';
33
+ env.HOSTBRIDGE_DAEMON_SOCKET = join(hostbridgeRuntimeDir(username), 'daemon.sock');
34
+ }
24
35
 
25
36
  const child = spawn(pythonPath, ['-m', 'server_control_mcp', ...process.argv.slice(2)], {
26
37
  stdio: 'inherit',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neoline/hostbridge",
3
- "version": "1.1.0",
3
+ "version": "1.4.0",
4
4
  "description": "HostBridge MCP server for persistent allowlisted SSH and shell sessions.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hostbridge"
7
- version = "1.1.0"
7
+ version = "1.4.0"
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,10 +3,12 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import sys
6
+ import os
7
+ import subprocess
6
8
  from typing import NoReturn
7
9
 
8
10
  __all__ = ["__version__", "ensure_runtime"]
9
- __version__ = "1.1.0"
11
+ __version__ = "1.4.0"
10
12
 
11
13
  # (import_name, pip_name) pairs for runtime dependency probing.
12
14
  _REQUIRED_DEPENDENCIES = (
@@ -38,6 +40,9 @@ def ensure_runtime() -> None:
38
40
  missing = _missing_dependencies()
39
41
  if not missing:
40
42
  return
43
+ if _auto_install_enabled() and _install_missing_dependencies(missing):
44
+ if not _missing_dependencies():
45
+ return
41
46
 
42
47
  install_command = "pip install " + " ".join(missing)
43
48
  message = (
@@ -52,3 +57,17 @@ def ensure_runtime() -> None:
52
57
  def _abort_with_error(message: str) -> NoReturn:
53
58
  print(message, file=sys.stderr)
54
59
  sys.exit(2)
60
+
61
+
62
+ def _auto_install_enabled() -> bool:
63
+ return (os.environ.get("HOSTBRIDGE_AUTO_INSTALL_DEPS") or os.environ.get("HOSTBRIDGE_NPM_LAUNCHER") or "").lower() in {
64
+ "1",
65
+ "true",
66
+ "yes",
67
+ }
68
+
69
+
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)
73
+ return result.returncode == 0
@@ -1157,11 +1157,11 @@ def main(argv: list[str] | None = None) -> int:
1157
1157
  daemon_subparsers = daemon_parser.add_subparsers(dest="daemon_command", required=True)
1158
1158
  daemon_start = daemon_subparsers.add_parser("start", help="Start the HostBridge daemon")
1159
1159
  daemon_start.add_argument("--foreground", action="store_true", help="Run daemon in the foreground")
1160
- daemon_start.add_argument("--socket", dest="socket_file", default=None, help="Unix socket path; default ~/.hostbridge/daemon.sock")
1160
+ daemon_start.add_argument("--socket", dest="socket_file", default=None, help="Unix socket path; default comes from HOSTBRIDGE_DAEMON_SOCKET or HOSTBRIDGE_DAEMON_DIR")
1161
1161
  daemon_stop = daemon_subparsers.add_parser("stop", help="Stop the HostBridge daemon")
1162
- daemon_stop.add_argument("--socket", dest="socket_file", default=None, help="Unix socket path; default ~/.hostbridge/daemon.sock")
1162
+ daemon_stop.add_argument("--socket", dest="socket_file", default=None, help="Unix socket path; default comes from HOSTBRIDGE_DAEMON_SOCKET or HOSTBRIDGE_DAEMON_DIR")
1163
1163
  daemon_status = daemon_subparsers.add_parser("status", help="Show daemon status")
1164
- daemon_status.add_argument("--socket", dest="socket_file", default=None, help="Unix socket path; default ~/.hostbridge/daemon.sock")
1164
+ daemon_status.add_argument("--socket", dest="socket_file", default=None, help="Unix socket path; default comes from HOSTBRIDGE_DAEMON_SOCKET or HOSTBRIDGE_DAEMON_DIR")
1165
1165
 
1166
1166
  remove_parser = subparsers.add_parser("remove", parents=[config_parent], help="Remove a configured host")
1167
1167
  remove_parser.add_argument("host_id")
@@ -27,7 +27,7 @@ from .manager import (
27
27
  )
28
28
  from .policy import build_audit_sink, load_policy
29
29
 
30
- DEFAULT_DAEMON_DIR = Path("~/.hostbridge").expanduser()
30
+ DEFAULT_DAEMON_DIR = Path("~/.hostbridge")
31
31
  DEFAULT_SOCKET_FILE = DEFAULT_DAEMON_DIR / "daemon.sock"
32
32
  PID_FILE = DEFAULT_DAEMON_DIR / "daemon.pid"
33
33
  LOG_FILE = DEFAULT_DAEMON_DIR / "daemon.log"
@@ -280,13 +280,37 @@ def build_manager() -> SessionManager:
280
280
  return manager
281
281
 
282
282
 
283
+ def daemon_dir(path: Path | str | None = None) -> Path:
284
+ configured = path or os.environ.get("HOSTBRIDGE_DAEMON_DIR")
285
+ if configured:
286
+ return Path(configured).expanduser()
287
+ socket_override = os.environ.get("HOSTBRIDGE_DAEMON_SOCKET")
288
+ if socket_override:
289
+ return Path(socket_override).expanduser().parent
290
+ configured = DEFAULT_DAEMON_DIR
291
+ return Path(configured).expanduser()
292
+
293
+
283
294
  def socket_path(path: Path | str | None = None) -> Path:
284
- configured = path or os.environ.get("HOSTBRIDGE_DAEMON_SOCKET") or DEFAULT_SOCKET_FILE
295
+ configured = path or os.environ.get("HOSTBRIDGE_DAEMON_SOCKET")
296
+ if configured:
297
+ return Path(configured).expanduser()
298
+ configured = daemon_dir() / "daemon.sock"
285
299
  return Path(configured).expanduser()
286
300
 
287
301
 
302
+ def pid_file() -> Path:
303
+ return daemon_dir() / "daemon.pid"
304
+
305
+
306
+ def log_file() -> Path:
307
+ return daemon_dir() / "daemon.log"
308
+
309
+
288
310
  def _prepare_socket_path(path: Path) -> None:
289
311
  path.parent.mkdir(parents=True, exist_ok=True)
312
+ log_file().parent.mkdir(parents=True, exist_ok=True)
313
+ pid_file().parent.mkdir(parents=True, exist_ok=True)
290
314
  with suppress(OSError):
291
315
  path.parent.chmod(0o700)
292
316
  if path.exists():
@@ -310,7 +334,7 @@ def run_daemon(*, socket_file: Path | str | None = None) -> int:
310
334
  path = socket_path(socket_file)
311
335
  _prepare_socket_path(path)
312
336
  server = ThreadingUnixRPCServer(str(path), make_handler(manager))
313
- PID_FILE.write_text(str(os.getpid()), encoding="utf-8")
337
+ pid_file().write_text(str(os.getpid()), encoding="utf-8")
314
338
  try:
315
339
  print(f"hostbridge daemon listening on unix://{path}", flush=True)
316
340
  server.serve_forever()
@@ -321,7 +345,7 @@ def run_daemon(*, socket_file: Path | str | None = None) -> int:
321
345
  with suppress(FileNotFoundError):
322
346
  path.unlink()
323
347
  with suppress(FileNotFoundError):
324
- PID_FILE.unlink()
348
+ pid_file().unlink()
325
349
  return 0
326
350
 
327
351
 
@@ -383,7 +407,7 @@ def start_background(*, socket_file: Path | str | None = None, quiet: bool = Fal
383
407
  path.parent.mkdir(parents=True, exist_ok=True)
384
408
  with suppress(OSError):
385
409
  path.parent.chmod(0o700)
386
- log_handle = LOG_FILE.open("a", encoding="utf-8")
410
+ log_handle = log_file().open("a", encoding="utf-8")
387
411
  process = subprocess.Popen(
388
412
  [sys.executable, "-m", "server_control_mcp", "daemon", "start", "--foreground", "--socket", str(path)],
389
413
  stdin=subprocess.DEVNULL,
@@ -392,7 +416,7 @@ def start_background(*, socket_file: Path | str | None = None, quiet: bool = Fal
392
416
  start_new_session=True,
393
417
  env=os.environ.copy(),
394
418
  )
395
- PID_FILE.write_text(str(process.pid), encoding="utf-8")
419
+ pid_file().write_text(str(process.pid), encoding="utf-8")
396
420
  deadline = time.time() + 5
397
421
  while time.time() < deadline:
398
422
  if is_running(socket_file=path):
@@ -401,7 +425,7 @@ def start_background(*, socket_file: Path | str | None = None, quiet: bool = Fal
401
425
  return 0
402
426
  time.sleep(0.1)
403
427
  if not quiet:
404
- print(f"hostbridge daemon failed to start; see {LOG_FILE}", file=sys.stderr)
428
+ print(f"hostbridge daemon failed to start; see {log_file()}", file=sys.stderr)
405
429
  return 1
406
430
 
407
431
 
@@ -411,9 +435,9 @@ def stop_background(*, socket_file: Path | str | None = None) -> int:
411
435
  request_json("POST", "/shutdown", timeout=2.0, socket_file=path)
412
436
  print("hostbridge daemon stopped")
413
437
  return 0
414
- if PID_FILE.exists():
438
+ if pid_file().exists():
415
439
  with suppress(Exception):
416
- os.kill(int(PID_FILE.read_text(encoding="utf-8").strip()), signal.SIGTERM)
440
+ os.kill(int(pid_file().read_text(encoding="utf-8").strip()), signal.SIGTERM)
417
441
  print("hostbridge daemon process signaled")
418
442
  return 0
419
443
  print("hostbridge daemon is not running")
@@ -52,7 +52,7 @@ def _daemon_request(method: str, path: str, payload: dict[str, object] | None =
52
52
  except OSError as exc:
53
53
  # MCP stdout must stay protocol-clean, so auto-start the daemon quietly.
54
54
  if daemon.start_background(quiet=True) != 0:
55
- raise RuntimeError(f"hostbridge daemon failed to start; see {daemon.LOG_FILE}") from exc
55
+ raise RuntimeError(f"hostbridge daemon failed to start; see {daemon.log_file()}") from exc
56
56
  return daemon.request_json(method, path, payload, timeout=timeout)
57
57
 
58
58