@neoline/hostbridge 1.1.0 → 1.3.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,10 @@ 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
+ if (!env.HOSTBRIDGE_DAEMON_SOCKET && !env.HOSTBRIDGE_DAEMON_DIR) {
31
+ const username = env.USER || env.LOGNAME || userInfo().username || 'user';
32
+ env.HOSTBRIDGE_DAEMON_SOCKET = join(hostbridgeRuntimeDir(username), 'daemon.sock');
33
+ }
24
34
 
25
35
  const child = spawn(pythonPath, ['-m', 'server_control_mcp', ...process.argv.slice(2)], {
26
36
  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.3.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.3.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"
@@ -6,7 +6,7 @@ import sys
6
6
  from typing import NoReturn
7
7
 
8
8
  __all__ = ["__version__", "ensure_runtime"]
9
- __version__ = "1.1.0"
9
+ __version__ = "1.3.0"
10
10
 
11
11
  # (import_name, pip_name) pairs for runtime dependency probing.
12
12
  _REQUIRED_DEPENDENCIES = (
@@ -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,11 +280,27 @@ 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") or DEFAULT_DAEMON_DIR
285
+ return Path(configured).expanduser()
286
+
287
+
283
288
  def socket_path(path: Path | str | None = None) -> Path:
284
- configured = path or os.environ.get("HOSTBRIDGE_DAEMON_SOCKET") or DEFAULT_SOCKET_FILE
289
+ configured = path or os.environ.get("HOSTBRIDGE_DAEMON_SOCKET")
290
+ if configured:
291
+ return Path(configured).expanduser()
292
+ configured = daemon_dir() / "daemon.sock"
285
293
  return Path(configured).expanduser()
286
294
 
287
295
 
296
+ def pid_file() -> Path:
297
+ return daemon_dir() / "daemon.pid"
298
+
299
+
300
+ def log_file() -> Path:
301
+ return daemon_dir() / "daemon.log"
302
+
303
+
288
304
  def _prepare_socket_path(path: Path) -> None:
289
305
  path.parent.mkdir(parents=True, exist_ok=True)
290
306
  with suppress(OSError):
@@ -310,7 +326,7 @@ def run_daemon(*, socket_file: Path | str | None = None) -> int:
310
326
  path = socket_path(socket_file)
311
327
  _prepare_socket_path(path)
312
328
  server = ThreadingUnixRPCServer(str(path), make_handler(manager))
313
- PID_FILE.write_text(str(os.getpid()), encoding="utf-8")
329
+ pid_file().write_text(str(os.getpid()), encoding="utf-8")
314
330
  try:
315
331
  print(f"hostbridge daemon listening on unix://{path}", flush=True)
316
332
  server.serve_forever()
@@ -321,7 +337,7 @@ def run_daemon(*, socket_file: Path | str | None = None) -> int:
321
337
  with suppress(FileNotFoundError):
322
338
  path.unlink()
323
339
  with suppress(FileNotFoundError):
324
- PID_FILE.unlink()
340
+ pid_file().unlink()
325
341
  return 0
326
342
 
327
343
 
@@ -383,7 +399,7 @@ def start_background(*, socket_file: Path | str | None = None, quiet: bool = Fal
383
399
  path.parent.mkdir(parents=True, exist_ok=True)
384
400
  with suppress(OSError):
385
401
  path.parent.chmod(0o700)
386
- log_handle = LOG_FILE.open("a", encoding="utf-8")
402
+ log_handle = log_file().open("a", encoding="utf-8")
387
403
  process = subprocess.Popen(
388
404
  [sys.executable, "-m", "server_control_mcp", "daemon", "start", "--foreground", "--socket", str(path)],
389
405
  stdin=subprocess.DEVNULL,
@@ -392,7 +408,7 @@ def start_background(*, socket_file: Path | str | None = None, quiet: bool = Fal
392
408
  start_new_session=True,
393
409
  env=os.environ.copy(),
394
410
  )
395
- PID_FILE.write_text(str(process.pid), encoding="utf-8")
411
+ pid_file().write_text(str(process.pid), encoding="utf-8")
396
412
  deadline = time.time() + 5
397
413
  while time.time() < deadline:
398
414
  if is_running(socket_file=path):
@@ -401,7 +417,7 @@ def start_background(*, socket_file: Path | str | None = None, quiet: bool = Fal
401
417
  return 0
402
418
  time.sleep(0.1)
403
419
  if not quiet:
404
- print(f"hostbridge daemon failed to start; see {LOG_FILE}", file=sys.stderr)
420
+ print(f"hostbridge daemon failed to start; see {log_file()}", file=sys.stderr)
405
421
  return 1
406
422
 
407
423
 
@@ -411,9 +427,9 @@ def stop_background(*, socket_file: Path | str | None = None) -> int:
411
427
  request_json("POST", "/shutdown", timeout=2.0, socket_file=path)
412
428
  print("hostbridge daemon stopped")
413
429
  return 0
414
- if PID_FILE.exists():
430
+ if pid_file().exists():
415
431
  with suppress(Exception):
416
- os.kill(int(PID_FILE.read_text(encoding="utf-8").strip()), signal.SIGTERM)
432
+ os.kill(int(pid_file().read_text(encoding="utf-8").strip()), signal.SIGTERM)
417
433
  print("hostbridge daemon process signaled")
418
434
  return 0
419
435
  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