@neoline/hostbridge 0.2.1 → 1.0.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 +57 -11
- package/examples/claude_desktop_config.json +4 -1
- package/examples/codex.config.toml +2 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/server_control_mcp/__init__.py +1 -1
- package/src/server_control_mcp/__main__.py +6 -3
- package/src/server_control_mcp/cli.py +52 -12
- package/src/server_control_mcp/daemon.py +419 -0
- package/src/server_control_mcp/manager.py +294 -11
- package/src/server_control_mcp/server.py +144 -115
package/README.md
CHANGED
|
@@ -17,6 +17,10 @@ This repo is portable: it ships with no personal server paths or built-in hosts.
|
|
|
17
17
|
- `task_stop(session_id, task_id)` — terminate a running background job and reap its process.
|
|
18
18
|
- `session_write(session_id, text)` — send a raw line for interactive edge cases.
|
|
19
19
|
- `session_read(session_id, timeout=1.0, max_chars=20000)` — read available raw output after interactive writes.
|
|
20
|
+
- `file_write_text(session_id, remote_path, content)` — write a small UTF-8 text file through the active shell.
|
|
21
|
+
- `file_read_text(session_id, remote_path)` — read a small UTF-8 text file through the active shell.
|
|
22
|
+
- `file_upload(session_id, local_path, remote_path)` — upload a small local file with shell/base64 transfer.
|
|
23
|
+
- `file_download(session_id, remote_path, local_path)` — download a small remote file with shell/base64 transfer.
|
|
20
24
|
- `session_close(session_id)` — close a persistent session.
|
|
21
25
|
|
|
22
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`).
|
|
@@ -70,6 +74,43 @@ python3 -m server_control_mcp.server install
|
|
|
70
74
|
npm run test:node
|
|
71
75
|
```
|
|
72
76
|
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
## File transfer model
|
|
80
|
+
|
|
81
|
+
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 therefore use base64 through the already-open shell session and work best for scripts, configs, logs, and small artifacts:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
file_upload(session_id, "/absolute/local/script.py", "/remote/work/script.py")
|
|
85
|
+
file_download(session_id, "/remote/work/result.log", "/absolute/local/result.log")
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Large files are deliberately rejected with guidance to use remote pull instead, for example `curl`, `wget`, `git clone`, `modelscope download`, or another remote-native downloader. HostBridge does not plan a standard SSH/SFTP fast path; the reliable fallback for non-standard login flows is shell/base64, and large model/data movement should happen from the remote host.
|
|
89
|
+
|
|
90
|
+
## Daemon lifecycle
|
|
91
|
+
|
|
92
|
+
HostBridge always keeps remote sessions in the local daemon. The MCP server process is only a lightweight stdio adapter for agent clients and talks to the daemon over a Unix domain socket. If the daemon is not already running, the MCP adapter starts it lazily on the first tool call.
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
hostbridge daemon start
|
|
96
|
+
hostbridge daemon status
|
|
97
|
+
hostbridge daemon stop
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The daemon uses a Unix domain socket with a small JSON-line RPC protocol by default, not HTTP and not a TCP port:
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
~/.hostbridge/daemon.sock
|
|
104
|
+
```
|
|
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:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
hostbridge daemon start --socket /tmp/hostbridge.sock
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
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
|
+
|
|
73
114
|
## Guided setup wizard
|
|
74
115
|
|
|
75
116
|
Run the local setup wizard instead of hand-writing `hosts.json`:
|
|
@@ -121,9 +162,10 @@ Recommended agent flow:
|
|
|
121
162
|
|
|
122
163
|
1. Call `hosts_list`.
|
|
123
164
|
2. If the target host exists, call `session_open`.
|
|
124
|
-
3. If
|
|
125
|
-
4.
|
|
126
|
-
5.
|
|
165
|
+
3. If `session_open` times out or fails, run `hostbridge check <host_id>` to verify the recorded login path, then retry or repair setup.
|
|
166
|
+
4. If it does not exist and the user identifies a similar bastion-backed host, run `hostbridge setup <new_id> --from-host <source_id> --menu-selection <ip-or-name> --final-prompt <regex>`.
|
|
167
|
+
5. Call `hosts_list` again, then `session_open(<new_id>)`.
|
|
168
|
+
6. Do not inspect `~/.ssh`, old expect scripts, or shell snapshots unless the user explicitly asks or the setup command lacks required information.
|
|
127
169
|
|
|
128
170
|
### Encrypted login automation
|
|
129
171
|
|
|
@@ -269,6 +311,8 @@ Codex config written by the installer, for example in `~/.codex/config.toml`:
|
|
|
269
311
|
[mcp_servers.hostbridge]
|
|
270
312
|
command = "npx"
|
|
271
313
|
args = ["-y", "hostbridge"]
|
|
314
|
+
[mcp_servers.hostbridge.env]
|
|
315
|
+
HOSTBRIDGE_AGENT_ID = "codex"
|
|
272
316
|
```
|
|
273
317
|
|
|
274
318
|
Claude Desktop style config:
|
|
@@ -278,7 +322,8 @@ Claude Desktop style config:
|
|
|
278
322
|
"mcpServers": {
|
|
279
323
|
"hostbridge": {
|
|
280
324
|
"command": "npx",
|
|
281
|
-
"args": ["-y", "hostbridge"]
|
|
325
|
+
"args": ["-y", "hostbridge"],
|
|
326
|
+
"env": {"HOSTBRIDGE_AGENT_ID": "claude-code"}
|
|
282
327
|
}
|
|
283
328
|
}
|
|
284
329
|
}
|
|
@@ -295,7 +340,7 @@ See `examples/` for copyable config templates.
|
|
|
295
340
|
|
|
296
341
|
## Policy, audit, and idle reaping
|
|
297
342
|
|
|
298
|
-
hostbridge ships a lightweight policy layer that runs
|
|
343
|
+
hostbridge ships a lightweight policy layer that runs inside the daemon process. It is on by default and can be tuned or disabled via environment variables or a JSON config file.
|
|
299
344
|
|
|
300
345
|
Environment variables:
|
|
301
346
|
|
|
@@ -327,11 +372,12 @@ See `SECURITY.md` and `docs/THREAT_MODEL.md` for the trust model and audited eve
|
|
|
327
372
|
1. Call `hosts_list` and choose a configured host id.
|
|
328
373
|
2. If the host is missing, ask for only the missing fields and use `hostbridge setup <host_id>` rather than manually editing config.
|
|
329
374
|
3. Call `session_open` once for the chosen host.
|
|
330
|
-
4.
|
|
331
|
-
5. Use `
|
|
332
|
-
6.
|
|
333
|
-
7.
|
|
334
|
-
8. Call `
|
|
375
|
+
4. Only if `session_open` times out or fails, run `hostbridge check <host_id>` and retry after fixing the login path.
|
|
376
|
+
5. Use `command_run` for quick commands such as `nvidia-smi`, `pwd`, `df -h`, or `tmux ls`.
|
|
377
|
+
6. Use `task_start` for long training jobs, downloads, builds, or scripts.
|
|
378
|
+
7. Poll `task_status` until `state` is `finished`; inspect `exit_code` and `log_tail`.
|
|
379
|
+
8. Call `task_stop` early if a job should be aborted.
|
|
380
|
+
9. Call `session_close` when finished, or `sessions_close_all` to tear down every session.
|
|
335
381
|
|
|
336
382
|
## Security notes
|
|
337
383
|
|
|
@@ -347,4 +393,4 @@ See `CONTRIBUTING.md`. Run tests with `python -m pytest -q`. The full suite shou
|
|
|
347
393
|
|
|
348
394
|
## License
|
|
349
395
|
|
|
350
|
-
MIT. See `LICENSE`.
|
|
396
|
+
MIT. See `LICENSE`.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[mcp_servers.hostbridge]
|
|
2
2
|
command = "npx"
|
|
3
3
|
args = ["-y", "hostbridge"]
|
|
4
|
-
|
|
4
|
+
[mcp_servers.hostbridge.env]
|
|
5
|
+
HOSTBRIDGE_AGENT_ID = "codex"
|
|
5
6
|
# Optional: use a project-specific host file instead of ~/.hostbridge/hosts.json
|
|
6
|
-
# [mcp_servers.hostbridge.env]
|
|
7
7
|
# HOSTBRIDGE_HOSTS_FILE = "/absolute/path/to/hosts.json"
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import sys
|
|
4
|
+
|
|
3
5
|
from . import ensure_runtime
|
|
4
6
|
|
|
5
7
|
|
|
6
|
-
def main() ->
|
|
8
|
+
def main() -> int:
|
|
7
9
|
ensure_runtime()
|
|
8
10
|
# Imported lazily so a missing mcp/pexpect/cryptography dependency surfaces
|
|
9
11
|
# the friendly ensure_runtime() message instead of a raw ImportError.
|
|
10
12
|
from .server import main as server_main
|
|
11
13
|
|
|
12
|
-
server_main()
|
|
14
|
+
result = server_main()
|
|
15
|
+
return result if isinstance(result, int) else 0
|
|
13
16
|
|
|
14
17
|
|
|
15
18
|
if __name__ == "__main__":
|
|
16
|
-
main()
|
|
19
|
+
sys.exit(main())
|
|
@@ -35,9 +35,11 @@ Use HostBridge when the user asks to connect to, inspect, or operate an allowlis
|
|
|
35
35
|
|
|
36
36
|
- First call `hosts_list` to see configured hosts; then use `session_open("<host_id>")`.
|
|
37
37
|
- If the host is missing, ask only for the missing connection details and run `hostbridge setup <host_id>` instead of editing JSON by hand.
|
|
38
|
-
-
|
|
38
|
+
- Do not run `hostbridge check` before every connection; only use `hostbridge check <host_id>` when `session_open` times out or fails.
|
|
39
39
|
- Do not search shell history, SSH config, or old scripts unless the setup flow lacks required information or the user explicitly asks.
|
|
40
40
|
- Keep long-running work in `task_start`/`task_status`; use `session_close` or `sessions_close_all` when done.
|
|
41
|
+
- Use `file_write_text`/`file_read_text` for small UTF-8 files and `file_upload`/`file_download` for small binary or text artifacts.
|
|
42
|
+
- Do not use HostBridge file tools for large model/data transfers; start a remote pull instead (`curl`, `wget`, `git clone`, `modelscope download`, etc.) and poll it with `task_status`.
|
|
41
43
|
{HOSTBRIDGE_GUIDANCE_END}
|
|
42
44
|
"""
|
|
43
45
|
SUPPORTED_INSTALL_TARGETS = (
|
|
@@ -51,7 +53,7 @@ SUPPORTED_INSTALL_TARGETS = (
|
|
|
51
53
|
"antigravity",
|
|
52
54
|
"kiro",
|
|
53
55
|
)
|
|
54
|
-
SETUP_COMMANDS = {"install", "setup", "check", "list", "remove", "reload", "secret", "step"}
|
|
56
|
+
SETUP_COMMANDS = {"install", "setup", "check", "list", "remove", "reload", "secret", "step", "daemon"}
|
|
55
57
|
AskFunc = Callable[[str], str]
|
|
56
58
|
SecretAskFunc = Callable[[str], str]
|
|
57
59
|
VerifyFunc = Callable[[dict[str, object], int], "VerificationResult"]
|
|
@@ -268,11 +270,13 @@ def _toml_array(values: list[str]) -> str:
|
|
|
268
270
|
return "[" + ", ".join(_toml_quote(value) for value in values) + "]"
|
|
269
271
|
|
|
270
272
|
|
|
271
|
-
def _server_control_block(command: str, args: list[str]) -> str:
|
|
273
|
+
def _server_control_block(command: str, args: list[str], *, agent_id: str) -> str:
|
|
272
274
|
return "\n".join([
|
|
273
275
|
f"[mcp_servers.{PUBLIC_NAME}]",
|
|
274
276
|
f"command = {_toml_quote(command)}",
|
|
275
277
|
f"args = {_toml_array(args)}",
|
|
278
|
+
f"[mcp_servers.{PUBLIC_NAME}.env]",
|
|
279
|
+
f"HOSTBRIDGE_AGENT_ID = {_toml_quote(agent_id)}",
|
|
276
280
|
"",
|
|
277
281
|
])
|
|
278
282
|
|
|
@@ -360,27 +364,28 @@ def _remove_legacy_mcp_server_keys(value: object) -> None:
|
|
|
360
364
|
_remove_legacy_mcp_server_keys(child)
|
|
361
365
|
|
|
362
366
|
|
|
363
|
-
def _install_codex_config(config_path: Path, command: str, args: list[str]) -> None:
|
|
364
|
-
block = _server_control_block(command, args)
|
|
367
|
+
def _install_codex_config(config_path: Path, command: str, args: list[str], *, agent_id: str) -> None:
|
|
368
|
+
block = _server_control_block(command, args, agent_id=agent_id)
|
|
365
369
|
existing = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
|
|
366
370
|
updated = _remove_toml_table(existing, f"[mcp_servers.{LEGACY_PUBLIC_NAME}]")
|
|
367
371
|
updated = _remove_toml_table(updated, f"[mcp_servers.{LEGACY_PACKAGE_NAME}]")
|
|
372
|
+
updated = _remove_toml_table(updated, f"[mcp_servers.{PUBLIC_NAME}.env]")
|
|
368
373
|
updated = _replace_toml_table(updated, f"[mcp_servers.{PUBLIC_NAME}]", block)
|
|
369
374
|
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
370
375
|
config_path.write_text(updated, encoding="utf-8")
|
|
371
376
|
|
|
372
377
|
|
|
373
|
-
def _install_mcp_servers_json(config_path: Path, command: str, args: list[str]) -> None:
|
|
378
|
+
def _install_mcp_servers_json(config_path: Path, command: str, args: list[str], *, agent_id: str) -> None:
|
|
374
379
|
data = _read_json_object(config_path)
|
|
375
380
|
_remove_legacy_mcp_server_keys(data)
|
|
376
381
|
servers = data.setdefault("mcpServers", {})
|
|
377
382
|
if not isinstance(servers, dict):
|
|
378
383
|
raise ValueError(f"{config_path} mcpServers must be an object")
|
|
379
|
-
servers[PUBLIC_NAME] = {"command": command, "args": args}
|
|
384
|
+
servers[PUBLIC_NAME] = {"command": command, "args": args, "env": {"HOSTBRIDGE_AGENT_ID": agent_id}}
|
|
380
385
|
_write_json_object(config_path, data)
|
|
381
386
|
|
|
382
387
|
|
|
383
|
-
def _install_opencode_config(config_path: Path, command: str, args: list[str]) -> None:
|
|
388
|
+
def _install_opencode_config(config_path: Path, command: str, args: list[str], *, agent_id: str) -> None:
|
|
384
389
|
data = _read_json_object(config_path)
|
|
385
390
|
servers = data.setdefault("mcp", {})
|
|
386
391
|
if not isinstance(servers, dict):
|
|
@@ -391,6 +396,7 @@ def _install_opencode_config(config_path: Path, command: str, args: list[str]) -
|
|
|
391
396
|
"type": "local",
|
|
392
397
|
"command": [command, *args],
|
|
393
398
|
"enabled": True,
|
|
399
|
+
"env": {"HOSTBRIDGE_AGENT_ID": agent_id},
|
|
394
400
|
}
|
|
395
401
|
_write_json_object(config_path, data)
|
|
396
402
|
|
|
@@ -420,11 +426,11 @@ def _agent_install_specs(home: Path) -> list[AgentInstallSpec]:
|
|
|
420
426
|
|
|
421
427
|
def _install_spec(spec: AgentInstallSpec, command: str, args: list[str]) -> None:
|
|
422
428
|
if spec.kind == "codex_toml":
|
|
423
|
-
_install_codex_config(spec.path, command, args)
|
|
429
|
+
_install_codex_config(spec.path, command, args, agent_id=spec.target)
|
|
424
430
|
elif spec.kind == "opencode_json":
|
|
425
|
-
_install_opencode_config(spec.path, command, args)
|
|
431
|
+
_install_opencode_config(spec.path, command, args, agent_id=spec.target)
|
|
426
432
|
else:
|
|
427
|
-
_install_mcp_servers_json(spec.path, command, args)
|
|
433
|
+
_install_mcp_servers_json(spec.path, command, args, agent_id=spec.target)
|
|
428
434
|
if spec.guidance_path is not None:
|
|
429
435
|
_install_guidance_file(spec.guidance_path)
|
|
430
436
|
|
|
@@ -458,7 +464,7 @@ def run_install(
|
|
|
458
464
|
|
|
459
465
|
if target == "codex":
|
|
460
466
|
resolved_config = Path(config_path or DEFAULT_CODEX_CONFIG).expanduser()
|
|
461
|
-
_install_codex_config(resolved_config, resolved_command, resolved_args)
|
|
467
|
+
_install_codex_config(resolved_config, resolved_command, resolved_args, agent_id="codex")
|
|
462
468
|
label = "Codex"
|
|
463
469
|
else:
|
|
464
470
|
home = Path(home_path or Path.home()).expanduser()
|
|
@@ -982,6 +988,24 @@ def run_reload() -> int:
|
|
|
982
988
|
return 0
|
|
983
989
|
|
|
984
990
|
|
|
991
|
+
def run_daemon_command(command: str, *, foreground: bool = False, socket_file: Path | str | None = None) -> int:
|
|
992
|
+
from . import daemon
|
|
993
|
+
|
|
994
|
+
if command == "start":
|
|
995
|
+
if foreground:
|
|
996
|
+
return daemon.run_daemon(socket_file=socket_file)
|
|
997
|
+
return daemon.start_background(socket_file=socket_file)
|
|
998
|
+
if command == "stop":
|
|
999
|
+
return daemon.stop_background(socket_file=socket_file)
|
|
1000
|
+
if command == "status":
|
|
1001
|
+
if daemon.is_running(socket_file=socket_file):
|
|
1002
|
+
print(f"hostbridge daemon running at unix://{daemon.socket_path(socket_file)}")
|
|
1003
|
+
return 0
|
|
1004
|
+
print("hostbridge daemon is not running")
|
|
1005
|
+
return 1
|
|
1006
|
+
raise ValueError(f"unknown daemon command: {command}")
|
|
1007
|
+
|
|
1008
|
+
|
|
985
1009
|
|
|
986
1010
|
def run_entry_login_steps(child: object, entry: dict[str, object], timeout: int) -> None:
|
|
987
1011
|
raw_steps = entry.get("login_steps", [])
|
|
@@ -1128,6 +1152,16 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1128
1152
|
subparsers.add_parser("list", parents=[config_parent], help="List configured hosts")
|
|
1129
1153
|
subparsers.add_parser("reload", help="Explain runtime host reload behavior")
|
|
1130
1154
|
|
|
1155
|
+
daemon_parser = subparsers.add_parser("daemon", help="Manage the HostBridge daemon")
|
|
1156
|
+
daemon_subparsers = daemon_parser.add_subparsers(dest="daemon_command", required=True)
|
|
1157
|
+
daemon_start = daemon_subparsers.add_parser("start", help="Start the HostBridge daemon")
|
|
1158
|
+
daemon_start.add_argument("--foreground", action="store_true", help="Run daemon in the foreground")
|
|
1159
|
+
daemon_start.add_argument("--socket", dest="socket_file", default=None, help="Unix socket path; default ~/.hostbridge/daemon.sock")
|
|
1160
|
+
daemon_stop = daemon_subparsers.add_parser("stop", help="Stop the HostBridge daemon")
|
|
1161
|
+
daemon_stop.add_argument("--socket", dest="socket_file", default=None, help="Unix socket path; default ~/.hostbridge/daemon.sock")
|
|
1162
|
+
daemon_status = daemon_subparsers.add_parser("status", help="Show daemon status")
|
|
1163
|
+
daemon_status.add_argument("--socket", dest="socket_file", default=None, help="Unix socket path; default ~/.hostbridge/daemon.sock")
|
|
1164
|
+
|
|
1131
1165
|
remove_parser = subparsers.add_parser("remove", parents=[config_parent], help="Remove a configured host")
|
|
1132
1166
|
remove_parser.add_argument("host_id")
|
|
1133
1167
|
|
|
@@ -1176,6 +1210,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1176
1210
|
return run_remove(args.host_id, config_path=config_path)
|
|
1177
1211
|
if args.command == "reload":
|
|
1178
1212
|
return run_reload()
|
|
1213
|
+
if args.command == "daemon":
|
|
1214
|
+
return run_daemon_command(
|
|
1215
|
+
args.daemon_command,
|
|
1216
|
+
foreground=getattr(args, "foreground", False),
|
|
1217
|
+
socket_file=getattr(args, "socket_file", None),
|
|
1218
|
+
)
|
|
1179
1219
|
if args.command == "secret" and args.secret_command == "set":
|
|
1180
1220
|
value = getpass.getpass(f"Secret value for {args.host_id}/{args.name}: ")
|
|
1181
1221
|
set_host_secret(config_path, args.host_id, args.name, value)
|