@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 +19 -7
- package/bin/hostbridge.js +10 -0
- package/package.json +1 -1
- package/pyproject.toml +2 -2
- package/src/server_control_mcp/__init__.py +1 -1
- package/src/server_control_mcp/cli.py +6 -5
- package/src/server_control_mcp/daemon.py +30 -13
- package/src/server_control_mcp/manager.py +105 -25
- package/src/server_control_mcp/server.py +14 -9
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
|
|
21
|
-
- `file_read_text(session_id, remote_path)` — read a
|
|
22
|
-
- `file_upload(session_id, local_path, remote_path)` — upload a
|
|
23
|
-
- `file_download(session_id, remote_path, local_path)` — download a
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
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.
|
|
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"]
|
|
@@ -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
|
|
42
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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")
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
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
|
|
430
|
+
if pid_file().exists():
|
|
414
431
|
with suppress(Exception):
|
|
415
|
-
os.kill(int(
|
|
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
|
-
|
|
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
|
-
|
|
611
|
-
|
|
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};
|
|
619
|
+
f"target={remote_q}; "
|
|
614
620
|
'dir=$(dirname "$target"); mkdir -p "$dir"; '
|
|
615
|
-
|
|
616
|
-
|
|
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,
|
|
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": "
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
736
|
+
for line in result.output.splitlines():
|
|
676
737
|
key, _, value = line.partition("=")
|
|
677
738
|
if key:
|
|
678
739
|
fields[key] = value
|
|
679
|
-
|
|
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": "
|
|
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.
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
244
|
+
"""Upload a local file through the active shell session; supports up to 100MiB via chunked base64."""
|
|
240
245
|
try:
|
|
241
|
-
safe_max =
|
|
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
|
|
263
|
+
"""Download a remote file through the active shell session; supports up to 100MiB via chunked base64."""
|
|
259
264
|
try:
|
|
260
|
-
safe_max =
|
|
265
|
+
safe_max = _safe_transfer_limit(max_bytes)
|
|
261
266
|
return _daemon_request(
|
|
262
267
|
"POST",
|
|
263
268
|
f"/sessions/{_qid(session_id)}/files",
|