@neoline/hostbridge 2.0.3 → 2.0.5

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.
Files changed (48) hide show
  1. package/README.md +24 -15
  2. package/bin/hostbridge.js +80 -9
  3. package/docs/ARCHITECTURE.md +62 -0
  4. package/examples/claude_desktop_config.json +1 -1
  5. package/examples/codex.config.toml +1 -1
  6. package/examples/hosts.example.json +1 -0
  7. package/package.json +2 -1
  8. package/pyproject.toml +5 -4
  9. package/src/server_control_mcp/__init__.py +60 -24
  10. package/src/server_control_mcp/async_lifecycle.py +41 -0
  11. package/src/server_control_mcp/cli.py +2 -2
  12. package/src/server_control_mcp/client.py +345 -238
  13. package/src/server_control_mcp/config.py +18 -6
  14. package/src/server_control_mcp/daemon.py +309 -412
  15. package/src/server_control_mcp/doctor.py +41 -5
  16. package/src/server_control_mcp/hosts.py +252 -24
  17. package/src/server_control_mcp/mock_ssh.py +97 -960
  18. package/src/server_control_mcp/mock_ssh_exec.py +299 -0
  19. package/src/server_control_mcp/mock_ssh_session.py +69 -0
  20. package/src/server_control_mcp/mock_ssh_sftp.py +604 -0
  21. package/src/server_control_mcp/mock_ssh_tunnel.py +289 -0
  22. package/src/server_control_mcp/mux_connection.py +326 -0
  23. package/src/server_control_mcp/mux_daemon.py +186 -0
  24. package/src/server_control_mcp/mux_protocol.py +279 -0
  25. package/src/server_control_mcp/mux_records.py +71 -0
  26. package/src/server_control_mcp/mux_rpc.py +1779 -0
  27. package/src/server_control_mcp/mux_service.py +506 -0
  28. package/src/server_control_mcp/mux_stream.py +283 -0
  29. package/src/server_control_mcp/mux_sync_client.py +224 -0
  30. package/src/server_control_mcp/policy.py +86 -14
  31. package/src/server_control_mcp/remote_agent_bundle.py +56 -0
  32. package/src/server_control_mcp/remote_mux_agent.py +769 -0
  33. package/src/server_control_mcp/remote_task_agent.py +102 -0
  34. package/src/server_control_mcp/runtime.py +3 -2
  35. package/src/server_control_mcp/runtime_services.py +862 -0
  36. package/src/server_control_mcp/secrets.py +6 -6
  37. package/src/server_control_mcp/secure_files.py +121 -0
  38. package/src/server_control_mcp/server.py +53 -20
  39. package/src/server_control_mcp/services.py +3 -469
  40. package/src/server_control_mcp/transports/__init__.py +4 -8
  41. package/src/server_control_mcp/transports/base.py +60 -38
  42. package/src/server_control_mcp/transports/ssh.py +315 -84
  43. package/src/server_control_mcp/tunnel_manager.py +1245 -0
  44. package/src/server_control_mcp/tunnel_native_ssh.py +454 -0
  45. package/src/server_control_mcp/tunnel_providers.py +20 -0
  46. package/src/server_control_mcp/tunnel_pty_agent.py +909 -0
  47. package/src/server_control_mcp/protocol.py +0 -362
  48. package/src/server_control_mcp/transports/pty.py +0 -434
@@ -10,6 +10,7 @@ from typing import Protocol
10
10
 
11
11
  from . import __version__
12
12
  from .config import load_v1_config
13
+ from .mux_protocol import MUX_PROTOCOL_VERSION
13
14
  from .runtime import runtime_paths
14
15
 
15
16
 
@@ -17,6 +18,9 @@ class DoctorClient(Protocol):
17
18
  def hello(self) -> dict[str, object]:
18
19
  raise NotImplementedError
19
20
 
21
+ def tunnel_health(self) -> dict[str, object]:
22
+ raise NotImplementedError
23
+
20
24
 
21
25
  @dataclass(frozen=True, slots=True)
22
26
  class DiagnosticResult:
@@ -46,6 +50,7 @@ def collect_diagnostics(
46
50
  _config_diagnostic(config_path),
47
51
  _runtime_diagnostic(),
48
52
  _daemon_diagnostic(client),
53
+ _tunnel_diagnostic(client),
49
54
  _executable_diagnostic(executable_paths),
50
55
  ]
51
56
  return DoctorReport(tuple(results))
@@ -60,8 +65,10 @@ def _version_diagnostic() -> DiagnosticResult:
60
65
  "npm": npm.get("version"),
61
66
  "python": pyproject.get("project", {}).get("version"),
62
67
  }
63
- status = "pass" if len(set(versions.values())) == 1 and __version__ == "2.0.3" else "fail"
64
- return DiagnosticResult("version", status, "version sources agree" if status == "pass" else "version drift detected", versions)
68
+ status = "pass" if len(set(versions.values())) == 1 else "fail"
69
+ return DiagnosticResult(
70
+ "version", status, "version sources agree" if status == "pass" else "version drift detected", versions
71
+ )
65
72
 
66
73
 
67
74
  def _config_diagnostic(config_path: Path | str) -> DiagnosticResult:
@@ -69,7 +76,9 @@ def _config_diagnostic(config_path: Path | str) -> DiagnosticResult:
69
76
  config = load_v1_config(config_path)
70
77
  except (OSError, ValueError) as exc:
71
78
  return DiagnosticResult("config", "fail", str(exc), {"path": str(config_path)})
72
- return DiagnosticResult("config", "pass", "schema v1 configuration is valid", {"path": str(config.path), "hosts": len(config.hosts)})
79
+ return DiagnosticResult(
80
+ "config", "pass", "schema v1 configuration is valid", {"path": str(config.path), "hosts": len(config.hosts)}
81
+ )
73
82
 
74
83
 
75
84
  def _runtime_diagnostic() -> DiagnosticResult:
@@ -93,15 +102,42 @@ def _daemon_diagnostic(client: DoctorClient) -> DiagnosticResult:
93
102
  hello = client.hello()
94
103
  except Exception as exc:
95
104
  return DiagnosticResult("daemon", "fail", f"daemon unavailable: {exc}", {})
96
- valid = hello.get("service") == "hostbridge" and hello.get("protocol") == 1
105
+ valid = (
106
+ hello.get("service") == "hostbridge"
107
+ and hello.get("protocol") == MUX_PROTOCOL_VERSION
108
+ and hello.get("version") == __version__
109
+ )
97
110
  return DiagnosticResult(
98
111
  "daemon",
99
112
  "pass" if valid else "fail",
100
- "daemon protocol v1 is healthy" if valid else "daemon returned an incompatible handshake",
113
+ f"daemon protocol v{MUX_PROTOCOL_VERSION} is healthy" if valid else "daemon returned an incompatible handshake",
101
114
  dict(hello),
102
115
  )
103
116
 
104
117
 
118
+ def _tunnel_diagnostic(client: DoctorClient) -> DiagnosticResult:
119
+ try:
120
+ result = client.tunnel_health()
121
+ except Exception as exc:
122
+ return DiagnosticResult("tunnel", "fail", f"tunnel diagnostics unavailable: {exc}", {})
123
+ hosts = result.get("hosts")
124
+ if not isinstance(hosts, list) or any(not isinstance(host, dict) for host in hosts):
125
+ return DiagnosticResult("tunnel", "fail", "daemon returned invalid tunnel diagnostics", dict(result))
126
+ enabled = [host for host in hosts if host.get("enabled") is True]
127
+ failed = [host for host in enabled if host.get("state") == "failed"]
128
+ inactive = [host for host in enabled if host.get("state") != "ready"]
129
+ if failed:
130
+ status = "fail"
131
+ message = f"{len(failed)} tunnel provider runtime(s) failed"
132
+ elif inactive:
133
+ status = "warn"
134
+ message = f"{len(inactive)} tunnel provider runtime(s) configured but not handshaken"
135
+ else:
136
+ status = "pass"
137
+ message = "tunnel provider runtimes are ready"
138
+ return DiagnosticResult("tunnel", status, message, dict(result))
139
+
140
+
105
141
  def _executable_diagnostic(paths: list[str]) -> DiagnosticResult:
106
142
  if not paths:
107
143
  return DiagnosticResult("executable", "fail", "hostbridge executable was not found", {"paths": []})
@@ -1,12 +1,13 @@
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
7
+ from ipaddress import ip_address, ip_network
8
8
  from pathlib import Path
9
9
 
10
+ from .config import load_v1_config
10
11
  from .secrets import decrypt_secret
11
12
 
12
13
  DEFAULT_HOSTS_FILE = Path("~/.hostbridge/hosts.json").expanduser()
@@ -14,6 +15,17 @@ LEGACY_HOSTS_FILE = Path("~/.server_control_mcp/hosts.json").expanduser()
14
15
  HOST_ID_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+$")
15
16
 
16
17
 
18
+ @dataclass(frozen=True, slots=True)
19
+ class HostLimits:
20
+ max_sessions: int = 16
21
+ command_timeout_seconds: int = 24 * 60 * 60
22
+ max_output_bytes: int = 1_000_000
23
+ max_stdin_bytes: int = 100 * 1024 * 1024
24
+ max_transfer_bytes: int = 100 * 1024 * 1024
25
+ max_text_bytes: int = 4 * 1024 * 1024
26
+ max_shell_bytes: int = 1024 * 1024
27
+
28
+
17
29
  @dataclass(frozen=True, slots=True)
18
30
  class LoginStep:
19
31
  expect: str
@@ -21,6 +33,27 @@ class LoginStep:
21
33
  send_secret: str | None = None
22
34
 
23
35
 
36
+ @dataclass(frozen=True, slots=True)
37
+ class SshHostConfig:
38
+ host: str
39
+ username: str | None = None
40
+ port: int = 22
41
+ identity_file: Path | None = None
42
+ proxy_jump: str | None = None
43
+ extra_args: tuple[str, ...] = ()
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class ForwardingPolicy:
48
+ enabled: bool = False
49
+ allow_cidrs: tuple[str, ...] = ()
50
+ allow_hostnames: tuple[str, ...] = ()
51
+ allow_ports: tuple[int, ...] = ()
52
+ max_streams: int = 128
53
+ idle_timeout_seconds: float = 300
54
+ stream_deadline_seconds: float = 24 * 60 * 60
55
+
56
+
24
57
  @dataclass(frozen=True, slots=True)
25
58
  class HostConfig:
26
59
  """A single approved connection target."""
@@ -35,11 +68,33 @@ class HostConfig:
35
68
  login_steps: list[LoginStep] = field(default_factory=list)
36
69
  ready_expect: str | None = None
37
70
  transport: str = "auto"
71
+ limits: HostLimits = field(default_factory=HostLimits)
72
+ ssh: SshHostConfig | None = None
73
+ forwarding: ForwardingPolicy = field(default_factory=ForwardingPolicy)
38
74
 
39
75
  @classmethod
40
- def script(cls, host_id: str, label: str, script: Path | str, *, transport: str = "auto") -> HostConfig:
76
+ def script(
77
+ cls,
78
+ host_id: str,
79
+ label: str,
80
+ script: Path | str,
81
+ *,
82
+ transport: str = "auto",
83
+ limits: HostLimits | None = None,
84
+ forwarding: ForwardingPolicy | None = None,
85
+ ) -> HostConfig:
41
86
  path = Path(script).expanduser()
42
- return cls(host_id, label, "/bin/sh", [str(path)], "script", path, transport=transport)
87
+ return cls(
88
+ host_id,
89
+ label,
90
+ "/bin/sh",
91
+ [str(path)],
92
+ "script",
93
+ path,
94
+ transport=transport,
95
+ limits=limits or HostLimits(),
96
+ forwarding=forwarding or ForwardingPolicy(),
97
+ )
43
98
 
44
99
  @classmethod
45
100
  def command_line(
@@ -54,6 +109,9 @@ class HostConfig:
54
109
  login_steps: list[LoginStep] | None = None,
55
110
  ready_expect: str | None = None,
56
111
  transport: str = "auto",
112
+ limits: HostLimits | None = None,
113
+ ssh: SshHostConfig | None = None,
114
+ forwarding: ForwardingPolicy | None = None,
57
115
  ) -> HostConfig:
58
116
  return cls(
59
117
  host_id,
@@ -66,6 +124,9 @@ class HostConfig:
66
124
  login_steps or [],
67
125
  ready_expect,
68
126
  transport,
127
+ limits or HostLimits(),
128
+ ssh,
129
+ forwarding or ForwardingPolicy(),
69
130
  )
70
131
 
71
132
  def describe(self) -> dict[str, object]:
@@ -76,6 +137,12 @@ class HostConfig:
76
137
  "command": self.command,
77
138
  "args": list(self.args),
78
139
  "transport": self.transport,
140
+ "forwarding": {
141
+ "enabled": self.forwarding.enabled,
142
+ "max_streams": self.forwarding.max_streams,
143
+ "idle_timeout_seconds": self.forwarding.idle_timeout_seconds,
144
+ "stream_deadline_seconds": self.forwarding.stream_deadline_seconds,
145
+ },
79
146
  }
80
147
  if self.script_path is not None:
81
148
  info.update(
@@ -96,25 +163,29 @@ DEFAULT_HOSTS: list[HostConfig] = []
96
163
  def load_hosts(config_path: Path | str | None = None) -> list[HostConfig]:
97
164
  """Load approved hosts from JSON configuration."""
98
165
 
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()
166
+ path = configured_hosts_path(config_path)
101
167
  if not path.exists():
102
168
  return []
103
169
 
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")
170
+ raw_hosts = load_v1_config(path).hosts
108
171
 
109
- hosts_by_id: dict[str, HostConfig] = {}
110
- order: list[str] = []
172
+ hosts: list[HostConfig] = []
173
+ seen: set[str] = set()
111
174
  for index, item in enumerate(raw_hosts):
112
175
  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
176
+ if host.id in seen:
177
+ raise ValueError(f"{path} contains duplicate host id {host.id!r}")
178
+ seen.add(host.id)
179
+ hosts.append(host)
116
180
 
117
- return [hosts_by_id[host_id] for host_id in order]
181
+ return hosts
182
+
183
+
184
+ def configured_hosts_path(config_path: Path | str | None = None) -> Path:
185
+ configured = (
186
+ config_path or os.environ.get("HOSTBRIDGE_HOSTS_FILE") or os.environ.get("SERVER_CONTROL_MCP_HOSTS_FILE")
187
+ )
188
+ return Path(configured).expanduser() if configured else _default_hosts_path()
118
189
 
119
190
 
120
191
  def _parse_host(item: object, path: Path, index: int) -> HostConfig:
@@ -137,12 +208,23 @@ def _parse_host(item: object, path: Path, index: int) -> HostConfig:
137
208
  transport = str(item.get("transport", "auto")).strip().lower()
138
209
  if transport not in {"auto", "pty", "ssh"}:
139
210
  raise ValueError(f"{path} hosts[{index}].transport must be one of: auto, pty, ssh")
211
+ if transport == "ssh" and "ssh" not in item:
212
+ raise ValueError(f"{path} hosts[{index}].transport ssh requires an ssh connection")
213
+ limits = _parse_limits(item.get("limits"), path, index)
214
+ forwarding = _parse_forwarding(item.get("forwarding"), path, index)
140
215
 
141
216
  if "script" in item:
142
217
  script = str(item.get("script", "")).strip()
143
218
  if not script:
144
219
  raise ValueError(f"{path} hosts[{index}].script must not be empty")
145
- host = HostConfig.script(host_id, label, script, transport=transport)
220
+ host = HostConfig.script(
221
+ host_id,
222
+ label,
223
+ script,
224
+ transport=transport,
225
+ limits=limits,
226
+ forwarding=forwarding,
227
+ )
146
228
  return HostConfig(
147
229
  host.id,
148
230
  host.label,
@@ -154,19 +236,29 @@ def _parse_host(item: object, path: Path, index: int) -> HostConfig:
154
236
  login_steps,
155
237
  ready_expect,
156
238
  transport,
239
+ limits,
240
+ forwarding=forwarding,
157
241
  )
158
242
 
159
243
  if "ssh" in item:
244
+ ssh_config, ssh_args = _parse_ssh(item["ssh"], path, index)
245
+ if transport == "ssh" and ssh_config.extra_args:
246
+ raise ValueError(f"{path} hosts[{index}].ssh.extra_args cannot be used with native ssh transport")
247
+ if transport == "ssh" and login_steps:
248
+ raise ValueError(f"{path} hosts[{index}].login_steps cannot be used with native ssh transport")
160
249
  return HostConfig.command_line(
161
250
  host_id,
162
251
  label,
163
252
  "ssh",
164
- _parse_ssh_args(item["ssh"], path, index),
253
+ ssh_args,
165
254
  connection_type="ssh",
166
255
  secrets=secrets,
167
256
  login_steps=login_steps,
168
257
  ready_expect=ready_expect,
169
258
  transport=transport,
259
+ limits=limits,
260
+ ssh=ssh_config,
261
+ forwarding=forwarding,
170
262
  )
171
263
 
172
264
  command = str(item.get("command", "")).strip()
@@ -184,9 +276,119 @@ def _parse_host(item: object, path: Path, index: int) -> HostConfig:
184
276
  login_steps=login_steps,
185
277
  ready_expect=ready_expect,
186
278
  transport=transport,
279
+ limits=limits,
280
+ forwarding=forwarding,
187
281
  )
188
282
 
189
283
 
284
+ def _parse_forwarding(raw: object, path: Path, index: int) -> ForwardingPolicy:
285
+ if raw is None:
286
+ return ForwardingPolicy()
287
+ if not isinstance(raw, dict):
288
+ raise ValueError(f"{path} hosts[{index}].forwarding must be an object")
289
+ allowed_fields = {
290
+ "enabled",
291
+ "allow_cidrs",
292
+ "allow_hostnames",
293
+ "allow_ports",
294
+ "max_streams",
295
+ "idle_timeout_seconds",
296
+ "stream_deadline_seconds",
297
+ }
298
+ unknown = sorted(set(raw) - allowed_fields)
299
+ if unknown:
300
+ raise ValueError(f"{path} hosts[{index}].forwarding contains unknown field {unknown[0]!r}")
301
+
302
+ enabled = raw.get("enabled", False)
303
+ if not isinstance(enabled, bool):
304
+ raise ValueError(f"{path} hosts[{index}].forwarding.enabled must be a boolean")
305
+
306
+ raw_cidrs = raw.get("allow_cidrs", [])
307
+ if not isinstance(raw_cidrs, list) or not all(isinstance(value, str) for value in raw_cidrs):
308
+ raise ValueError(f"{path} hosts[{index}].forwarding.allow_cidrs must be a list of CIDR strings")
309
+ cidrs: list[str] = []
310
+ for value in raw_cidrs:
311
+ try:
312
+ network = ip_network(value, strict=True)
313
+ except ValueError as exc:
314
+ raise ValueError(f"{path} hosts[{index}].forwarding contains invalid CIDR {value!r}") from exc
315
+ cidrs.append(str(network))
316
+
317
+ raw_hostnames = raw.get("allow_hostnames", [])
318
+ if not isinstance(raw_hostnames, list) or not all(isinstance(value, str) for value in raw_hostnames):
319
+ raise ValueError(f"{path} hosts[{index}].forwarding.allow_hostnames must be a list of hostnames")
320
+ hostnames: list[str] = []
321
+ for value in raw_hostnames:
322
+ hostname = value.strip().rstrip(".").lower()
323
+ if not _valid_exact_hostname(hostname):
324
+ raise ValueError(f"{path} hosts[{index}].forwarding contains invalid hostname {value!r}")
325
+ hostnames.append(hostname)
326
+
327
+ raw_ports = raw.get("allow_ports", [])
328
+ if not isinstance(raw_ports, list) or any(
329
+ not isinstance(port, int) or isinstance(port, bool) or not 1 <= port <= 65535 for port in raw_ports
330
+ ):
331
+ raise ValueError(f"{path} hosts[{index}].forwarding.allow_ports must contain valid TCP ports")
332
+
333
+ max_streams = raw.get("max_streams", 128)
334
+ if not isinstance(max_streams, int) or isinstance(max_streams, bool) or not 1 <= max_streams <= 4096:
335
+ raise ValueError(f"{path} hosts[{index}].forwarding.max_streams must be between 1 and 4096")
336
+ idle_timeout = raw.get("idle_timeout_seconds", 300)
337
+ if not isinstance(idle_timeout, (int, float)) or isinstance(idle_timeout, bool) or not 0 < idle_timeout <= 86400:
338
+ raise ValueError(f"{path} hosts[{index}].forwarding.idle_timeout_seconds must be between 0 and 86400")
339
+ stream_deadline = raw.get("stream_deadline_seconds", 24 * 60 * 60)
340
+ if (
341
+ not isinstance(stream_deadline, (int, float))
342
+ or isinstance(stream_deadline, bool)
343
+ or not 0 < stream_deadline <= 7 * 24 * 60 * 60
344
+ ):
345
+ raise ValueError(f"{path} hosts[{index}].forwarding.stream_deadline_seconds must be between 0 and 604800")
346
+
347
+ return ForwardingPolicy(
348
+ enabled=enabled,
349
+ allow_cidrs=tuple(dict.fromkeys(cidrs)),
350
+ allow_hostnames=tuple(dict.fromkeys(hostnames)),
351
+ allow_ports=tuple(dict.fromkeys(raw_ports)),
352
+ max_streams=max_streams,
353
+ idle_timeout_seconds=float(idle_timeout),
354
+ stream_deadline_seconds=float(stream_deadline),
355
+ )
356
+
357
+
358
+ def _valid_exact_hostname(hostname: str) -> bool:
359
+ if not hostname or len(hostname) > 253 or "*" in hostname or "\x00" in hostname:
360
+ return False
361
+ try:
362
+ ip_address(hostname)
363
+ except ValueError:
364
+ pass
365
+ else:
366
+ return False
367
+ labels = hostname.split(".")
368
+ return all(
369
+ 1 <= len(label) <= 63
370
+ and label[0].isalnum()
371
+ and label[-1].isalnum()
372
+ and all(character.isalnum() or character == "-" for character in label)
373
+ for label in labels
374
+ )
375
+
376
+
377
+ def _parse_limits(raw_limits: object, path: Path, index: int) -> HostLimits:
378
+ if raw_limits is None:
379
+ return HostLimits()
380
+ if not isinstance(raw_limits, dict):
381
+ raise ValueError(f"{path} hosts[{index}].limits must be an object")
382
+ values = {field: getattr(HostLimits(), field) for field in HostLimits.__dataclass_fields__}
383
+ for name, value in raw_limits.items():
384
+ if name not in values:
385
+ raise ValueError(f"{path} hosts[{index}].limits contains unknown limit {name!r}")
386
+ if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
387
+ raise ValueError(f"{path} hosts[{index}].limits.{name} must be a positive integer")
388
+ values[name] = value
389
+ return HostLimits(**values)
390
+
391
+
190
392
  def _parse_ready_expect(raw_ready_expect: object, path: Path, index: int) -> str | None:
191
393
  if raw_ready_expect in (None, ""):
192
394
  return None
@@ -218,20 +420,36 @@ def _parse_login_steps(raw_steps: object, path: Path, index: int) -> list[LoginS
218
420
  if not expect:
219
421
  raise ValueError(f"{path} hosts[{index}].login_steps[{step_index}].expect must not be empty")
220
422
  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)))
423
+ raise ValueError(
424
+ f"{path} hosts[{index}].login_steps[{step_index}] must define exactly one of send or send_secret"
425
+ )
426
+ steps.append(
427
+ LoginStep(
428
+ expect=expect,
429
+ send=None if send is None else str(send),
430
+ send_secret=None if send_secret is None else str(send_secret),
431
+ )
432
+ )
223
433
  return steps
224
434
 
225
435
 
226
- def _parse_ssh_args(raw_ssh: object, path: Path, index: int) -> list[str]:
436
+ def _parse_ssh(raw_ssh: object, path: Path, index: int) -> tuple[SshHostConfig, list[str]]:
227
437
  if not isinstance(raw_ssh, dict):
228
438
  raise ValueError(f"{path} hosts[{index}].ssh must be an object")
229
439
  target = str(raw_ssh.get("host", "")).strip()
230
440
  if not target:
231
441
  raise ValueError(f"{path} hosts[{index}].ssh.host must not be empty")
232
442
 
443
+ username: str | None = None
444
+ host = target
445
+ if "@" in target:
446
+ username, host = target.rsplit("@", 1)
447
+ if not username or not host:
448
+ raise ValueError(f"{path} hosts[{index}].ssh.host must be [username@]hostname")
449
+
233
450
  args: list[str] = []
234
451
  port = raw_ssh.get("port")
452
+ parsed_port = 22
235
453
  if port not in (None, ""):
236
454
  if isinstance(port, bool) or not isinstance(port, int | str):
237
455
  raise ValueError(f"{path} hosts[{index}].ssh.port must be an integer")
@@ -243,8 +461,9 @@ def _parse_ssh_args(raw_ssh: object, path: Path, index: int) -> list[str]:
243
461
  raise ValueError(f"{path} hosts[{index}].ssh.port must be between 1 and 65535")
244
462
  args.extend(["-p", str(parsed_port)])
245
463
  identity_file = str(raw_ssh.get("identity_file", "")).strip()
464
+ identity_path = Path(identity_file).expanduser() if identity_file else None
246
465
  if identity_file:
247
- args.extend(["-i", str(Path(identity_file).expanduser())])
466
+ args.extend(["-i", str(identity_path)])
248
467
  proxy_jump = str(raw_ssh.get("proxy_jump", "")).strip()
249
468
  if proxy_jump:
250
469
  args.extend(["-J", proxy_jump])
@@ -253,7 +472,17 @@ def _parse_ssh_args(raw_ssh: object, path: Path, index: int) -> list[str]:
253
472
  raise ValueError(f"{path} hosts[{index}].ssh.extra_args must be a list of strings")
254
473
  args.extend(extra_args)
255
474
  args.append(target)
256
- return args
475
+ return (
476
+ SshHostConfig(
477
+ host=host,
478
+ username=username,
479
+ port=parsed_port,
480
+ identity_file=identity_path,
481
+ proxy_jump=proxy_jump or None,
482
+ extra_args=tuple(extra_args),
483
+ ),
484
+ args,
485
+ )
257
486
 
258
487
 
259
488
  class HostRegistry:
@@ -272,8 +501,7 @@ class HostRegistry:
272
501
  except KeyError as exc:
273
502
  if not self._hosts:
274
503
  raise KeyError(
275
- "no hosts are configured; create ~/.hostbridge/hosts.json "
276
- "or set HOSTBRIDGE_HOSTS_FILE"
504
+ "no hosts are configured; create ~/.hostbridge/hosts.json or set HOSTBRIDGE_HOSTS_FILE"
277
505
  ) from exc
278
506
  valid = ", ".join(sorted(self._hosts))
279
507
  raise KeyError(f"unknown host '{host_id}'; valid hosts: {valid}") from exc