@neoline/hostbridge 1.4.0 → 1.4.1

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
@@ -85,7 +85,43 @@ 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
- 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.
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. The MCP file tools intentionally keep this shell/base64 backend for non-standard login flows. The optional `mock-ssh` bridge below exposes SFTP/SCP protocol compatibility on top of the same backend for clients which require SSH file-transfer channels.
89
+
90
+ ## Mock SSH bridge
91
+
92
+ Some clients, including compute providers, know how to connect to SSH targets but cannot run HostBridge's MCP connector inside their local sandbox. `hostbridge mock-ssh` exposes one configured HostBridge host as a local SSH server while keeping the actual remote access path behind HostBridge. This is useful when the client-side SSH implementation runs outside a restricted sandbox.
93
+
94
+ ```bash
95
+ hostbridge mock-ssh a800_95
96
+ ```
97
+
98
+ By default HostBridge selects a free local port from `2222-2299`, creates host/client keys under `~/.hostbridge/mock-ssh/<host_id>/`, and writes a managed `Host hostbridge-<host_id>` block into `~/.ssh/config`. The command prints the chosen alias, port, config path, and identity file:
99
+
100
+ ```text
101
+ hostbridge mock-ssh forwarding host 'a800_95'
102
+ listening on ssh://127.0.0.1:2222
103
+ ssh host alias: hostbridge-a800_95
104
+ ssh config: /Users/alice/.ssh/config
105
+ identity file: /Users/alice/.hostbridge/mock-ssh/a800_95/client_ed25519_key
106
+ ```
107
+
108
+ Point the compute provider at the printed SSH alias, for example `ssh:hostbridge-a800_95`. To remove the managed SSH config block later:
109
+
110
+ ```bash
111
+ hostbridge mock-ssh a800_95 --remove
112
+ ```
113
+
114
+ Advanced options are still available for fixed deployments: `--listen-host`, `--port`, `--key-dir`, `--ssh-host-alias`, `--ssh-config`, and `--no-ssh-config`.
115
+
116
+ The bridge supports:
117
+
118
+ - SSH exec requests, suitable for compute `call_command` APIs.
119
+ - Interactive shell channels.
120
+ - SFTP upload/download/list/stat/mkdir/remove/rename operations.
121
+ - OpenSSH `scp` in its default SFTP-backed mode.
122
+ - Legacy OpenSSH `scp -O` mode through AsyncSSH's SCP server.
123
+
124
+ Each SSH channel opens its own HostBridge backend session and closes it when the channel ends, so an interactive shell `exit` does not kill later exec or file-transfer requests. The SFTP/SCP implementation is a compatibility layer backed by HostBridge's shell/base64 transfer primitives, so it keeps working across JumpServer/menu/wrapper login flows where direct remote SFTP is unavailable. It inherits the same transfer size limit (100MiB by default); for larger files, start a remote-native pull from the target host.
89
125
 
90
126
  ## Daemon lifecycle
91
127
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neoline/hostbridge",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
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.4.0"
7
+ version = "1.4.1"
8
8
  description = "HostBridge MCP server for persistent allowlisted SSH and shell sessions."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -39,6 +39,7 @@ dependencies = [
39
39
  "mcp>=1.0.0",
40
40
  "pexpect>=4.9.0",
41
41
  "cryptography>=42.0.0",
42
+ "asyncssh>=2.14.0",
42
43
  ]
43
44
 
44
45
  [project.optional-dependencies]
@@ -8,7 +8,7 @@ import subprocess
8
8
  from typing import NoReturn
9
9
 
10
10
  __all__ = ["__version__", "ensure_runtime"]
11
- __version__ = "1.4.0"
11
+ __version__ = "1.4.1"
12
12
 
13
13
  # (import_name, pip_name) pairs for runtime dependency probing.
14
14
  _REQUIRED_DEPENDENCIES = (
@@ -54,7 +54,7 @@ SUPPORTED_INSTALL_TARGETS = (
54
54
  "antigravity",
55
55
  "kiro",
56
56
  )
57
- SETUP_COMMANDS = {"install", "setup", "check", "list", "remove", "reload", "secret", "step", "daemon"}
57
+ SETUP_COMMANDS = {"install", "setup", "check", "list", "remove", "reload", "secret", "step", "daemon", "mock-ssh"}
58
58
  AskFunc = Callable[[str], str]
59
59
  SecretAskFunc = Callable[[str], str]
60
60
  VerifyFunc = Callable[[dict[str, object], int], "VerificationResult"]
@@ -1163,6 +1163,19 @@ def main(argv: list[str] | None = None) -> int:
1163
1163
  daemon_status = daemon_subparsers.add_parser("status", help="Show daemon status")
1164
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")
1165
1165
 
1166
+ mock_ssh_parser = subparsers.add_parser("mock-ssh", parents=[config_parent], help="Expose a HostBridge host as a local SSH server")
1167
+ mock_ssh_parser.add_argument("host_id")
1168
+ mock_ssh_parser.add_argument("--listen-host", default="127.0.0.1")
1169
+ mock_ssh_parser.add_argument("--port", type=int, default=None, help="Local SSH port; default auto-selects a free port from 2222-2299")
1170
+ mock_ssh_parser.add_argument("--key-dir", help="Directory for generated mock SSH host/client keys")
1171
+ mock_ssh_parser.add_argument("--ssh-host-alias", help="Host alias to manage in ~/.ssh/config; default hostbridge-<host_id>")
1172
+ mock_ssh_parser.add_argument("--ssh-config", help="SSH config path; default ~/.ssh/config")
1173
+ mock_ssh_parser.add_argument("--no-ssh-config", action="store_true", help="Do not write a managed Host block to SSH config")
1174
+ mock_ssh_parser.add_argument("--remove", action="store_true", help="Remove the managed SSH config block and exit")
1175
+ mock_ssh_parser.add_argument("--connect-timeout", type=int, default=60)
1176
+ mock_ssh_parser.add_argument("--command-timeout", type=int, default=DEFAULT_VERIFY_TIMEOUT)
1177
+ mock_ssh_parser.add_argument("--output-limit", type=int, default=120000)
1178
+
1166
1179
  remove_parser = subparsers.add_parser("remove", parents=[config_parent], help="Remove a configured host")
1167
1180
  remove_parser.add_argument("host_id")
1168
1181
 
@@ -1217,6 +1230,30 @@ def main(argv: list[str] | None = None) -> int:
1217
1230
  foreground=getattr(args, "foreground", False),
1218
1231
  socket_file=getattr(args, "socket_file", None),
1219
1232
  )
1233
+ if args.command == "mock-ssh":
1234
+ from .mock_ssh import run_mock_ssh, uninstall_mock_ssh
1235
+
1236
+ ssh_config_path = Path(args.ssh_config).expanduser() if args.ssh_config else None
1237
+ if args.remove:
1238
+ return uninstall_mock_ssh(
1239
+ args.host_id,
1240
+ ssh_host_alias=args.ssh_host_alias,
1241
+ ssh_config_path=ssh_config_path,
1242
+ )
1243
+
1244
+ return run_mock_ssh(
1245
+ host_id=args.host_id,
1246
+ config_path=config_path,
1247
+ listen_host=args.listen_host,
1248
+ port=args.port,
1249
+ key_dir=Path(args.key_dir).expanduser() if args.key_dir else None,
1250
+ ssh_host_alias=args.ssh_host_alias,
1251
+ ssh_config_path=ssh_config_path,
1252
+ install_ssh_config=not args.no_ssh_config,
1253
+ connect_timeout=args.connect_timeout,
1254
+ command_timeout=args.command_timeout,
1255
+ output_limit=args.output_limit,
1256
+ )
1220
1257
  if args.command == "secret" and args.secret_command == "set":
1221
1258
  value = getpass.getpass(f"Secret value for {args.host_id}/{args.name}: ")
1222
1259
  set_host_secret(config_path, args.host_id, args.name, value)
@@ -267,8 +267,8 @@ def _max_sessions_per_host_from_env() -> int:
267
267
  return 0
268
268
 
269
269
 
270
- def build_manager() -> SessionManager:
271
- registry = HostRegistry(load_hosts())
270
+ def build_manager(config_path: Path | str | None = None) -> SessionManager:
271
+ registry = HostRegistry(load_hosts(config_path))
272
272
  policy = load_policy()
273
273
  manager = SessionManager(
274
274
  registry,
@@ -117,10 +117,15 @@ def _tail(value: str, limit: int = MAX_OUTPUT_CHARS) -> str:
117
117
 
118
118
 
119
119
  def _extract_between_markers(raw: str, start_marker: str) -> str:
120
+ raw = raw.replace("\x1b[?2004h", "").replace("\x1b[?2004l", "")
120
121
  if start_marker in raw:
121
122
  raw = raw.split(start_marker, 1)[1]
122
123
  lines = raw.replace("\r\n", "\n").replace("\r", "\n").split("\n")
123
- cleaned = [line for line in lines if not line.startswith("printf '") and "__SCM_EXIT_" not in line]
124
+ cleaned = [
125
+ line
126
+ for line in lines
127
+ if not line.startswith("printf '") and "__SCM_START_" not in line and "__SCM_EXIT_" not in line
128
+ ]
124
129
  return "\n".join(cleaned).strip("\n")
125
130
 
126
131
 
@@ -536,6 +541,44 @@ class SessionManager:
536
541
  result["content"] = data.decode("utf-8")
537
542
  return result
538
543
 
544
+ def file_write_bytes(
545
+ self,
546
+ session_id: str,
547
+ remote_path: str,
548
+ data: bytes,
549
+ *,
550
+ mode: str = "0644",
551
+ max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
552
+ token_factory: TokenFactory = _new_token,
553
+ owner_agent_id: str | None = None,
554
+ ) -> dict[str, object]:
555
+ return self._upload_bytes(
556
+ session_id,
557
+ data,
558
+ remote_path,
559
+ mode=mode,
560
+ max_bytes=max_bytes,
561
+ token_factory=token_factory,
562
+ owner_agent_id=owner_agent_id,
563
+ )
564
+
565
+ def file_read_bytes(
566
+ self,
567
+ session_id: str,
568
+ remote_path: str,
569
+ *,
570
+ max_bytes: int = DEFAULT_MAX_TRANSFER_BYTES,
571
+ token_factory: TokenFactory = _new_token,
572
+ owner_agent_id: str | None = None,
573
+ ) -> dict[str, object]:
574
+ return self._download_bytes(
575
+ session_id,
576
+ remote_path,
577
+ max_bytes=max_bytes,
578
+ token_factory=token_factory,
579
+ owner_agent_id=owner_agent_id,
580
+ )
581
+
539
582
  def file_upload(
540
583
  self,
541
584
  session_id: str,
@@ -635,7 +678,7 @@ class SessionManager:
635
678
  finalize_script = (
636
679
  "set -e; "
637
680
  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"; '
681
+ '{ base64 -d < "$tmp_b64" 2>/dev/null || base64 -D < "$tmp_b64"; } > "$tmp_out"; '
639
682
  'chmod "$mode" "$tmp_out"; mv "$tmp_out" "$target"; rm -f "$tmp_b64"; '
640
683
  f"printf 'BYTES=%s\\nSHA256=%s\\n' {len(data)} {shlex.quote(sha256)}"
641
684
  )
@@ -787,13 +830,16 @@ class SessionManager:
787
830
  )
788
831
 
789
832
  def write(self, session_id: str, text: str, *, owner_agent_id: str | None = None) -> dict[str, object]:
833
+ return self.send(session_id, f"{text}\n", owner_agent_id=owner_agent_id)
834
+
835
+ def send(self, session_id: str, text: str, *, owner_agent_id: str | None = None) -> dict[str, object]:
790
836
  session = self._get_session(session_id)
791
837
  self._check_owner(session, owner_agent_id)
792
838
  if session.busy:
793
839
  raise RemoteCommandError(f"session {session_id} is busy")
794
840
  if not session.child.isalive():
795
841
  raise RemoteCommandError(f"session {session_id} is not alive")
796
- session.child.sendline(text)
842
+ session.child.send(text)
797
843
  session.last_used_at = time.time()
798
844
  return {"session_id": session_id, "written": True}
799
845
 
@@ -851,10 +897,11 @@ class SessionManager:
851
897
  raise RemoteCommandError(f"host {host_id} reached session limit {self._max_sessions_per_host}")
852
898
 
853
899
  def _build_remote_line(self, command: str, token: str, start_marker: str) -> str:
854
- quoted_command = shlex.quote(command)
900
+ encoded_command = base64.b64encode(command.encode("utf-8")).decode("ascii")
855
901
  return (
902
+ f"__scm_cmd=$(printf %s {shlex.quote(encoded_command)} | {{ base64 --decode 2>/dev/null || base64 -D; }}); "
856
903
  f"printf '\\n{start_marker}\\n'; "
857
- f"{self.remote_shell} -lc {quoted_command}; "
904
+ f"{self.remote_shell} -lc \"$__scm_cmd\"; "
858
905
  "__scm_status=$?; "
859
906
  f"printf '\\n__SCM_EXIT_{token}:%s\\n' \"$__scm_status\""
860
907
  )
@@ -0,0 +1,647 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import posixpath
6
+ import shlex
7
+ import sys
8
+ import time
9
+ from contextlib import suppress
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+
13
+ import asyncssh
14
+
15
+ from .daemon import build_manager
16
+ from .manager import DEFAULT_COMMAND_TIMEOUT, RemoteCommandError, SessionManager, _new_token
17
+
18
+ DEFAULT_PORT_RANGE = range(2222, 2300)
19
+
20
+
21
+ class HostBridgeSSHServer(asyncssh.SSHServer):
22
+ def __init__(self, authorized_public_key: str) -> None:
23
+ self._authorized_public_key = authorized_public_key.strip()
24
+
25
+ def begin_auth(self, username: str) -> bool: # noqa: ARG002
26
+ return True
27
+
28
+ def public_key_auth_supported(self) -> bool:
29
+ return True
30
+
31
+ def password_auth_supported(self) -> bool:
32
+ return False
33
+
34
+ def validate_public_key(self, username: str, key: asyncssh.SSHKey) -> bool: # noqa: ARG002
35
+ return key.export_public_key().decode("utf-8").strip() == self._authorized_public_key
36
+
37
+
38
+ class HostBridgeSSHProcess:
39
+ def __init__(self, manager: SessionManager, session_factory: "HostBridgeSessionFactory", timeout: int, output_limit: int) -> None:
40
+ self._manager = manager
41
+ self._session_factory = session_factory
42
+ self._timeout = timeout
43
+ self._output_limit = output_limit
44
+
45
+ async def __call__(self, process: asyncssh.SSHServerProcess[str]) -> None:
46
+ session_id = self._session_factory.open()
47
+ try:
48
+ await self._run(process, session_id)
49
+ finally:
50
+ self._session_factory.close(session_id)
51
+
52
+ async def _run(self, process: asyncssh.SSHServerProcess[str], session_id: str) -> None:
53
+ command = process.command
54
+ if not command:
55
+ await self._run_shell(process, session_id)
56
+ return
57
+
58
+ loop = asyncio.get_running_loop()
59
+ try:
60
+ result = await loop.run_in_executor(
61
+ None,
62
+ lambda: self._manager.run_command(
63
+ session_id,
64
+ command,
65
+ timeout=self._timeout,
66
+ token_factory=_new_token,
67
+ output_limit=self._output_limit,
68
+ ),
69
+ )
70
+ if result.output:
71
+ process.stdout.write(result.output)
72
+ if not result.output.endswith("\n"):
73
+ process.stdout.write("\n")
74
+ process.exit(124 if result.timed_out else int(result.exit_code or 0))
75
+ except Exception as exc:
76
+ print(f"hostbridge mock-ssh exec failed: {exc}", file=sys.stderr, flush=True)
77
+ process.stderr.write(f"hostbridge mock-ssh error: {exc}\n")
78
+ process.exit(1)
79
+
80
+ async def _run_shell(self, process: asyncssh.SSHServerProcess[str], session_id: str) -> None:
81
+ loop = asyncio.get_running_loop()
82
+ stop = asyncio.Event()
83
+ input_done = asyncio.Event()
84
+
85
+ async def pump_input() -> None:
86
+ try:
87
+ while not process.stdin.at_eof():
88
+ data = await process.stdin.read(4096)
89
+ if not data:
90
+ break
91
+ await loop.run_in_executor(
92
+ None,
93
+ lambda chunk=data: self._manager.send(session_id, chunk, owner_agent_id="mock-ssh"),
94
+ )
95
+ finally:
96
+ input_done.set()
97
+
98
+ async def pump_output() -> None:
99
+ try:
100
+ idle_after_input = 0
101
+ while not stop.is_set():
102
+ result = await loop.run_in_executor(
103
+ None,
104
+ lambda: self._manager.read(
105
+ session_id,
106
+ timeout=0.2,
107
+ max_chars=4096,
108
+ owner_agent_id="mock-ssh",
109
+ ),
110
+ )
111
+ output = str(result.get("output") or "")
112
+ if output:
113
+ idle_after_input = 0
114
+ process.stdout.write(output)
115
+ elif input_done.is_set():
116
+ idle_after_input += 1
117
+ if idle_after_input >= 5:
118
+ stop.set()
119
+ break
120
+ if not result.get("alive", True):
121
+ stop.set()
122
+ break
123
+ except Exception as exc:
124
+ print(f"hostbridge mock-ssh shell failed: {exc}", file=sys.stderr, flush=True)
125
+ process.stderr.write(f"hostbridge mock-ssh shell error: {exc}\n")
126
+ stop.set()
127
+
128
+ tasks = [asyncio.create_task(pump_input()), asyncio.create_task(pump_output())]
129
+ await stop.wait()
130
+ for task in tasks:
131
+ task.cancel()
132
+ await asyncio.gather(*tasks, return_exceptions=True)
133
+ process.exit(0)
134
+
135
+
136
+ @dataclass
137
+ class HostBridgeSFTPHandle:
138
+ path: str
139
+ readable: bool = False
140
+ writable: bool = False
141
+ data: bytes = b""
142
+ writes: dict[int, bytes] = field(default_factory=dict)
143
+ mode: int = 0o644
144
+
145
+
146
+ class HostBridgeSessionFactory:
147
+ def __init__(self, manager: SessionManager, host_id: str, connect_timeout: int) -> None:
148
+ self._manager = manager
149
+ self._host_id = host_id
150
+ self._connect_timeout = connect_timeout
151
+
152
+ def open(self) -> str:
153
+ session = self._manager.open_session(
154
+ self._host_id,
155
+ connect_timeout=self._connect_timeout,
156
+ owner_agent_id="mock-ssh",
157
+ )
158
+ return session.id
159
+
160
+ def close(self, session_id: str) -> None:
161
+ with _suppress_error():
162
+ self._manager.close_session(session_id, owner_agent_id="mock-ssh", force=True)
163
+
164
+
165
+ class HostBridgeSFTPServer(asyncssh.SFTPServer):
166
+ def __init__(
167
+ self,
168
+ chan: asyncssh.SSHServerChannel,
169
+ manager: SessionManager,
170
+ session_factory: HostBridgeSessionFactory,
171
+ timeout: int,
172
+ max_bytes: int,
173
+ ) -> None:
174
+ super().__init__(chan)
175
+ self._manager = manager
176
+ self._session_factory = session_factory
177
+ self._session_id = session_factory.open()
178
+ self._timeout = timeout
179
+ self._max_bytes = max_bytes
180
+
181
+ def exit(self) -> None:
182
+ self._session_factory.close(self._session_id)
183
+
184
+ def open(self, path: bytes, pflags: int, attrs: asyncssh.SFTPAttrs) -> HostBridgeSFTPHandle:
185
+ remote_path = self._path(path)
186
+ readable = bool(pflags & asyncssh.FXF_READ)
187
+ writable = bool(pflags & asyncssh.FXF_WRITE)
188
+ mode = attrs.permissions if attrs.permissions is not None else 0o644
189
+ data = b""
190
+ if readable or (writable and not (pflags & asyncssh.FXF_TRUNC) and not (pflags & asyncssh.FXF_CREAT)):
191
+ try:
192
+ data = self._download(remote_path)
193
+ except asyncssh.SFTPNoSuchFile:
194
+ if not writable:
195
+ raise
196
+ data = b""
197
+ return HostBridgeSFTPHandle(remote_path, readable=readable, writable=writable, data=data, mode=mode)
198
+
199
+ def read(self, file_obj: object, offset: int, size: int) -> bytes:
200
+ handle = self._handle(file_obj)
201
+ if not handle.readable:
202
+ raise asyncssh.SFTPPermissionDenied("file is not open for reading")
203
+ return handle.data[offset : offset + size]
204
+
205
+ def write(self, file_obj: object, offset: int, data: bytes) -> int:
206
+ handle = self._handle(file_obj)
207
+ if not handle.writable:
208
+ raise asyncssh.SFTPPermissionDenied("file is not open for writing")
209
+ handle.writes[int(offset)] = bytes(data)
210
+ return len(data)
211
+
212
+ def close(self, file_obj: object) -> None:
213
+ handle = self._handle(file_obj)
214
+ if handle.writable:
215
+ self._upload(handle)
216
+
217
+ def stat(self, path: bytes) -> asyncssh.SFTPAttrs:
218
+ return self._stat(self._path(path), follow=True)
219
+
220
+ def lstat(self, path: bytes) -> asyncssh.SFTPAttrs:
221
+ return self._stat(self._path(path), follow=False)
222
+
223
+ def fstat(self, file_obj: object) -> asyncssh.SFTPAttrs:
224
+ handle = self._handle(file_obj)
225
+ if handle.writable and handle.writes:
226
+ size = len(handle.data)
227
+ for offset, chunk in handle.writes.items():
228
+ size = max(size, offset + len(chunk))
229
+ return asyncssh.SFTPAttrs(size=size, permissions=handle.mode, type=asyncssh.FILEXFER_TYPE_REGULAR)
230
+ return self._stat(handle.path, follow=True)
231
+
232
+ def fsetstat(self, file_obj: object, attrs: asyncssh.SFTPAttrs) -> None:
233
+ handle = self._handle(file_obj)
234
+ if attrs.permissions is not None:
235
+ handle.mode = attrs.permissions
236
+ if attrs.atime is not None or attrs.mtime is not None:
237
+ # Applied after upload in close(); the handle may not exist remotely yet.
238
+ return
239
+
240
+ async def scandir(self, path: bytes):
241
+ for item in self._listdir(self._path(path)):
242
+ yield item
243
+
244
+ def mkdir(self, path: bytes, attrs: asyncssh.SFTPAttrs) -> None:
245
+ mode = attrs.permissions if attrs.permissions is not None else 0o755
246
+ self._checked_run(f"mkdir -p -m {shlex.quote(format(mode & 0o7777, 'o'))} -- {shlex.quote(self._path(path))}")
247
+
248
+ def rmdir(self, path: bytes) -> None:
249
+ self._checked_run(f"rmdir -- {shlex.quote(self._path(path))}")
250
+
251
+ def remove(self, path: bytes) -> None:
252
+ self._checked_run(f"rm -f -- {shlex.quote(self._path(path))}")
253
+
254
+ def rename(self, oldpath: bytes, newpath: bytes) -> None:
255
+ self._checked_run(f"mv -- {shlex.quote(self._path(oldpath))} {shlex.quote(self._path(newpath))}")
256
+
257
+ def realpath(self, path: bytes) -> bytes:
258
+ command = (
259
+ "python3 -c "
260
+ + shlex.quote("import os,sys; print(os.path.realpath(sys.argv[1]))")
261
+ + " "
262
+ + shlex.quote(self._path(path))
263
+ )
264
+ result = self._checked_run(command)
265
+ return (result.output.strip() or self._path(path)).encode("utf-8")
266
+
267
+ def setstat(self, path: bytes, attrs: asyncssh.SFTPAttrs) -> None:
268
+ remote_path = self._path(path)
269
+ if attrs.permissions is not None:
270
+ self._checked_run(f"chmod {shlex.quote(format(attrs.permissions & 0o7777, 'o'))} -- {shlex.quote(remote_path)}")
271
+ if attrs.atime is not None or attrs.mtime is not None:
272
+ script = "import os,sys; os.utime(sys.argv[1], (int(sys.argv[2]), int(sys.argv[3])))"
273
+ current = self._stat(remote_path, follow=True)
274
+ atime = int(attrs.atime if attrs.atime is not None else current.atime or time.time())
275
+ mtime = int(attrs.mtime if attrs.mtime is not None else current.mtime or time.time())
276
+ self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)} {atime} {mtime}")
277
+
278
+ def _download(self, remote_path: str) -> bytes:
279
+ try:
280
+ result = self._manager.file_read_bytes(
281
+ self._session_id,
282
+ remote_path,
283
+ max_bytes=self._max_bytes,
284
+ token_factory=_new_token,
285
+ owner_agent_id="mock-ssh",
286
+ )
287
+ except RemoteCommandError as exc:
288
+ raise self._sftp_error(exc) from exc
289
+ return bytes(result["data_bytes"])
290
+
291
+ def _upload(self, handle: HostBridgeSFTPHandle) -> None:
292
+ size = len(handle.data)
293
+ for offset, chunk in handle.writes.items():
294
+ size = max(size, offset + len(chunk))
295
+ data = bytearray(handle.data)
296
+ if len(data) < size:
297
+ data.extend(b"\x00" * (size - len(data)))
298
+ for offset, chunk in sorted(handle.writes.items()):
299
+ data[offset : offset + len(chunk)] = chunk
300
+ try:
301
+ self._manager.file_write_bytes(
302
+ self._session_id,
303
+ handle.path,
304
+ bytes(data),
305
+ mode=format(handle.mode & 0o7777, "04o"),
306
+ max_bytes=self._max_bytes,
307
+ token_factory=_new_token,
308
+ owner_agent_id="mock-ssh",
309
+ )
310
+ except RemoteCommandError as exc:
311
+ print(f"hostbridge mock-ssh sftp upload failed: {exc}", file=sys.stderr, flush=True)
312
+ raise self._sftp_error(exc) from exc
313
+
314
+ def _stat(self, remote_path: str, *, follow: bool) -> asyncssh.SFTPAttrs:
315
+ script = r"""
316
+ import json, os, stat, sys
317
+ path = sys.argv[1]
318
+ st = os.stat(path) if sys.argv[2] == "1" else os.lstat(path)
319
+ print(json.dumps({
320
+ "size": st.st_size,
321
+ "permissions": st.st_mode,
322
+ "uid": st.st_uid,
323
+ "gid": st.st_gid,
324
+ "atime": int(st.st_atime),
325
+ "mtime": int(st.st_mtime),
326
+ "type": "dir" if stat.S_ISDIR(st.st_mode) else "link" if stat.S_ISLNK(st.st_mode) else "file",
327
+ }))
328
+ """
329
+ result = self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)} {'1' if follow else '0'}")
330
+ return self._attrs(json.loads(result.output))
331
+
332
+ def _listdir(self, remote_path: str) -> list[asyncssh.SFTPName]:
333
+ script = r"""
334
+ import json, os, stat, sys, time
335
+ path = sys.argv[1]
336
+ items = []
337
+ for name in os.listdir(path):
338
+ full = os.path.join(path, name)
339
+ st = os.lstat(full)
340
+ item_type = "dir" if stat.S_ISDIR(st.st_mode) else "link" if stat.S_ISLNK(st.st_mode) else "file"
341
+ items.append({
342
+ "filename": name,
343
+ "longname": name,
344
+ "attrs": {
345
+ "size": st.st_size,
346
+ "permissions": st.st_mode,
347
+ "uid": st.st_uid,
348
+ "gid": st.st_gid,
349
+ "atime": int(st.st_atime),
350
+ "mtime": int(st.st_mtime),
351
+ "type": item_type,
352
+ },
353
+ })
354
+ print(json.dumps(items))
355
+ """
356
+ result = self._checked_run(f"python3 -c {shlex.quote(script)} {shlex.quote(remote_path)}")
357
+ return [
358
+ asyncssh.SFTPName(item["filename"], item["longname"], self._attrs(item["attrs"]))
359
+ for item in json.loads(result.output or "[]")
360
+ ]
361
+
362
+ def _attrs(self, data: dict[str, object]) -> asyncssh.SFTPAttrs:
363
+ permissions = int(data.get("permissions") or 0)
364
+ raw_type = data.get("type")
365
+ file_type = {
366
+ "file": asyncssh.FILEXFER_TYPE_REGULAR,
367
+ "dir": asyncssh.FILEXFER_TYPE_DIRECTORY,
368
+ "link": asyncssh.FILEXFER_TYPE_SYMLINK,
369
+ }.get(str(raw_type), asyncssh.FILEXFER_TYPE_UNKNOWN)
370
+ return asyncssh.SFTPAttrs(
371
+ type=file_type,
372
+ size=int(data.get("size") or 0),
373
+ uid=int(data.get("uid") or 0),
374
+ gid=int(data.get("gid") or 0),
375
+ permissions=permissions,
376
+ atime=int(data.get("atime") or time.time()),
377
+ mtime=int(data.get("mtime") or time.time()),
378
+ )
379
+
380
+ def _checked_run(self, command: str):
381
+ try:
382
+ result = self._manager.run_command(
383
+ self._session_id,
384
+ command,
385
+ timeout=self._timeout,
386
+ token_factory=_new_token,
387
+ owner_agent_id="mock-ssh",
388
+ )
389
+ except RemoteCommandError as exc:
390
+ raise self._sftp_error(exc) from exc
391
+ if result.timed_out:
392
+ print(f"hostbridge mock-ssh sftp command timed out: {command}", file=sys.stderr, flush=True)
393
+ raise asyncssh.SFTPFailure("remote command timed out")
394
+ if result.exit_code not in (0, None):
395
+ error = self._sftp_error(RemoteCommandError(result.output or f"remote command exited {result.exit_code}"))
396
+ if isinstance(error, asyncssh.SFTPNoSuchFile):
397
+ raise error
398
+ print(
399
+ f"hostbridge mock-ssh sftp command failed exit={result.exit_code}: {command}\n{result.output}",
400
+ file=sys.stderr,
401
+ flush=True,
402
+ )
403
+ raise error
404
+ return result
405
+
406
+ def _path(self, path: bytes) -> str:
407
+ text = path.decode("utf-8", "surrogateescape")
408
+ if not text:
409
+ return "."
410
+ return posixpath.normpath(text)
411
+
412
+ def _handle(self, file_obj: object) -> HostBridgeSFTPHandle:
413
+ if not isinstance(file_obj, HostBridgeSFTPHandle):
414
+ raise asyncssh.SFTPInvalidHandle("invalid HostBridge SFTP handle")
415
+ return file_obj
416
+
417
+ def _sftp_error(self, exc: RemoteCommandError) -> Exception:
418
+ text = str(exc)
419
+ lowered = text.lower()
420
+ if "no such file" in lowered or "not found" in lowered or "filenotfounderror" in lowered:
421
+ return asyncssh.SFTPNoSuchFile(text)
422
+ if "permission denied" in lowered:
423
+ return asyncssh.SFTPPermissionDenied(text)
424
+ return asyncssh.SFTPFailure(text)
425
+
426
+
427
+ def ensure_keypair(key_dir: Path) -> tuple[Path, Path, str]:
428
+ key_dir.mkdir(parents=True, exist_ok=True)
429
+ with _suppress_chmod_error(key_dir):
430
+ key_dir.chmod(0o700)
431
+ host_key_path = key_dir / "ssh_host_ed25519_key"
432
+ client_key_path = key_dir / "client_ed25519_key"
433
+ if not host_key_path.exists():
434
+ host_key = asyncssh.generate_private_key("ssh-ed25519")
435
+ host_key_path.write_bytes(host_key.export_private_key())
436
+ if not client_key_path.exists():
437
+ client_key = asyncssh.generate_private_key("ssh-ed25519")
438
+ client_key_path.write_bytes(client_key.export_private_key())
439
+ (key_dir / "client_ed25519_key.pub").write_bytes(client_key.export_public_key())
440
+ with _suppress_chmod_error(host_key_path):
441
+ host_key_path.chmod(0o600)
442
+ with _suppress_chmod_error(client_key_path):
443
+ client_key_path.chmod(0o600)
444
+ public_key = (key_dir / "client_ed25519_key.pub").read_text(encoding="utf-8").strip()
445
+ return host_key_path, client_key_path, public_key
446
+
447
+
448
+ def default_key_dir(host_id: str) -> Path:
449
+ safe_host_id = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in host_id)
450
+ return Path("~/.hostbridge/mock-ssh").expanduser() / safe_host_id
451
+
452
+
453
+ def default_ssh_host_alias(host_id: str) -> str:
454
+ safe_host_id = "".join(ch if ch.isalnum() or ch in ".-" else "-" for ch in host_id).strip(".-")
455
+ return f"hostbridge-{safe_host_id or 'host'}"
456
+
457
+
458
+ async def create_server_on_available_port(
459
+ *,
460
+ server_factory,
461
+ listen_host: str,
462
+ port: int | None,
463
+ server_host_keys: list[str],
464
+ process_factory,
465
+ sftp_factory,
466
+ ):
467
+ candidates = [port] if port is not None else list(DEFAULT_PORT_RANGE)
468
+ last_error: OSError | None = None
469
+ for candidate in candidates:
470
+ if candidate is None:
471
+ continue
472
+ try:
473
+ server = await asyncssh.create_server(
474
+ server_factory,
475
+ listen_host,
476
+ int(candidate),
477
+ server_host_keys=server_host_keys,
478
+ process_factory=process_factory,
479
+ sftp_factory=sftp_factory,
480
+ allow_scp=True,
481
+ )
482
+ return server, int(candidate)
483
+ except OSError as exc:
484
+ last_error = exc
485
+ continue
486
+ if port is not None:
487
+ raise RuntimeError(f"port {port} is not available on {listen_host}") from last_error
488
+ raise RuntimeError(f"no free port found in {DEFAULT_PORT_RANGE.start}-{DEFAULT_PORT_RANGE.stop - 1}") from last_error
489
+
490
+
491
+ def install_ssh_config_block(
492
+ *,
493
+ host_alias: str,
494
+ listen_host: str,
495
+ port: int,
496
+ client_key_path: Path,
497
+ config_path: Path | None = None,
498
+ ) -> Path:
499
+ config_path = config_path or Path("~/.ssh/config").expanduser()
500
+ config_path.parent.mkdir(parents=True, exist_ok=True)
501
+ existing = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
502
+ begin = f"# >>> hostbridge mock-ssh {host_alias} >>>"
503
+ end = f"# <<< hostbridge mock-ssh {host_alias} <<<"
504
+ block = "\n".join(
505
+ [
506
+ begin,
507
+ f"Host {host_alias}",
508
+ f" HostName {listen_host}",
509
+ f" Port {port}",
510
+ " User hostbridge",
511
+ f" IdentityFile {client_key_path}",
512
+ " StrictHostKeyChecking no",
513
+ " UserKnownHostsFile /dev/null",
514
+ " LogLevel ERROR",
515
+ end,
516
+ "",
517
+ ]
518
+ )
519
+ _write_text_atomic(config_path, _replace_managed_block(existing, begin, end, block))
520
+ with _suppress_chmod_error(config_path):
521
+ config_path.chmod(0o600)
522
+ return config_path
523
+
524
+
525
+ def uninstall_ssh_config_block(host_alias: str, config_path: Path | None = None) -> bool:
526
+ config_path = config_path or Path("~/.ssh/config").expanduser()
527
+ if not config_path.exists():
528
+ return False
529
+ begin = f"# >>> hostbridge mock-ssh {host_alias} >>>"
530
+ end = f"# <<< hostbridge mock-ssh {host_alias} <<<"
531
+ existing = config_path.read_text(encoding="utf-8")
532
+ updated = _remove_managed_block(existing, begin, end)
533
+ if updated == existing:
534
+ return False
535
+ _write_text_atomic(config_path, updated)
536
+ return True
537
+
538
+
539
+ def _replace_managed_block(existing: str, begin: str, end: str, block: str) -> str:
540
+ without = _remove_managed_block(existing, begin, end).rstrip()
541
+ return f"{without}\n\n{block}" if without else block
542
+
543
+
544
+ def _remove_managed_block(existing: str, begin: str, end: str) -> str:
545
+ start = existing.find(begin)
546
+ if start == -1:
547
+ return existing
548
+ stop = existing.find(end, start)
549
+ if stop == -1:
550
+ return existing
551
+ stop += len(end)
552
+ while stop < len(existing) and existing[stop] in "\r\n":
553
+ stop += 1
554
+ return existing[:start].rstrip() + ("\n" if existing[:start].strip() and existing[stop:].strip() else "") + existing[stop:].lstrip()
555
+
556
+
557
+ def _write_text_atomic(path: Path, text: str) -> None:
558
+ tmp_path = path.with_name(f".{path.name}.hostbridge.tmp")
559
+ tmp_path.write_text(text, encoding="utf-8")
560
+ tmp_path.replace(path)
561
+
562
+
563
+ class _suppress_chmod_error:
564
+ def __init__(self, path: Path) -> None:
565
+ self.path = path
566
+
567
+ def __enter__(self) -> None:
568
+ return None
569
+
570
+ def __exit__(self, exc_type: object, exc: object, tb: object) -> bool:
571
+ return exc_type is PermissionError
572
+
573
+
574
+ class _suppress_error:
575
+ def __enter__(self) -> None:
576
+ return None
577
+
578
+ def __exit__(self, exc_type: object, exc: object, tb: object) -> bool:
579
+ return isinstance(exc, Exception)
580
+
581
+
582
+ async def serve_mock_ssh(
583
+ host_id: str,
584
+ *,
585
+ config_path: Path | str | None = None,
586
+ listen_host: str = "127.0.0.1",
587
+ port: int | None = None,
588
+ key_dir: Path | None = None,
589
+ ssh_host_alias: str | None = None,
590
+ ssh_config_path: Path | None = None,
591
+ install_ssh_config: bool = True,
592
+ connect_timeout: int = 60,
593
+ command_timeout: int = DEFAULT_COMMAND_TIMEOUT,
594
+ output_limit: int = 120000,
595
+ ) -> int:
596
+ key_dir = key_dir or default_key_dir(host_id)
597
+ ssh_host_alias = ssh_host_alias or default_ssh_host_alias(host_id)
598
+ host_key_path, client_key_path, public_key = ensure_keypair(key_dir)
599
+ manager = build_manager(config_path)
600
+ session_factory = HostBridgeSessionFactory(manager, host_id, connect_timeout)
601
+ try:
602
+ server, selected_port = await create_server_on_available_port(
603
+ server_factory=lambda: HostBridgeSSHServer(public_key),
604
+ listen_host=listen_host,
605
+ port=port,
606
+ server_host_keys=[str(host_key_path)],
607
+ process_factory=HostBridgeSSHProcess(manager, session_factory, command_timeout, output_limit),
608
+ sftp_factory=lambda chan: HostBridgeSFTPServer(chan, manager, session_factory, command_timeout, output_limit),
609
+ )
610
+ installed_config_path: Path | None = None
611
+ if install_ssh_config:
612
+ installed_config_path = install_ssh_config_block(
613
+ host_alias=ssh_host_alias,
614
+ listen_host=listen_host,
615
+ port=selected_port,
616
+ client_key_path=client_key_path,
617
+ config_path=ssh_config_path,
618
+ )
619
+ print(f"hostbridge mock-ssh forwarding host '{host_id}'", flush=True)
620
+ print(f"listening on ssh://{listen_host}:{selected_port}", flush=True)
621
+ print(f"ssh host alias: {ssh_host_alias}", flush=True)
622
+ if installed_config_path is not None:
623
+ print(f"ssh config: {installed_config_path}", flush=True)
624
+ print(f"identity file: {client_key_path}", flush=True)
625
+ print("supports SSH exec, shell, SFTP, and SCP; press Ctrl-C to stop", flush=True)
626
+ await server.wait_closed()
627
+ finally:
628
+ manager.close_all_sessions()
629
+ manager.stop_idle_reaper()
630
+ return 0
631
+
632
+
633
+ def run_mock_ssh(**kwargs: object) -> int:
634
+ try:
635
+ return asyncio.run(serve_mock_ssh(**kwargs))
636
+ except KeyboardInterrupt:
637
+ return 130
638
+ except RemoteCommandError as exc:
639
+ print(f"hostbridge mock-ssh failed: {exc}")
640
+ return 1
641
+
642
+
643
+ def uninstall_mock_ssh(host_id: str, *, ssh_host_alias: str | None = None, ssh_config_path: Path | None = None) -> int:
644
+ host_alias = ssh_host_alias or default_ssh_host_alias(host_id)
645
+ removed = uninstall_ssh_config_block(host_alias, ssh_config_path)
646
+ print(f"removed SSH config for {host_alias}" if removed else f"no managed SSH config found for {host_alias}")
647
+ return 0
@@ -50,6 +50,13 @@ def _daemon_request(method: str, path: str, payload: dict[str, object] | None =
50
50
  try:
51
51
  return daemon.request_json(method, path, payload, timeout=timeout)
52
52
  except OSError as exc:
53
+ if os.environ.get("HOSTBRIDGE_NPM_LAUNCHER"):
54
+ socket_path = daemon.socket_path()
55
+ raise RuntimeError(
56
+ "hostbridge daemon is not reachable at "
57
+ f"unix://{socket_path}. Start it outside the MCP sandbox, for example: "
58
+ f"HOSTBRIDGE_DAEMON_SOCKET={socket_path} npx -y @neoline/hostbridge daemon start --foreground"
59
+ ) from exc
53
60
  # MCP stdout must stay protocol-clean, so auto-start the daemon quietly.
54
61
  if daemon.start_background(quiet=True) != 0:
55
62
  raise RuntimeError(f"hostbridge daemon failed to start; see {daemon.log_file()}") from exc