@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 +13 -1
- package/bin/hostbridge.js +11 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/server_control_mcp/__init__.py +20 -1
- package/src/server_control_mcp/cli.py +3 -3
- package/src/server_control_mcp/daemon.py +33 -9
- package/src/server_control_mcp/server.py +1 -1
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
|
-
|
|
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
package/pyproject.toml
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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
|
|
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")
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
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
|
|
438
|
+
if pid_file().exists():
|
|
415
439
|
with suppress(Exception):
|
|
416
|
-
os.kill(int(
|
|
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.
|
|
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
|
|