@neoline/hostbridge 1.0.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 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
 
@@ -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
- 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:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neoline/hostbridge",
3
- "version": "1.0.0",
3
+ "version": "1.3.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.3.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.3.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 = (
@@ -1156,11 +1157,11 @@ def main(argv: list[str] | None = None) -> int:
1156
1157
  daemon_subparsers = daemon_parser.add_subparsers(dest="daemon_command", required=True)
1157
1158
  daemon_start = daemon_subparsers.add_parser("start", help="Start the HostBridge daemon")
1158
1159
  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_start.add_argument("--socket", dest="socket_file", default=None, help="Unix socket path; default comes from HOSTBRIDGE_DAEMON_SOCKET or HOSTBRIDGE_DAEMON_DIR")
1160
1161
  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_stop.add_argument("--socket", dest="socket_file", default=None, help="Unix socket path; default comes from HOSTBRIDGE_DAEMON_SOCKET or HOSTBRIDGE_DAEMON_DIR")
1162
1163
  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
+ daemon_status.add_argument("--socket", dest="socket_file", default=None, help="Unix socket path; default comes from HOSTBRIDGE_DAEMON_SOCKET or HOSTBRIDGE_DAEMON_DIR")
1164
1165
 
1165
1166
  remove_parser = subparsers.add_parser("remove", parents=[config_parent], help="Remove a configured host")
1166
1167
  remove_parser.add_argument("host_id")
@@ -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,
@@ -26,7 +27,7 @@ from .manager import (
26
27
  )
27
28
  from .policy import build_audit_sink, load_policy
28
29
 
29
- DEFAULT_DAEMON_DIR = Path("~/.hostbridge").expanduser()
30
+ DEFAULT_DAEMON_DIR = Path("~/.hostbridge")
30
31
  DEFAULT_SOCKET_FILE = DEFAULT_DAEMON_DIR / "daemon.sock"
31
32
  PID_FILE = DEFAULT_DAEMON_DIR / "daemon.pid"
32
33
  LOG_FILE = DEFAULT_DAEMON_DIR / "daemon.log"
@@ -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
  )
@@ -279,11 +280,27 @@ def build_manager() -> SessionManager:
279
280
  return manager
280
281
 
281
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
+
282
288
  def socket_path(path: Path | str | None = None) -> Path:
283
- configured = path or os.environ.get("HOSTBRIDGE_DAEMON_SOCKET") or DEFAULT_SOCKET_FILE
289
+ configured = path or os.environ.get("HOSTBRIDGE_DAEMON_SOCKET")
290
+ if configured:
291
+ return Path(configured).expanduser()
292
+ configured = daemon_dir() / "daemon.sock"
284
293
  return Path(configured).expanduser()
285
294
 
286
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
+
287
304
  def _prepare_socket_path(path: Path) -> None:
288
305
  path.parent.mkdir(parents=True, exist_ok=True)
289
306
  with suppress(OSError):
@@ -309,7 +326,7 @@ def run_daemon(*, socket_file: Path | str | None = None) -> int:
309
326
  path = socket_path(socket_file)
310
327
  _prepare_socket_path(path)
311
328
  server = ThreadingUnixRPCServer(str(path), make_handler(manager))
312
- PID_FILE.write_text(str(os.getpid()), encoding="utf-8")
329
+ pid_file().write_text(str(os.getpid()), encoding="utf-8")
313
330
  try:
314
331
  print(f"hostbridge daemon listening on unix://{path}", flush=True)
315
332
  server.serve_forever()
@@ -320,7 +337,7 @@ def run_daemon(*, socket_file: Path | str | None = None) -> int:
320
337
  with suppress(FileNotFoundError):
321
338
  path.unlink()
322
339
  with suppress(FileNotFoundError):
323
- PID_FILE.unlink()
340
+ pid_file().unlink()
324
341
  return 0
325
342
 
326
343
 
@@ -382,7 +399,7 @@ def start_background(*, socket_file: Path | str | None = None, quiet: bool = Fal
382
399
  path.parent.mkdir(parents=True, exist_ok=True)
383
400
  with suppress(OSError):
384
401
  path.parent.chmod(0o700)
385
- log_handle = LOG_FILE.open("a", encoding="utf-8")
402
+ log_handle = log_file().open("a", encoding="utf-8")
386
403
  process = subprocess.Popen(
387
404
  [sys.executable, "-m", "server_control_mcp", "daemon", "start", "--foreground", "--socket", str(path)],
388
405
  stdin=subprocess.DEVNULL,
@@ -391,7 +408,7 @@ def start_background(*, socket_file: Path | str | None = None, quiet: bool = Fal
391
408
  start_new_session=True,
392
409
  env=os.environ.copy(),
393
410
  )
394
- PID_FILE.write_text(str(process.pid), encoding="utf-8")
411
+ pid_file().write_text(str(process.pid), encoding="utf-8")
395
412
  deadline = time.time() + 5
396
413
  while time.time() < deadline:
397
414
  if is_running(socket_file=path):
@@ -400,7 +417,7 @@ def start_background(*, socket_file: Path | str | None = None, quiet: bool = Fal
400
417
  return 0
401
418
  time.sleep(0.1)
402
419
  if not quiet:
403
- print(f"hostbridge daemon failed to start; see {LOG_FILE}", file=sys.stderr)
420
+ print(f"hostbridge daemon failed to start; see {log_file()}", file=sys.stderr)
404
421
  return 1
405
422
 
406
423
 
@@ -410,9 +427,9 @@ def stop_background(*, socket_file: Path | str | None = None) -> int:
410
427
  request_json("POST", "/shutdown", timeout=2.0, socket_file=path)
411
428
  print("hostbridge daemon stopped")
412
429
  return 0
413
- if PID_FILE.exists():
430
+ if pid_file().exists():
414
431
  with suppress(Exception):
415
- os.kill(int(PID_FILE.read_text(encoding="utf-8").strip()), signal.SIGTERM)
432
+ os.kill(int(pid_file().read_text(encoding="utf-8").strip()), signal.SIGTERM)
416
433
  print("hostbridge daemon process signaled")
417
434
  return 0
418
435
  print("hostbridge daemon is not running")
@@ -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
  )
@@ -51,7 +52,7 @@ def _daemon_request(method: str, path: str, payload: dict[str, object] | None =
51
52
  except OSError as exc:
52
53
  # MCP stdout must stay protocol-clean, so auto-start the daemon quietly.
53
54
  if daemon.start_background(quiet=True) != 0:
54
- raise RuntimeError(f"hostbridge daemon failed to start; see {daemon.LOG_FILE}") from exc
55
+ raise RuntimeError(f"hostbridge daemon failed to start; see {daemon.log_file()}") from exc
55
56
  return daemon.request_json(method, path, payload, timeout=timeout)
56
57
 
57
58
 
@@ -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",