@neoline/hostbridge 1.4.4 → 2.0.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 +24 -137
- package/package.json +1 -1
- package/pyproject.toml +2 -1
- package/src/server_control_mcp/__init__.py +5 -5
- package/src/server_control_mcp/cli.py +229 -1231
- package/src/server_control_mcp/client.py +478 -0
- package/src/server_control_mcp/config.py +158 -0
- package/src/server_control_mcp/daemon.py +387 -306
- package/src/server_control_mcp/doctor.py +108 -0
- package/src/server_control_mcp/hosts.py +52 -7
- package/src/server_control_mcp/mock_ssh.py +279 -99
- package/src/server_control_mcp/policy.py +2 -0
- package/src/server_control_mcp/protocol.py +362 -0
- package/src/server_control_mcp/runtime.py +40 -0
- package/src/server_control_mcp/secrets.py +8 -0
- package/src/server_control_mcp/server.py +132 -188
- package/src/server_control_mcp/services.py +508 -0
- package/src/server_control_mcp/transports/__init__.py +17 -0
- package/src/server_control_mcp/transports/base.py +78 -0
- package/src/server_control_mcp/transports/pty.py +427 -0
- package/src/server_control_mcp/transports/ssh.py +195 -0
- package/src/server_control_mcp/manager.py +0 -925
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Deterministic HostBridge installation diagnostics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import tomllib
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .config import load_v1_config
|
|
13
|
+
from .runtime import runtime_paths
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DoctorClient(Protocol):
|
|
17
|
+
def hello(self) -> dict[str, object]:
|
|
18
|
+
raise NotImplementedError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class DiagnosticResult:
|
|
23
|
+
name: str
|
|
24
|
+
status: str
|
|
25
|
+
message: str
|
|
26
|
+
details: dict[str, object]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class DoctorReport:
|
|
31
|
+
results: tuple[DiagnosticResult, ...]
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def ok(self) -> bool:
|
|
35
|
+
return all(result.status != "fail" for result in self.results)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def collect_diagnostics(
|
|
39
|
+
config_path: Path | str,
|
|
40
|
+
*,
|
|
41
|
+
client: DoctorClient,
|
|
42
|
+
executable_paths: list[str],
|
|
43
|
+
) -> DoctorReport:
|
|
44
|
+
results = [
|
|
45
|
+
_version_diagnostic(),
|
|
46
|
+
_config_diagnostic(config_path),
|
|
47
|
+
_runtime_diagnostic(),
|
|
48
|
+
_daemon_diagnostic(client),
|
|
49
|
+
_executable_diagnostic(executable_paths),
|
|
50
|
+
]
|
|
51
|
+
return DoctorReport(tuple(results))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _version_diagnostic() -> DiagnosticResult:
|
|
55
|
+
root = Path(__file__).parents[2]
|
|
56
|
+
npm = json.loads((root / "package.json").read_text(encoding="utf-8"))
|
|
57
|
+
pyproject = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))
|
|
58
|
+
versions = {
|
|
59
|
+
"runtime": __version__,
|
|
60
|
+
"npm": npm.get("version"),
|
|
61
|
+
"python": pyproject.get("project", {}).get("version"),
|
|
62
|
+
}
|
|
63
|
+
status = "pass" if len(set(versions.values())) == 1 and __version__ == "2.0.1" else "fail"
|
|
64
|
+
return DiagnosticResult("version", status, "version sources agree" if status == "pass" else "version drift detected", versions)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _config_diagnostic(config_path: Path | str) -> DiagnosticResult:
|
|
68
|
+
try:
|
|
69
|
+
config = load_v1_config(config_path)
|
|
70
|
+
except (OSError, ValueError) as exc:
|
|
71
|
+
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)})
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _runtime_diagnostic() -> DiagnosticResult:
|
|
76
|
+
paths = runtime_paths()
|
|
77
|
+
try:
|
|
78
|
+
paths.ensure_directory()
|
|
79
|
+
except OSError as exc:
|
|
80
|
+
return DiagnosticResult("runtime", "fail", str(exc), {"directory": str(paths.directory)})
|
|
81
|
+
mode = paths.directory.stat().st_mode & 0o777
|
|
82
|
+
status = "pass" if mode == 0o700 else "fail"
|
|
83
|
+
return DiagnosticResult(
|
|
84
|
+
"runtime",
|
|
85
|
+
status,
|
|
86
|
+
"runtime directory is private" if status == "pass" else "runtime directory must use mode 0700",
|
|
87
|
+
{"directory": str(paths.directory), "mode": format(mode, "04o")},
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _daemon_diagnostic(client: DoctorClient) -> DiagnosticResult:
|
|
92
|
+
try:
|
|
93
|
+
hello = client.hello()
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
return DiagnosticResult("daemon", "fail", f"daemon unavailable: {exc}", {})
|
|
96
|
+
valid = hello.get("service") == "hostbridge" and hello.get("protocol") == 1
|
|
97
|
+
return DiagnosticResult(
|
|
98
|
+
"daemon",
|
|
99
|
+
"pass" if valid else "fail",
|
|
100
|
+
"daemon protocol v1 is healthy" if valid else "daemon returned an incompatible handshake",
|
|
101
|
+
dict(hello),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _executable_diagnostic(paths: list[str]) -> DiagnosticResult:
|
|
106
|
+
if not paths:
|
|
107
|
+
return DiagnosticResult("executable", "fail", "hostbridge executable was not found", {"paths": []})
|
|
108
|
+
return DiagnosticResult("executable", "pass", "hostbridge executable found", {"paths": paths})
|
|
@@ -34,11 +34,12 @@ class HostConfig:
|
|
|
34
34
|
secrets: dict[str, str] = field(default_factory=dict)
|
|
35
35
|
login_steps: list[LoginStep] = field(default_factory=list)
|
|
36
36
|
ready_expect: str | None = None
|
|
37
|
+
transport: str = "auto"
|
|
37
38
|
|
|
38
39
|
@classmethod
|
|
39
|
-
def script(cls, host_id: str, label: str, script: Path | str) -> HostConfig:
|
|
40
|
+
def script(cls, host_id: str, label: str, script: Path | str, *, transport: str = "auto") -> HostConfig:
|
|
40
41
|
path = Path(script).expanduser()
|
|
41
|
-
return cls(host_id, label, "/bin/sh", [str(path)], "script", path)
|
|
42
|
+
return cls(host_id, label, "/bin/sh", [str(path)], "script", path, transport=transport)
|
|
42
43
|
|
|
43
44
|
@classmethod
|
|
44
45
|
def command_line(
|
|
@@ -52,6 +53,7 @@ class HostConfig:
|
|
|
52
53
|
secrets: dict[str, str] | None = None,
|
|
53
54
|
login_steps: list[LoginStep] | None = None,
|
|
54
55
|
ready_expect: str | None = None,
|
|
56
|
+
transport: str = "auto",
|
|
55
57
|
) -> HostConfig:
|
|
56
58
|
return cls(
|
|
57
59
|
host_id,
|
|
@@ -63,6 +65,7 @@ class HostConfig:
|
|
|
63
65
|
secrets or {},
|
|
64
66
|
login_steps or [],
|
|
65
67
|
ready_expect,
|
|
68
|
+
transport,
|
|
66
69
|
)
|
|
67
70
|
|
|
68
71
|
def describe(self) -> dict[str, object]:
|
|
@@ -72,6 +75,7 @@ class HostConfig:
|
|
|
72
75
|
"connection_type": self.connection_type,
|
|
73
76
|
"command": self.command,
|
|
74
77
|
"args": list(self.args),
|
|
78
|
+
"transport": self.transport,
|
|
75
79
|
}
|
|
76
80
|
if self.script_path is not None:
|
|
77
81
|
info.update(
|
|
@@ -130,16 +134,40 @@ def _parse_host(item: object, path: Path, index: int) -> HostConfig:
|
|
|
130
134
|
secrets = _parse_secrets(item.get("secrets", {}), path, index)
|
|
131
135
|
login_steps = _parse_login_steps(item.get("login_steps", []), path, index)
|
|
132
136
|
ready_expect = _parse_ready_expect(item.get("ready_expect"), path, index)
|
|
137
|
+
transport = str(item.get("transport", "auto")).strip().lower()
|
|
138
|
+
if transport not in {"auto", "pty", "ssh"}:
|
|
139
|
+
raise ValueError(f"{path} hosts[{index}].transport must be one of: auto, pty, ssh")
|
|
133
140
|
|
|
134
141
|
if "script" in item:
|
|
135
142
|
script = str(item.get("script", "")).strip()
|
|
136
143
|
if not script:
|
|
137
144
|
raise ValueError(f"{path} hosts[{index}].script must not be empty")
|
|
138
|
-
host = HostConfig.script(host_id, label, script)
|
|
139
|
-
return HostConfig(
|
|
145
|
+
host = HostConfig.script(host_id, label, script, transport=transport)
|
|
146
|
+
return HostConfig(
|
|
147
|
+
host.id,
|
|
148
|
+
host.label,
|
|
149
|
+
host.command,
|
|
150
|
+
host.args,
|
|
151
|
+
host.connection_type,
|
|
152
|
+
host.script_path,
|
|
153
|
+
secrets,
|
|
154
|
+
login_steps,
|
|
155
|
+
ready_expect,
|
|
156
|
+
transport,
|
|
157
|
+
)
|
|
140
158
|
|
|
141
159
|
if "ssh" in item:
|
|
142
|
-
return HostConfig.command_line(
|
|
160
|
+
return HostConfig.command_line(
|
|
161
|
+
host_id,
|
|
162
|
+
label,
|
|
163
|
+
"ssh",
|
|
164
|
+
_parse_ssh_args(item["ssh"], path, index),
|
|
165
|
+
connection_type="ssh",
|
|
166
|
+
secrets=secrets,
|
|
167
|
+
login_steps=login_steps,
|
|
168
|
+
ready_expect=ready_expect,
|
|
169
|
+
transport=transport,
|
|
170
|
+
)
|
|
143
171
|
|
|
144
172
|
command = str(item.get("command", "")).strip()
|
|
145
173
|
args = item.get("args", [])
|
|
@@ -147,7 +175,16 @@ def _parse_host(item: object, path: Path, index: int) -> HostConfig:
|
|
|
147
175
|
raise ValueError(f"{path} hosts[{index}].command must not be empty")
|
|
148
176
|
if not isinstance(args, list) or not all(isinstance(arg, str) for arg in args):
|
|
149
177
|
raise ValueError(f"{path} hosts[{index}].args must be a list of strings")
|
|
150
|
-
return HostConfig.command_line(
|
|
178
|
+
return HostConfig.command_line(
|
|
179
|
+
host_id,
|
|
180
|
+
label,
|
|
181
|
+
command,
|
|
182
|
+
args,
|
|
183
|
+
secrets=secrets,
|
|
184
|
+
login_steps=login_steps,
|
|
185
|
+
ready_expect=ready_expect,
|
|
186
|
+
transport=transport,
|
|
187
|
+
)
|
|
151
188
|
|
|
152
189
|
|
|
153
190
|
def _parse_ready_expect(raw_ready_expect: object, path: Path, index: int) -> str | None:
|
|
@@ -196,7 +233,15 @@ def _parse_ssh_args(raw_ssh: object, path: Path, index: int) -> list[str]:
|
|
|
196
233
|
args: list[str] = []
|
|
197
234
|
port = raw_ssh.get("port")
|
|
198
235
|
if port not in (None, ""):
|
|
199
|
-
|
|
236
|
+
if isinstance(port, bool) or not isinstance(port, int | str):
|
|
237
|
+
raise ValueError(f"{path} hosts[{index}].ssh.port must be an integer")
|
|
238
|
+
try:
|
|
239
|
+
parsed_port = int(port)
|
|
240
|
+
except ValueError as exc:
|
|
241
|
+
raise ValueError(f"{path} hosts[{index}].ssh.port must be an integer") from exc
|
|
242
|
+
if not 1 <= parsed_port <= 65535:
|
|
243
|
+
raise ValueError(f"{path} hosts[{index}].ssh.port must be between 1 and 65535")
|
|
244
|
+
args.extend(["-p", str(parsed_port)])
|
|
200
245
|
identity_file = str(raw_ssh.get("identity_file", "")).strip()
|
|
201
246
|
if identity_file:
|
|
202
247
|
args.extend(["-i", str(Path(identity_file).expanduser())])
|