@neoline/hostbridge 0.2.2 → 1.1.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 UTF-8 text file through the active shell, up to 100MiB.
21
+ - `file_read_text(session_id, remote_path)` — read a UTF-8 text file through the active shell, up to 100MiB.
22
+ - `file_upload(session_id, local_path, remote_path)` — upload a local file with chunked shell/base64 transfer, up to 100MiB.
23
+ - `file_download(session_id, remote_path, local_path)` — download a remote file with chunked shell/base64 transfer, up to 100MiB.
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 stream chunked base64 through the already-open shell session, verify hashes, and work for text or binary artifacts up to 100MiB:
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
+ Give file tools local and remote paths; do not paste base64 into tool arguments. Files larger than 100MiB 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`:
@@ -270,6 +311,8 @@ Codex config written by the installer, for example in `~/.codex/config.toml`:
270
311
  [mcp_servers.hostbridge]
271
312
  command = "npx"
272
313
  args = ["-y", "hostbridge"]
314
+ [mcp_servers.hostbridge.env]
315
+ HOSTBRIDGE_AGENT_ID = "codex"
273
316
  ```
274
317
 
275
318
  Claude Desktop style config:
@@ -279,7 +322,8 @@ Claude Desktop style config:
279
322
  "mcpServers": {
280
323
  "hostbridge": {
281
324
  "command": "npx",
282
- "args": ["-y", "hostbridge"]
325
+ "args": ["-y", "hostbridge"],
326
+ "env": {"HOSTBRIDGE_AGENT_ID": "claude-code"}
283
327
  }
284
328
  }
285
329
  }
@@ -296,7 +340,7 @@ See `examples/` for copyable config templates.
296
340
 
297
341
  ## Policy, audit, and idle reaping
298
342
 
299
- 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.
300
344
 
301
345
  Environment variables:
302
346
 
@@ -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.2",
3
+ "version": "1.1.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.2"
7
+ version = "1.1.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"
@@ -86,4 +86,4 @@ python_version = "3.11"
86
86
  ignore_missing_imports = true
87
87
  warn_unused_ignores = true
88
88
  warn_redundant_casts = true
89
- files = ["src/server_control_mcp"]
89
+ files = ["src/server_control_mcp"]
@@ -6,7 +6,7 @@ import sys
6
6
  from typing import NoReturn
7
7
 
8
8
  __all__ = ["__version__", "ensure_runtime"]
9
- __version__ = "0.2.2"
9
+ __version__ = "1.1.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())
@@ -38,6 +38,9 @@ Use HostBridge when the user asks to connect to, inspect, or operate an allowlis
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 UTF-8 files and `file_upload`/`file_download` for binary artifacts up to 100MiB; these tools stream through the already-open shell and work across JumpServer/menu/sudo flows where SFTP may not exist.
42
+ - For files larger than 100MiB, do not use HostBridge file tools; start a remote pull instead (`curl`, `wget`, `git clone`, `modelscope download`, etc.) and poll it with `task_status`.
43
+ - Do not ask the user to provide base64 content. Give file tools local/remote paths; HostBridge handles chunking, encoding, hashing, and cleanup internally.
41
44
  {HOSTBRIDGE_GUIDANCE_END}
42
45
  """
43
46
  SUPPORTED_INSTALL_TARGETS = (
@@ -51,7 +54,7 @@ SUPPORTED_INSTALL_TARGETS = (
51
54
  "antigravity",
52
55
  "kiro",
53
56
  )
54
- SETUP_COMMANDS = {"install", "setup", "check", "list", "remove", "reload", "secret", "step"}
57
+ SETUP_COMMANDS = {"install", "setup", "check", "list", "remove", "reload", "secret", "step", "daemon"}
55
58
  AskFunc = Callable[[str], str]
56
59
  SecretAskFunc = Callable[[str], str]
57
60
  VerifyFunc = Callable[[dict[str, object], int], "VerificationResult"]
@@ -268,11 +271,13 @@ def _toml_array(values: list[str]) -> str:
268
271
  return "[" + ", ".join(_toml_quote(value) for value in values) + "]"
269
272
 
270
273
 
271
- def _server_control_block(command: str, args: list[str]) -> str:
274
+ def _server_control_block(command: str, args: list[str], *, agent_id: str) -> str:
272
275
  return "\n".join([
273
276
  f"[mcp_servers.{PUBLIC_NAME}]",
274
277
  f"command = {_toml_quote(command)}",
275
278
  f"args = {_toml_array(args)}",
279
+ f"[mcp_servers.{PUBLIC_NAME}.env]",
280
+ f"HOSTBRIDGE_AGENT_ID = {_toml_quote(agent_id)}",
276
281
  "",
277
282
  ])
278
283
 
@@ -360,27 +365,28 @@ def _remove_legacy_mcp_server_keys(value: object) -> None:
360
365
  _remove_legacy_mcp_server_keys(child)
361
366
 
362
367
 
363
- def _install_codex_config(config_path: Path, command: str, args: list[str]) -> None:
364
- block = _server_control_block(command, args)
368
+ def _install_codex_config(config_path: Path, command: str, args: list[str], *, agent_id: str) -> None:
369
+ block = _server_control_block(command, args, agent_id=agent_id)
365
370
  existing = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
366
371
  updated = _remove_toml_table(existing, f"[mcp_servers.{LEGACY_PUBLIC_NAME}]")
367
372
  updated = _remove_toml_table(updated, f"[mcp_servers.{LEGACY_PACKAGE_NAME}]")
373
+ updated = _remove_toml_table(updated, f"[mcp_servers.{PUBLIC_NAME}.env]")
368
374
  updated = _replace_toml_table(updated, f"[mcp_servers.{PUBLIC_NAME}]", block)
369
375
  config_path.parent.mkdir(parents=True, exist_ok=True)
370
376
  config_path.write_text(updated, encoding="utf-8")
371
377
 
372
378
 
373
- def _install_mcp_servers_json(config_path: Path, command: str, args: list[str]) -> None:
379
+ def _install_mcp_servers_json(config_path: Path, command: str, args: list[str], *, agent_id: str) -> None:
374
380
  data = _read_json_object(config_path)
375
381
  _remove_legacy_mcp_server_keys(data)
376
382
  servers = data.setdefault("mcpServers", {})
377
383
  if not isinstance(servers, dict):
378
384
  raise ValueError(f"{config_path} mcpServers must be an object")
379
- servers[PUBLIC_NAME] = {"command": command, "args": args}
385
+ servers[PUBLIC_NAME] = {"command": command, "args": args, "env": {"HOSTBRIDGE_AGENT_ID": agent_id}}
380
386
  _write_json_object(config_path, data)
381
387
 
382
388
 
383
- def _install_opencode_config(config_path: Path, command: str, args: list[str]) -> None:
389
+ def _install_opencode_config(config_path: Path, command: str, args: list[str], *, agent_id: str) -> None:
384
390
  data = _read_json_object(config_path)
385
391
  servers = data.setdefault("mcp", {})
386
392
  if not isinstance(servers, dict):
@@ -391,6 +397,7 @@ def _install_opencode_config(config_path: Path, command: str, args: list[str]) -
391
397
  "type": "local",
392
398
  "command": [command, *args],
393
399
  "enabled": True,
400
+ "env": {"HOSTBRIDGE_AGENT_ID": agent_id},
394
401
  }
395
402
  _write_json_object(config_path, data)
396
403
 
@@ -420,11 +427,11 @@ def _agent_install_specs(home: Path) -> list[AgentInstallSpec]:
420
427
 
421
428
  def _install_spec(spec: AgentInstallSpec, command: str, args: list[str]) -> None:
422
429
  if spec.kind == "codex_toml":
423
- _install_codex_config(spec.path, command, args)
430
+ _install_codex_config(spec.path, command, args, agent_id=spec.target)
424
431
  elif spec.kind == "opencode_json":
425
- _install_opencode_config(spec.path, command, args)
432
+ _install_opencode_config(spec.path, command, args, agent_id=spec.target)
426
433
  else:
427
- _install_mcp_servers_json(spec.path, command, args)
434
+ _install_mcp_servers_json(spec.path, command, args, agent_id=spec.target)
428
435
  if spec.guidance_path is not None:
429
436
  _install_guidance_file(spec.guidance_path)
430
437
 
@@ -458,7 +465,7 @@ def run_install(
458
465
 
459
466
  if target == "codex":
460
467
  resolved_config = Path(config_path or DEFAULT_CODEX_CONFIG).expanduser()
461
- _install_codex_config(resolved_config, resolved_command, resolved_args)
468
+ _install_codex_config(resolved_config, resolved_command, resolved_args, agent_id="codex")
462
469
  label = "Codex"
463
470
  else:
464
471
  home = Path(home_path or Path.home()).expanduser()
@@ -982,6 +989,24 @@ def run_reload() -> int:
982
989
  return 0
983
990
 
984
991
 
992
+ def run_daemon_command(command: str, *, foreground: bool = False, socket_file: Path | str | None = None) -> int:
993
+ from . import daemon
994
+
995
+ if command == "start":
996
+ if foreground:
997
+ return daemon.run_daemon(socket_file=socket_file)
998
+ return daemon.start_background(socket_file=socket_file)
999
+ if command == "stop":
1000
+ return daemon.stop_background(socket_file=socket_file)
1001
+ if command == "status":
1002
+ if daemon.is_running(socket_file=socket_file):
1003
+ print(f"hostbridge daemon running at unix://{daemon.socket_path(socket_file)}")
1004
+ return 0
1005
+ print("hostbridge daemon is not running")
1006
+ return 1
1007
+ raise ValueError(f"unknown daemon command: {command}")
1008
+
1009
+
985
1010
 
986
1011
  def run_entry_login_steps(child: object, entry: dict[str, object], timeout: int) -> None:
987
1012
  raw_steps = entry.get("login_steps", [])
@@ -1128,6 +1153,16 @@ def main(argv: list[str] | None = None) -> int:
1128
1153
  subparsers.add_parser("list", parents=[config_parent], help="List configured hosts")
1129
1154
  subparsers.add_parser("reload", help="Explain runtime host reload behavior")
1130
1155
 
1156
+ daemon_parser = subparsers.add_parser("daemon", help="Manage the HostBridge daemon")
1157
+ daemon_subparsers = daemon_parser.add_subparsers(dest="daemon_command", required=True)
1158
+ daemon_start = daemon_subparsers.add_parser("start", help="Start the HostBridge daemon")
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")
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")
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")
1165
+
1131
1166
  remove_parser = subparsers.add_parser("remove", parents=[config_parent], help="Remove a configured host")
1132
1167
  remove_parser.add_argument("host_id")
1133
1168
 
@@ -1176,6 +1211,12 @@ def main(argv: list[str] | None = None) -> int:
1176
1211
  return run_remove(args.host_id, config_path=config_path)
1177
1212
  if args.command == "reload":
1178
1213
  return run_reload()
1214
+ if args.command == "daemon":
1215
+ return run_daemon_command(
1216
+ args.daemon_command,
1217
+ foreground=getattr(args, "foreground", False),
1218
+ socket_file=getattr(args, "socket_file", None),
1219
+ )
1179
1220
  if args.command == "secret" and args.secret_command == "set":
1180
1221
  value = getpass.getpass(f"Secret value for {args.host_id}/{args.name}: ")
1181
1222
  set_host_secret(config_path, args.host_id, args.name, value)