@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.
@@ -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(cls, host_id: str, label: str, script: Path | str, *, transport: str = "auto") -> HostConfig:
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(host_id, label, "/bin/sh", [str(path)], "script", path, transport=transport)
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
- configured = config_path or os.environ.get("HOSTBRIDGE_HOSTS_FILE") or os.environ.get("SERVER_CONTROL_MCP_HOSTS_FILE")
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
- data = json.loads(path.read_text(encoding="utf-8"))
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
- hosts_by_id: dict[str, HostConfig] = {}
110
- order: list[str] = []
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 not in hosts_by_id:
114
- order.append(host.id)
115
- hosts_by_id[host.id] = host
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
- return [hosts_by_id[host_id] for host_id in order]
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
- _parse_ssh_args(item["ssh"], path, index),
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(f"{path} hosts[{index}].login_steps[{step_index}] must define exactly one of send or send_secret")
222
- steps.append(LoginStep(expect=expect, send=None if send is None else str(send), send_secret=None if send_secret is None else str(send_secret)))
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 _parse_ssh_args(raw_ssh: object, path: Path, index: int) -> list[str]:
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(Path(identity_file).expanduser())])
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 args
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