@neoline/hostbridge 1.0.0 → 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,10 +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
+ - `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.
24
24
  - `session_close(session_id)` — close a persistent session.
25
25
 
26
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`).
@@ -78,14 +78,14 @@ npm run test:node
78
78
 
79
79
  ## File transfer model
80
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:
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
82
 
83
83
  ```bash
84
84
  file_upload(session_id, "/absolute/local/script.py", "/remote/work/script.py")
85
85
  file_download(session_id, "/remote/work/result.log", "/absolute/local/result.log")
86
86
  ```
87
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.
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
89
 
90
90
  ## Daemon lifecycle
91
91
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neoline/hostbridge",
3
- "version": "1.0.0",
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 = "1.0.0"
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__ = "1.0.0"
9
+ __version__ = "1.1.0"
10
10
 
11
11
  # (import_name, pip_name) pairs for runtime dependency probing.
12
12
  _REQUIRED_DEPENDENCIES = (
@@ -38,8 +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 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
+ - 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.
43
44
  {HOSTBRIDGE_GUIDANCE_END}
44
45
  """
45
46
  SUPPORTED_INSTALL_TARGETS = (
@@ -19,6 +19,7 @@ from .hosts import HostRegistry, load_hosts
19
19
  from .manager import (
20
20
  DEFAULT_COMMAND_TIMEOUT,
21
21
  DEFAULT_CONNECT_TIMEOUT,
22
+ DEFAULT_MAX_TRANSFER_BYTES,
22
23
  PolicyDeniedError,
23
24
  RemoteCommandError,
24
25
  SessionManager,
@@ -173,7 +174,7 @@ class HostBridgeDaemonHandler(StreamRequestHandler):
173
174
  str(body.get("remote_path", "")),
174
175
  str(body.get("content", "")),
175
176
  mode=str(body.get("mode", "0644")),
176
- max_bytes=int(body.get("max_bytes", 1024 * 1024)),
177
+ max_bytes=int(body.get("max_bytes", DEFAULT_MAX_TRANSFER_BYTES)),
177
178
  owner_agent_id=owner,
178
179
  token_factory=token_factory,
179
180
  )
@@ -183,7 +184,7 @@ class HostBridgeDaemonHandler(StreamRequestHandler):
183
184
  transfer=self.manager.file_read_text(
184
185
  parts[1],
185
186
  str(body.get("remote_path", "")),
186
- max_bytes=int(body.get("max_bytes", 1024 * 1024)),
187
+ max_bytes=int(body.get("max_bytes", DEFAULT_MAX_TRANSFER_BYTES)),
187
188
  owner_agent_id=owner,
188
189
  token_factory=token_factory,
189
190
  )
@@ -195,7 +196,7 @@ class HostBridgeDaemonHandler(StreamRequestHandler):
195
196
  str(body.get("local_path", "")),
196
197
  str(body.get("remote_path", "")),
197
198
  mode=str(body.get("mode", "0644")),
198
- max_bytes=int(body.get("max_bytes", 1024 * 1024)),
199
+ max_bytes=int(body.get("max_bytes", DEFAULT_MAX_TRANSFER_BYTES)),
199
200
  owner_agent_id=owner,
200
201
  token_factory=token_factory,
201
202
  )
@@ -206,7 +207,7 @@ class HostBridgeDaemonHandler(StreamRequestHandler):
206
207
  parts[1],
207
208
  str(body.get("remote_path", "")),
208
209
  str(body.get("local_path", "")),
209
- max_bytes=int(body.get("max_bytes", 1024 * 1024)),
210
+ max_bytes=int(body.get("max_bytes", DEFAULT_MAX_TRANSFER_BYTES)),
210
211
  owner_agent_id=owner,
211
212
  token_factory=token_factory,
212
213
  )
@@ -24,7 +24,10 @@ DEFAULT_REAPER_INTERVAL_SECONDS = 60
24
24
  MAX_OUTPUT_CHARS = 120_000
25
25
  REMOTE_JOB_ROOT = "$HOME/.hostbridge/jobs"
26
26
  REMOTE_SHELL = "bash"
27
- DEFAULT_MAX_TRANSFER_BYTES = 1024 * 1024
27
+ MAX_SHELL_TRANSFER_BYTES = 100 * 1024 * 1024
28
+ DEFAULT_MAX_TRANSFER_BYTES = MAX_SHELL_TRANSFER_BYTES
29
+ UPLOAD_BASE64_CHUNK_CHARS = 256 * 1024
30
+ DOWNLOAD_CHUNK_BYTES = 5 * 1024 * 1024
28
31
 
29
32
  class PolicyDeniedError(RuntimeError):
30
33
  """Raised when a command is blocked by the configured policy."""
@@ -607,33 +610,96 @@ class SessionManager:
607
610
  token = token_factory()
608
611
  remote_q = shlex.quote(remote_path)
609
612
  mode_q = shlex.quote(mode)
610
- heredoc = f"__HOSTBRIDGE_B64_{token}__"
611
- script = (
613
+ tmp_prefix = f"/tmp/hostbridge.upload.{token}"
614
+ tmp_b64 = f"{tmp_prefix}.b64"
615
+ tmp_out = f"{tmp_prefix}.out"
616
+ cleanup_script = f"rm -f {shlex.quote(tmp_b64)} {shlex.quote(tmp_out)}"
617
+ init_script = (
612
618
  "set -e; "
613
- f"target={remote_q}; mode={mode_q}; "
619
+ f"target={remote_q}; "
614
620
  'dir=$(dirname "$target"); mkdir -p "$dir"; '
615
- 'tmp_b64=$(mktemp "${TMPDIR:-/tmp}/hostbridge.XXXXXX.b64"); '
616
- 'tmp_out=$(mktemp "${TMPDIR:-/tmp}/hostbridge.XXXXXX.out"); '
617
- f"cat > \"$tmp_b64\" <<'{heredoc}'\n{b64}\n{heredoc}\n"
618
- 'base64 -d "$tmp_b64" > "$tmp_out"; '
619
- 'chmod "$mode" "$tmp_out"; mv "$tmp_out" "$target"; rm -f "$tmp_b64"; '
620
- f"printf 'BYTES=%s\\nSHA256=%s\\n' {len(data)} {shlex.quote(sha256)}"
621
+ f"rm -f {shlex.quote(tmp_b64)} {shlex.quote(tmp_out)}; "
622
+ f": > {shlex.quote(tmp_b64)}"
621
623
  )
622
- result = self.run_command(session_id, script, timeout=60, token_factory=lambda: token, owner_agent_id=owner_agent_id)
624
+ result = self.run_command(session_id, init_script, timeout=60, token_factory=lambda: token, owner_agent_id=owner_agent_id)
623
625
  if result.exit_code != 0:
624
- raise RemoteCommandError(result.output or f"failed to upload {remote_path}")
626
+ raise RemoteCommandError(result.output or f"failed to prepare upload for {remote_path}")
627
+ try:
628
+ self._stream_base64_to_remote_file(
629
+ session_id,
630
+ b64,
631
+ tmp_b64,
632
+ token=token,
633
+ owner_agent_id=owner_agent_id,
634
+ )
635
+ finalize_script = (
636
+ "set -e; "
637
+ f"target={remote_q}; mode={mode_q}; tmp_b64={shlex.quote(tmp_b64)}; tmp_out={shlex.quote(tmp_out)}; "
638
+ 'base64 -d "$tmp_b64" > "$tmp_out"; '
639
+ 'chmod "$mode" "$tmp_out"; mv "$tmp_out" "$target"; rm -f "$tmp_b64"; '
640
+ f"printf 'BYTES=%s\\nSHA256=%s\\n' {len(data)} {shlex.quote(sha256)}"
641
+ )
642
+ result = self.run_command(
643
+ session_id,
644
+ finalize_script,
645
+ timeout=60,
646
+ token_factory=lambda: token,
647
+ owner_agent_id=owner_agent_id,
648
+ )
649
+ if result.exit_code != 0:
650
+ raise RemoteCommandError(result.output or f"failed to finalize upload for {remote_path}")
651
+ except Exception:
652
+ with suppress(Exception):
653
+ self.run_command(session_id, cleanup_script, timeout=10, token_factory=lambda: token, owner_agent_id=owner_agent_id)
654
+ raise
625
655
  payload: dict[str, object] = {
626
656
  "session_id": session_id,
627
657
  "remote_path": remote_path,
628
658
  "bytes": len(data),
629
659
  "sha256": sha256,
630
- "strategy": "shell_base64",
660
+ "strategy": "shell_base64_chunked",
631
661
  }
632
662
  if local_path is not None:
633
663
  payload["local_path"] = local_path
634
664
  self._emit("file_uploaded", session_id=session_id, command=remote_path, outcome=f"bytes={len(data)}")
635
665
  return payload
636
666
 
667
+ def _stream_base64_to_remote_file(
668
+ self,
669
+ session_id: str,
670
+ encoded: str,
671
+ remote_tmp_b64: str,
672
+ *,
673
+ token: str,
674
+ owner_agent_id: str | None,
675
+ ) -> None:
676
+ session = self._get_session(session_id)
677
+ self._check_owner(session, owner_agent_id)
678
+ if session.busy:
679
+ raise RemoteCommandError(f"session {session_id} is busy")
680
+ if not session.child.isalive():
681
+ raise RemoteCommandError(f"session {session_id} is not alive")
682
+
683
+ heredoc = f"__HOSTBRIDGE_UPLOAD_{token}__"
684
+ done_pattern = re.compile(rf"__HOSTBRIDGE_UPLOAD_DONE_{re.escape(token)}__:(\d+)")
685
+ chunk_size = max(4, int(UPLOAD_BASE64_CHUNK_CHARS))
686
+ session.busy = True
687
+ try:
688
+ session.child.send(f"cat >> {shlex.quote(remote_tmp_b64)} <<'{heredoc}'\n")
689
+ for offset in range(0, len(encoded), chunk_size):
690
+ session.child.send(encoded[offset : offset + chunk_size])
691
+ session.child.send(f"\n{heredoc}\nprintf '\\n__HOSTBRIDGE_UPLOAD_DONE_{token}__:%s\\n' \"$?\"\n")
692
+ session.child.expect(done_pattern, timeout=60)
693
+ marker = session.child.after
694
+ text = marker.group(0) if hasattr(marker, "group") else str(marker)
695
+ match = re.search(r":(\d+)", text)
696
+ if match is None or int(match.group(1)) != 0:
697
+ raise RemoteCommandError(f"failed to stream upload chunk for {session_id}")
698
+ finally:
699
+ session.busy = False
700
+ session.last_used_at = time.time()
701
+ session.last_output = _tail(str(session.child.before))
702
+
637
703
  def _download_bytes(
638
704
  self,
639
705
  session_id: str,
@@ -646,39 +712,53 @@ class SessionManager:
646
712
  self._validate_remote_path(remote_path)
647
713
  token = token_factory()
648
714
  remote_q = shlex.quote(remote_path)
649
- script = (
715
+ metadata_script = (
650
716
  "set -e; "
651
717
  f"target={remote_q}; "
652
718
  'bytes=$(wc -c < "$target" | tr -d " "); '
653
719
  f"if [ \"$bytes\" -gt {int(max_bytes)} ]; then echo 'FILE_TOO_LARGE remote pull required' >&2; exit 73; fi; "
654
720
  "if command -v sha256sum >/dev/null 2>&1; then sha=$(sha256sum \"$target\" | awk '{print $1}'); "
655
721
  "else sha=$(shasum -a 256 \"$target\" | awk '{print $1}'); fi; "
656
- "printf 'BYTES=%s\\nSHA256=%s\\n---BASE64---\\n' \"$bytes\" \"$sha\"; base64 \"$target\""
722
+ "printf 'BYTES=%s\\nSHA256=%s\\n' \"$bytes\" \"$sha\""
657
723
  )
658
- output_limit = max(MAX_OUTPUT_CHARS, int(max_bytes) * 2 + 4096)
659
724
  result = self.run_command(
660
725
  session_id,
661
- script,
726
+ metadata_script,
662
727
  timeout=60,
663
728
  token_factory=lambda: token,
664
729
  owner_agent_id=owner_agent_id,
665
- output_limit=output_limit,
666
730
  )
667
731
  if result.exit_code == 73:
668
732
  self._raise_remote_pull_required(max_bytes)
669
733
  if result.exit_code != 0:
670
734
  raise RemoteCommandError(result.output or f"failed to download {remote_path}")
671
- metadata, sep, encoded = result.output.partition("---BASE64---\n")
672
- if not sep:
673
- raise RemoteCommandError("download response missing base64 marker")
674
735
  fields: dict[str, str] = {}
675
- for line in metadata.splitlines():
736
+ for line in result.output.splitlines():
676
737
  key, _, value = line.partition("=")
677
738
  if key:
678
739
  fields[key] = value
679
- data = base64.b64decode("".join(encoded.split()))
680
- byte_count = int(fields.get("BYTES") or len(data))
740
+ byte_count = int(fields.get("BYTES") or 0)
681
741
  self._check_transfer_size(byte_count, max_bytes)
742
+ chunks: list[bytes] = []
743
+ chunk_size = max(1, int(DOWNLOAD_CHUNK_BYTES))
744
+ for index in range((byte_count + chunk_size - 1) // chunk_size):
745
+ chunk_script = (
746
+ "set -e; "
747
+ f"dd if={remote_q} bs={chunk_size} skip={index} count=1 2>/dev/null | base64"
748
+ )
749
+ output_limit = max(MAX_OUTPUT_CHARS, chunk_size * 2 + 4096)
750
+ result = self.run_command(
751
+ session_id,
752
+ chunk_script,
753
+ timeout=60,
754
+ token_factory=lambda: token,
755
+ owner_agent_id=owner_agent_id,
756
+ output_limit=output_limit,
757
+ )
758
+ if result.exit_code != 0:
759
+ raise RemoteCommandError(result.output or f"failed to download chunk {index} from {remote_path}")
760
+ chunks.append(base64.b64decode("".join(result.output.split())))
761
+ data = b"".join(chunks)
682
762
  sha256 = hashlib.sha256(data).hexdigest()
683
763
  remote_sha = fields.get("SHA256") or sha256
684
764
  if remote_sha != "ignored" and sha256 != remote_sha:
@@ -689,7 +769,7 @@ class SessionManager:
689
769
  "remote_path": remote_path,
690
770
  "bytes": len(data),
691
771
  "sha256": remote_sha,
692
- "strategy": "shell_base64",
772
+ "strategy": "shell_base64_chunked",
693
773
  "data_bytes": data,
694
774
  }
695
775
 
@@ -12,6 +12,7 @@ from .manager import (
12
12
  DEFAULT_COMMAND_TIMEOUT,
13
13
  DEFAULT_CONNECT_TIMEOUT,
14
14
  DEFAULT_MAX_TRANSFER_BYTES,
15
+ MAX_SHELL_TRANSFER_BYTES,
15
16
  PolicyDeniedError,
16
17
  RemoteCommandError,
17
18
  )
@@ -66,6 +67,10 @@ def _qid(value: str) -> str:
66
67
  return quote(value, safe="")
67
68
 
68
69
 
70
+ def _safe_transfer_limit(max_bytes: int) -> int:
71
+ return max(1, min(int(max_bytes), MAX_SHELL_TRANSFER_BYTES))
72
+
73
+
69
74
  @mcp.tool()
70
75
  def hosts_list() -> dict[str, object]:
71
76
  """List approved connection targets and local metadata when available."""
@@ -202,9 +207,9 @@ def file_write_text(
202
207
  mode: str = "0644",
203
208
  max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
204
209
  ) -> dict[str, object]:
205
- """Write a small UTF-8 text file through the active shell session."""
210
+ """Write a UTF-8 text file through the active shell session; supports up to 100MiB via chunked base64."""
206
211
  try:
207
- safe_max = max(1, min(int(max_bytes), DEFAULT_MAX_TRANSFER_BYTES))
212
+ safe_max = _safe_transfer_limit(max_bytes)
208
213
  return _daemon_request(
209
214
  "POST",
210
215
  f"/sessions/{_qid(session_id)}/files",
@@ -216,9 +221,9 @@ def file_write_text(
216
221
 
217
222
  @mcp.tool()
218
223
  def file_read_text(session_id: str, remote_path: str, max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES) -> dict[str, object]:
219
- """Read a small UTF-8 text file through the active shell session."""
224
+ """Read a UTF-8 text file through the active shell session; supports up to 100MiB via chunked base64."""
220
225
  try:
221
- safe_max = max(1, min(int(max_bytes), DEFAULT_MAX_TRANSFER_BYTES))
226
+ safe_max = _safe_transfer_limit(max_bytes)
222
227
  return _daemon_request(
223
228
  "POST",
224
229
  f"/sessions/{_qid(session_id)}/files",
@@ -236,9 +241,9 @@ def file_upload(
236
241
  mode: str = "0644",
237
242
  max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
238
243
  ) -> dict[str, object]:
239
- """Upload a small local file via shell/base64."""
244
+ """Upload a local file through the active shell session; supports up to 100MiB via chunked base64."""
240
245
  try:
241
- safe_max = max(1, min(int(max_bytes), DEFAULT_MAX_TRANSFER_BYTES))
246
+ safe_max = _safe_transfer_limit(max_bytes)
242
247
  return _daemon_request(
243
248
  "POST",
244
249
  f"/sessions/{_qid(session_id)}/files",
@@ -255,9 +260,9 @@ def file_download(
255
260
  local_path: str,
256
261
  max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
257
262
  ) -> dict[str, object]:
258
- """Download a small remote file via shell/base64."""
263
+ """Download a remote file through the active shell session; supports up to 100MiB via chunked base64."""
259
264
  try:
260
- safe_max = max(1, min(int(max_bytes), DEFAULT_MAX_TRANSFER_BYTES))
265
+ safe_max = _safe_transfer_limit(max_bytes)
261
266
  return _daemon_request(
262
267
  "POST",
263
268
  f"/sessions/{_qid(session_id)}/files",