@neoline/hostbridge 2.0.3 → 2.0.4
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 +15 -8
- package/bin/hostbridge.js +80 -9
- package/examples/claude_desktop_config.json +1 -1
- package/examples/codex.config.toml +1 -1
- package/examples/hosts.example.json +1 -0
- package/package.json +1 -1
- package/pyproject.toml +5 -4
- package/src/server_control_mcp/__init__.py +58 -23
- package/src/server_control_mcp/cli.py +1 -1
- package/src/server_control_mcp/client.py +99 -7
- package/src/server_control_mcp/config.py +16 -5
- package/src/server_control_mcp/daemon.py +239 -49
- package/src/server_control_mcp/doctor.py +8 -4
- package/src/server_control_mcp/hosts.py +118 -24
- package/src/server_control_mcp/mock_ssh.py +53 -954
- package/src/server_control_mcp/mock_ssh_exec.py +210 -0
- package/src/server_control_mcp/mock_ssh_session.py +68 -0
- package/src/server_control_mcp/mock_ssh_sftp.py +506 -0
- package/src/server_control_mcp/mock_ssh_tunnel.py +592 -0
- package/src/server_control_mcp/policy.py +86 -14
- package/src/server_control_mcp/protocol.py +18 -17
- package/src/server_control_mcp/remote_task_agent.py +102 -0
- package/src/server_control_mcp/remote_tunnel_agent.py +143 -0
- package/src/server_control_mcp/runtime.py +3 -2
- package/src/server_control_mcp/secrets.py +6 -6
- package/src/server_control_mcp/secure_files.py +121 -0
- package/src/server_control_mcp/server.py +30 -12
- package/src/server_control_mcp/services.py +363 -165
- package/src/server_control_mcp/transports/pty.py +114 -33
- package/src/server_control_mcp/transports/ssh.py +308 -24
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
import json
|
|
4
3
|
import os
|
|
5
4
|
import re
|
|
6
5
|
from collections.abc import Iterable, Sequence
|
|
7
6
|
from dataclasses import dataclass, field
|
|
8
7
|
from pathlib import Path
|
|
9
8
|
|
|
9
|
+
from .config import load_v1_config
|
|
10
10
|
from .secrets import decrypt_secret
|
|
11
11
|
|
|
12
12
|
DEFAULT_HOSTS_FILE = Path("~/.hostbridge/hosts.json").expanduser()
|
|
@@ -14,6 +14,17 @@ LEGACY_HOSTS_FILE = Path("~/.server_control_mcp/hosts.json").expanduser()
|
|
|
14
14
|
HOST_ID_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+$")
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class HostLimits:
|
|
19
|
+
max_sessions: int = 16
|
|
20
|
+
command_timeout_seconds: int = 24 * 60 * 60
|
|
21
|
+
max_output_bytes: int = 1_000_000
|
|
22
|
+
max_stdin_bytes: int = 100 * 1024 * 1024
|
|
23
|
+
max_transfer_bytes: int = 100 * 1024 * 1024
|
|
24
|
+
max_text_bytes: int = 4 * 1024 * 1024
|
|
25
|
+
max_shell_bytes: int = 1024 * 1024
|
|
26
|
+
|
|
27
|
+
|
|
17
28
|
@dataclass(frozen=True, slots=True)
|
|
18
29
|
class LoginStep:
|
|
19
30
|
expect: str
|
|
@@ -21,6 +32,16 @@ class LoginStep:
|
|
|
21
32
|
send_secret: str | None = None
|
|
22
33
|
|
|
23
34
|
|
|
35
|
+
@dataclass(frozen=True, slots=True)
|
|
36
|
+
class SshHostConfig:
|
|
37
|
+
host: str
|
|
38
|
+
username: str | None = None
|
|
39
|
+
port: int = 22
|
|
40
|
+
identity_file: Path | None = None
|
|
41
|
+
proxy_jump: str | None = None
|
|
42
|
+
extra_args: tuple[str, ...] = ()
|
|
43
|
+
|
|
44
|
+
|
|
24
45
|
@dataclass(frozen=True, slots=True)
|
|
25
46
|
class HostConfig:
|
|
26
47
|
"""A single approved connection target."""
|
|
@@ -35,11 +56,23 @@ class HostConfig:
|
|
|
35
56
|
login_steps: list[LoginStep] = field(default_factory=list)
|
|
36
57
|
ready_expect: str | None = None
|
|
37
58
|
transport: str = "auto"
|
|
59
|
+
limits: HostLimits = field(default_factory=HostLimits)
|
|
60
|
+
ssh: SshHostConfig | None = None
|
|
38
61
|
|
|
39
62
|
@classmethod
|
|
40
|
-
def script(
|
|
63
|
+
def script(
|
|
64
|
+
cls,
|
|
65
|
+
host_id: str,
|
|
66
|
+
label: str,
|
|
67
|
+
script: Path | str,
|
|
68
|
+
*,
|
|
69
|
+
transport: str = "auto",
|
|
70
|
+
limits: HostLimits | None = None,
|
|
71
|
+
) -> HostConfig:
|
|
41
72
|
path = Path(script).expanduser()
|
|
42
|
-
return cls(
|
|
73
|
+
return cls(
|
|
74
|
+
host_id, label, "/bin/sh", [str(path)], "script", path, transport=transport, limits=limits or HostLimits()
|
|
75
|
+
)
|
|
43
76
|
|
|
44
77
|
@classmethod
|
|
45
78
|
def command_line(
|
|
@@ -54,6 +87,8 @@ class HostConfig:
|
|
|
54
87
|
login_steps: list[LoginStep] | None = None,
|
|
55
88
|
ready_expect: str | None = None,
|
|
56
89
|
transport: str = "auto",
|
|
90
|
+
limits: HostLimits | None = None,
|
|
91
|
+
ssh: SshHostConfig | None = None,
|
|
57
92
|
) -> HostConfig:
|
|
58
93
|
return cls(
|
|
59
94
|
host_id,
|
|
@@ -66,6 +101,8 @@ class HostConfig:
|
|
|
66
101
|
login_steps or [],
|
|
67
102
|
ready_expect,
|
|
68
103
|
transport,
|
|
104
|
+
limits or HostLimits(),
|
|
105
|
+
ssh,
|
|
69
106
|
)
|
|
70
107
|
|
|
71
108
|
def describe(self) -> dict[str, object]:
|
|
@@ -96,25 +133,29 @@ DEFAULT_HOSTS: list[HostConfig] = []
|
|
|
96
133
|
def load_hosts(config_path: Path | str | None = None) -> list[HostConfig]:
|
|
97
134
|
"""Load approved hosts from JSON configuration."""
|
|
98
135
|
|
|
99
|
-
|
|
100
|
-
path = Path(configured).expanduser() if configured else _default_hosts_path()
|
|
136
|
+
path = configured_hosts_path(config_path)
|
|
101
137
|
if not path.exists():
|
|
102
138
|
return []
|
|
103
139
|
|
|
104
|
-
|
|
105
|
-
raw_hosts = data.get("hosts") if isinstance(data, dict) else None
|
|
106
|
-
if not isinstance(raw_hosts, list):
|
|
107
|
-
raise ValueError(f"{path} must contain a top-level 'hosts' list")
|
|
140
|
+
raw_hosts = load_v1_config(path).hosts
|
|
108
141
|
|
|
109
|
-
|
|
110
|
-
|
|
142
|
+
hosts: list[HostConfig] = []
|
|
143
|
+
seen: set[str] = set()
|
|
111
144
|
for index, item in enumerate(raw_hosts):
|
|
112
145
|
host = _parse_host(item, path, index)
|
|
113
|
-
if host.id
|
|
114
|
-
|
|
115
|
-
|
|
146
|
+
if host.id in seen:
|
|
147
|
+
raise ValueError(f"{path} contains duplicate host id {host.id!r}")
|
|
148
|
+
seen.add(host.id)
|
|
149
|
+
hosts.append(host)
|
|
150
|
+
|
|
151
|
+
return hosts
|
|
152
|
+
|
|
116
153
|
|
|
117
|
-
|
|
154
|
+
def configured_hosts_path(config_path: Path | str | None = None) -> Path:
|
|
155
|
+
configured = (
|
|
156
|
+
config_path or os.environ.get("HOSTBRIDGE_HOSTS_FILE") or os.environ.get("SERVER_CONTROL_MCP_HOSTS_FILE")
|
|
157
|
+
)
|
|
158
|
+
return Path(configured).expanduser() if configured else _default_hosts_path()
|
|
118
159
|
|
|
119
160
|
|
|
120
161
|
def _parse_host(item: object, path: Path, index: int) -> HostConfig:
|
|
@@ -137,12 +178,15 @@ def _parse_host(item: object, path: Path, index: int) -> HostConfig:
|
|
|
137
178
|
transport = str(item.get("transport", "auto")).strip().lower()
|
|
138
179
|
if transport not in {"auto", "pty", "ssh"}:
|
|
139
180
|
raise ValueError(f"{path} hosts[{index}].transport must be one of: auto, pty, ssh")
|
|
181
|
+
if transport == "ssh" and "ssh" not in item:
|
|
182
|
+
raise ValueError(f"{path} hosts[{index}].transport ssh requires an ssh connection")
|
|
183
|
+
limits = _parse_limits(item.get("limits"), path, index)
|
|
140
184
|
|
|
141
185
|
if "script" in item:
|
|
142
186
|
script = str(item.get("script", "")).strip()
|
|
143
187
|
if not script:
|
|
144
188
|
raise ValueError(f"{path} hosts[{index}].script must not be empty")
|
|
145
|
-
host = HostConfig.script(host_id, label, script, transport=transport)
|
|
189
|
+
host = HostConfig.script(host_id, label, script, transport=transport, limits=limits)
|
|
146
190
|
return HostConfig(
|
|
147
191
|
host.id,
|
|
148
192
|
host.label,
|
|
@@ -154,19 +198,27 @@ def _parse_host(item: object, path: Path, index: int) -> HostConfig:
|
|
|
154
198
|
login_steps,
|
|
155
199
|
ready_expect,
|
|
156
200
|
transport,
|
|
201
|
+
limits,
|
|
157
202
|
)
|
|
158
203
|
|
|
159
204
|
if "ssh" in item:
|
|
205
|
+
ssh_config, ssh_args = _parse_ssh(item["ssh"], path, index)
|
|
206
|
+
if transport == "ssh" and ssh_config.extra_args:
|
|
207
|
+
raise ValueError(f"{path} hosts[{index}].ssh.extra_args cannot be used with native ssh transport")
|
|
208
|
+
if transport == "ssh" and login_steps:
|
|
209
|
+
raise ValueError(f"{path} hosts[{index}].login_steps cannot be used with native ssh transport")
|
|
160
210
|
return HostConfig.command_line(
|
|
161
211
|
host_id,
|
|
162
212
|
label,
|
|
163
213
|
"ssh",
|
|
164
|
-
|
|
214
|
+
ssh_args,
|
|
165
215
|
connection_type="ssh",
|
|
166
216
|
secrets=secrets,
|
|
167
217
|
login_steps=login_steps,
|
|
168
218
|
ready_expect=ready_expect,
|
|
169
219
|
transport=transport,
|
|
220
|
+
limits=limits,
|
|
221
|
+
ssh=ssh_config,
|
|
170
222
|
)
|
|
171
223
|
|
|
172
224
|
command = str(item.get("command", "")).strip()
|
|
@@ -184,9 +236,25 @@ def _parse_host(item: object, path: Path, index: int) -> HostConfig:
|
|
|
184
236
|
login_steps=login_steps,
|
|
185
237
|
ready_expect=ready_expect,
|
|
186
238
|
transport=transport,
|
|
239
|
+
limits=limits,
|
|
187
240
|
)
|
|
188
241
|
|
|
189
242
|
|
|
243
|
+
def _parse_limits(raw_limits: object, path: Path, index: int) -> HostLimits:
|
|
244
|
+
if raw_limits is None:
|
|
245
|
+
return HostLimits()
|
|
246
|
+
if not isinstance(raw_limits, dict):
|
|
247
|
+
raise ValueError(f"{path} hosts[{index}].limits must be an object")
|
|
248
|
+
values = {field: getattr(HostLimits(), field) for field in HostLimits.__dataclass_fields__}
|
|
249
|
+
for name, value in raw_limits.items():
|
|
250
|
+
if name not in values:
|
|
251
|
+
raise ValueError(f"{path} hosts[{index}].limits contains unknown limit {name!r}")
|
|
252
|
+
if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
|
|
253
|
+
raise ValueError(f"{path} hosts[{index}].limits.{name} must be a positive integer")
|
|
254
|
+
values[name] = value
|
|
255
|
+
return HostLimits(**values)
|
|
256
|
+
|
|
257
|
+
|
|
190
258
|
def _parse_ready_expect(raw_ready_expect: object, path: Path, index: int) -> str | None:
|
|
191
259
|
if raw_ready_expect in (None, ""):
|
|
192
260
|
return None
|
|
@@ -218,20 +286,36 @@ def _parse_login_steps(raw_steps: object, path: Path, index: int) -> list[LoginS
|
|
|
218
286
|
if not expect:
|
|
219
287
|
raise ValueError(f"{path} hosts[{index}].login_steps[{step_index}].expect must not be empty")
|
|
220
288
|
if (send is None) == (send_secret is None):
|
|
221
|
-
raise ValueError(
|
|
222
|
-
|
|
289
|
+
raise ValueError(
|
|
290
|
+
f"{path} hosts[{index}].login_steps[{step_index}] must define exactly one of send or send_secret"
|
|
291
|
+
)
|
|
292
|
+
steps.append(
|
|
293
|
+
LoginStep(
|
|
294
|
+
expect=expect,
|
|
295
|
+
send=None if send is None else str(send),
|
|
296
|
+
send_secret=None if send_secret is None else str(send_secret),
|
|
297
|
+
)
|
|
298
|
+
)
|
|
223
299
|
return steps
|
|
224
300
|
|
|
225
301
|
|
|
226
|
-
def
|
|
302
|
+
def _parse_ssh(raw_ssh: object, path: Path, index: int) -> tuple[SshHostConfig, list[str]]:
|
|
227
303
|
if not isinstance(raw_ssh, dict):
|
|
228
304
|
raise ValueError(f"{path} hosts[{index}].ssh must be an object")
|
|
229
305
|
target = str(raw_ssh.get("host", "")).strip()
|
|
230
306
|
if not target:
|
|
231
307
|
raise ValueError(f"{path} hosts[{index}].ssh.host must not be empty")
|
|
232
308
|
|
|
309
|
+
username: str | None = None
|
|
310
|
+
host = target
|
|
311
|
+
if "@" in target:
|
|
312
|
+
username, host = target.rsplit("@", 1)
|
|
313
|
+
if not username or not host:
|
|
314
|
+
raise ValueError(f"{path} hosts[{index}].ssh.host must be [username@]hostname")
|
|
315
|
+
|
|
233
316
|
args: list[str] = []
|
|
234
317
|
port = raw_ssh.get("port")
|
|
318
|
+
parsed_port = 22
|
|
235
319
|
if port not in (None, ""):
|
|
236
320
|
if isinstance(port, bool) or not isinstance(port, int | str):
|
|
237
321
|
raise ValueError(f"{path} hosts[{index}].ssh.port must be an integer")
|
|
@@ -243,8 +327,9 @@ def _parse_ssh_args(raw_ssh: object, path: Path, index: int) -> list[str]:
|
|
|
243
327
|
raise ValueError(f"{path} hosts[{index}].ssh.port must be between 1 and 65535")
|
|
244
328
|
args.extend(["-p", str(parsed_port)])
|
|
245
329
|
identity_file = str(raw_ssh.get("identity_file", "")).strip()
|
|
330
|
+
identity_path = Path(identity_file).expanduser() if identity_file else None
|
|
246
331
|
if identity_file:
|
|
247
|
-
args.extend(["-i", str(
|
|
332
|
+
args.extend(["-i", str(identity_path)])
|
|
248
333
|
proxy_jump = str(raw_ssh.get("proxy_jump", "")).strip()
|
|
249
334
|
if proxy_jump:
|
|
250
335
|
args.extend(["-J", proxy_jump])
|
|
@@ -253,7 +338,17 @@ def _parse_ssh_args(raw_ssh: object, path: Path, index: int) -> list[str]:
|
|
|
253
338
|
raise ValueError(f"{path} hosts[{index}].ssh.extra_args must be a list of strings")
|
|
254
339
|
args.extend(extra_args)
|
|
255
340
|
args.append(target)
|
|
256
|
-
return
|
|
341
|
+
return (
|
|
342
|
+
SshHostConfig(
|
|
343
|
+
host=host,
|
|
344
|
+
username=username,
|
|
345
|
+
port=parsed_port,
|
|
346
|
+
identity_file=identity_path,
|
|
347
|
+
proxy_jump=proxy_jump or None,
|
|
348
|
+
extra_args=tuple(extra_args),
|
|
349
|
+
),
|
|
350
|
+
args,
|
|
351
|
+
)
|
|
257
352
|
|
|
258
353
|
|
|
259
354
|
class HostRegistry:
|
|
@@ -272,8 +367,7 @@ class HostRegistry:
|
|
|
272
367
|
except KeyError as exc:
|
|
273
368
|
if not self._hosts:
|
|
274
369
|
raise KeyError(
|
|
275
|
-
"no hosts are configured; create ~/.hostbridge/hosts.json "
|
|
276
|
-
"or set HOSTBRIDGE_HOSTS_FILE"
|
|
370
|
+
"no hosts are configured; create ~/.hostbridge/hosts.json or set HOSTBRIDGE_HOSTS_FILE"
|
|
277
371
|
) from exc
|
|
278
372
|
valid = ", ".join(sorted(self._hosts))
|
|
279
373
|
raise KeyError(f"unknown host '{host_id}'; valid hosts: {valid}") from exc
|