@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 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 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>`.
125
- 4. Call `hosts_list` again, then `session_open(<new_id>)`.
126
- 5. Do not inspect `~/.ssh`, old expect scripts, or shell snapshots unless the user explicitly asks or the setup command lacks required information.
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 in the same process as the MCP server. It is on by default and can be tuned or disabled via environment variables or a JSON config file.
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. Use `command_run` for quick commands such as `nvidia-smi`, `pwd`, `df -h`, or `tmux ls`.
331
- 5. Use `task_start` for long training jobs, downloads, builds, or scripts.
332
- 6. Poll `task_status` until `state` is `finished`; inspect `exit_code` and `log_tail`.
333
- 7. Call `task_stop` early if a job should be aborted.
334
- 8. Call `session_close` when finished, or `sessions_close_all` to tear down every session.
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`.
@@ -2,7 +2,10 @@
2
2
  "mcpServers": {
3
3
  "hostbridge": {
4
4
  "command": "npx",
5
- "args": ["-y", "hostbridge"]
5
+ "args": ["-y", "hostbridge"],
6
+ "env": {
7
+ "HOSTBRIDGE_AGENT_ID": "claude-code"
8
+ }
6
9
  }
7
10
  }
8
11
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neoline/hostbridge",
3
- "version": "0.2.1",
3
+ "version": "1.0.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 = "0.2.1"
7
+ version = "1.0.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__ = "0.2.1"
9
+ __version__ = "1.0.0"
10
10
 
11
11
  # (import_name, pip_name) pairs for runtime dependency probing.
12
12
  _REQUIRED_DEPENDENCIES = (
@@ -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() -> None:
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
- - Validate host entries with `hostbridge check <host_id>` before relying on them.
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)