@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 +13 -1
- package/bin/hostbridge.js +10 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/server_control_mcp/__init__.py +1 -1
- package/src/server_control_mcp/cli.py +3 -3
- package/src/server_control_mcp/daemon.py +25 -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,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
package/pyproject.toml
CHANGED
|
@@ -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,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")
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
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
|
|
430
|
+
if pid_file().exists():
|
|
415
431
|
with suppress(Exception):
|
|
416
|
-
os.kill(int(
|
|
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.
|
|
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
|
|