@neoline/hostbridge 1.4.4 → 2.0.0
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 +4 -5
- package/src/server_control_mcp/cli.py +229 -1231
- package/src/server_control_mcp/client.py +468 -0
- package/src/server_control_mcp/config.py +139 -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 +177 -87
- 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/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 +405 -0
- package/src/server_control_mcp/transports/ssh.py +195 -0
- package/src/server_control_mcp/manager.py +0 -925
|
@@ -1,1268 +1,266 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
|
-
import copy
|
|
5
|
-
import getpass
|
|
6
4
|
import json
|
|
7
5
|
import os
|
|
8
|
-
import
|
|
9
|
-
import select
|
|
10
|
-
import shlex
|
|
6
|
+
import shutil
|
|
11
7
|
import sys
|
|
12
|
-
import
|
|
13
|
-
import
|
|
14
|
-
from collections.abc import Callable, Iterable
|
|
15
|
-
from contextlib import suppress
|
|
16
|
-
from dataclasses import dataclass
|
|
8
|
+
import tempfile
|
|
9
|
+
from dataclasses import asdict
|
|
17
10
|
from pathlib import Path
|
|
18
11
|
|
|
19
|
-
import
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .client import HostBridgeClient, HostBridgeError
|
|
14
|
+
from .config import SCHEMA_VERSION, load_v1_config, migrate_config, validate_v1_config
|
|
15
|
+
from .doctor import collect_diagnostics
|
|
16
|
+
from .hosts import DEFAULT_HOSTS_FILE
|
|
20
17
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
SETUP_COMMANDS = {"setup", "migrate", "doctor", "hosts", "daemon", "mock-ssh", "version"}
|
|
19
|
+
EXIT_OK = 0
|
|
20
|
+
EXIT_USAGE = 2
|
|
21
|
+
EXIT_UNAVAILABLE = 3
|
|
22
|
+
EXIT_DENIED = 4
|
|
23
|
+
EXIT_REMOTE = 5
|
|
24
24
|
|
|
25
|
-
DEFAULT_VERIFY_TIMEOUT = 30
|
|
26
|
-
DEFAULT_CODEX_CONFIG = Path("~/.codex/config.toml").expanduser()
|
|
27
|
-
PUBLIC_NAME = "hostbridge"
|
|
28
|
-
LEGACY_PUBLIC_NAME = "server-control"
|
|
29
|
-
LEGACY_PACKAGE_NAME = "server-control-mcp"
|
|
30
|
-
HOSTBRIDGE_GUIDANCE_START = "<!-- HOSTBRIDGE_START -->"
|
|
31
|
-
HOSTBRIDGE_GUIDANCE_END = "<!-- HOSTBRIDGE_END -->"
|
|
32
|
-
HOSTBRIDGE_AGENT_GUIDANCE = f"""{HOSTBRIDGE_GUIDANCE_START}
|
|
33
|
-
## HostBridge
|
|
34
25
|
|
|
35
|
-
|
|
26
|
+
def _config_path(value: str | None) -> Path:
|
|
27
|
+
return Path(value).expanduser() if value else DEFAULT_HOSTS_FILE
|
|
36
28
|
|
|
37
|
-
- First call `hosts_list` to see configured hosts; then use `session_open("<host_id>")`.
|
|
38
|
-
- If the host is missing, ask only for the missing connection details and run `hostbridge setup <host_id>` instead of editing JSON by hand.
|
|
39
|
-
- Do not run `hostbridge check` before every connection; only use `hostbridge check <host_id>` when `session_open` times out or fails.
|
|
40
|
-
- Do not search shell history, SSH config, or old scripts unless the setup flow lacks required information or the user explicitly asks.
|
|
41
|
-
- Keep long-running work in `task_start`/`task_status`; use `session_close` or `sessions_close_all` when done.
|
|
42
|
-
- Use `file_write_text`/`file_read_text` for UTF-8 files and `file_upload`/`file_download` for binary artifacts up to 100MiB; these tools stream through the already-open shell and work across JumpServer/menu/sudo flows where SFTP may not exist.
|
|
43
|
-
- For files larger than 100MiB, do not use HostBridge file tools; start a remote pull instead (`curl`, `wget`, `git clone`, `modelscope download`, etc.) and poll it with `task_status`.
|
|
44
|
-
- Do not ask the user to provide base64 content. Give file tools local/remote paths; HostBridge handles chunking, encoding, hashing, and cleanup internally.
|
|
45
|
-
{HOSTBRIDGE_GUIDANCE_END}
|
|
46
|
-
"""
|
|
47
|
-
SUPPORTED_INSTALL_TARGETS = (
|
|
48
|
-
"all",
|
|
49
|
-
"codex",
|
|
50
|
-
"claude-code",
|
|
51
|
-
"cursor",
|
|
52
|
-
"gemini",
|
|
53
|
-
"opencode",
|
|
54
|
-
"hermes",
|
|
55
|
-
"antigravity",
|
|
56
|
-
"kiro",
|
|
57
|
-
)
|
|
58
|
-
SETUP_COMMANDS = {"install", "setup", "check", "list", "remove", "reload", "secret", "step", "daemon", "mock-ssh"}
|
|
59
|
-
AskFunc = Callable[[str], str]
|
|
60
|
-
SecretAskFunc = Callable[[str], str]
|
|
61
|
-
VerifyFunc = Callable[[dict[str, object], int], "VerificationResult"]
|
|
62
|
-
SpawnFunc = Callable[[dict[str, object], int], object]
|
|
63
|
-
SetupProbeFunc = Callable[[dict[str, object], int, AskFunc, SecretAskFunc], "VerificationResult"]
|
|
64
29
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
class VerificationResult:
|
|
68
|
-
ok: bool
|
|
69
|
-
message: str
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
@dataclass(frozen=True, slots=True)
|
|
73
|
-
class RecorderAction:
|
|
74
|
-
kind: str
|
|
75
|
-
text: str = ""
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
class InteractiveLoginRecorder:
|
|
79
|
-
"""Record prompt-timed user inputs into replayable login_steps."""
|
|
80
|
-
|
|
81
|
-
def __init__(self, entry: dict[str, object]) -> None:
|
|
82
|
-
self._entry = entry
|
|
83
|
-
self._output = ""
|
|
84
|
-
self._steps: list[dict[str, str]] = []
|
|
85
|
-
self._secrets: dict[str, object] = {}
|
|
86
|
-
self._secret_index = 1
|
|
87
|
-
|
|
88
|
-
def observe(self, text: str) -> None:
|
|
89
|
-
self._output = (self._output + text)[-20000:]
|
|
90
|
-
|
|
91
|
-
def current_prompt_is_secret(self) -> bool:
|
|
92
|
-
return _looks_secret_prompt(self._current_prompt_text())
|
|
93
|
-
|
|
94
|
-
def finish_line(self, line: str) -> RecorderAction:
|
|
95
|
-
line = line.rstrip("\r\n")
|
|
96
|
-
if line == "/abort":
|
|
97
|
-
return RecorderAction("abort")
|
|
98
|
-
if line == "/ready":
|
|
99
|
-
self._entry["ready_expect"] = _prompt_regex_from_output(self._current_prompt_text())
|
|
100
|
-
return RecorderAction("ready")
|
|
101
|
-
|
|
102
|
-
expect = _prompt_regex_from_output(self._current_prompt_text())
|
|
103
|
-
if self.current_prompt_is_secret():
|
|
104
|
-
secret_name = f"password_{self._secret_index}"
|
|
105
|
-
self._secret_index += 1
|
|
106
|
-
self._secrets[secret_name] = encrypt_secret(line)
|
|
107
|
-
self._steps.append({"expect": expect, "send_secret": secret_name})
|
|
108
|
-
else:
|
|
109
|
-
self._steps.append({"expect": expect, "send": line})
|
|
110
|
-
self._output = ""
|
|
111
|
-
return RecorderAction("send", line)
|
|
112
|
-
|
|
113
|
-
def apply_to_entry(self) -> None:
|
|
114
|
-
if self._steps:
|
|
115
|
-
self._entry["login_steps"] = self._steps
|
|
116
|
-
if self._secrets:
|
|
117
|
-
self._entry["secrets"] = self._secrets
|
|
118
|
-
|
|
119
|
-
def _current_prompt_text(self) -> str:
|
|
120
|
-
return self._output
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
class RealTerminalIO:
|
|
124
|
-
def __init__(self, stdin: object = sys.stdin, stdout: object = sys.stdout, child_fd: int | None = None) -> None:
|
|
125
|
-
self._stdin = stdin
|
|
126
|
-
self._stdout = stdout
|
|
127
|
-
self._stdin_fd = stdin.fileno() # type: ignore[attr-defined]
|
|
128
|
-
if child_fd is None:
|
|
129
|
-
raise ValueError("child_fd is required")
|
|
130
|
-
self._child_fd = child_fd
|
|
131
|
-
self._stdout_buffer = getattr(stdout, "buffer", stdout)
|
|
132
|
-
self._old_attrs = None
|
|
133
|
-
|
|
134
|
-
def start(self) -> None:
|
|
135
|
-
if hasattr(self._stdin, "isatty") and self._stdin.isatty(): # type: ignore[attr-defined]
|
|
136
|
-
self._old_attrs = termios.tcgetattr(self._stdin_fd)
|
|
137
|
-
tty.setraw(self._stdin_fd)
|
|
138
|
-
|
|
139
|
-
def read_event(self) -> tuple[str, bytes]:
|
|
140
|
-
while True:
|
|
141
|
-
readable, _, _ = select.select([self._stdin_fd, self._child_fd], [], [], 0.1)
|
|
142
|
-
if self._child_fd in readable:
|
|
143
|
-
return "child", os.read(self._child_fd, 4096)
|
|
144
|
-
if self._stdin_fd in readable:
|
|
145
|
-
return "stdin", os.read(self._stdin_fd, 4096)
|
|
146
|
-
|
|
147
|
-
def write_stdout(self, data: bytes) -> None:
|
|
148
|
-
self._stdout_buffer.write(data)
|
|
149
|
-
self._stdout_buffer.flush()
|
|
150
|
-
|
|
151
|
-
def close(self) -> None:
|
|
152
|
-
if self._old_attrs is not None:
|
|
153
|
-
termios.tcsetattr(self._stdin_fd, termios.TCSADRAIN, self._old_attrs)
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
def build_host_entry(host_id: str, label: str, command_text: str) -> dict[str, object]:
|
|
157
|
-
"""Convert a user-entered connection command into a hosts.json entry."""
|
|
158
|
-
|
|
159
|
-
host_id = host_id.strip()
|
|
160
|
-
label = label.strip() or host_id
|
|
161
|
-
if not HOST_ID_PATTERN.fullmatch(host_id):
|
|
162
|
-
raise ValueError("host id may contain only letters, numbers, '_', '-', and '.'")
|
|
163
|
-
parts = shlex.split(command_text.strip())
|
|
164
|
-
if not parts:
|
|
165
|
-
raise ValueError("command must not be empty")
|
|
166
|
-
|
|
167
|
-
entry: dict[str, object] = {"id": host_id, "label": label}
|
|
168
|
-
command = parts[0]
|
|
169
|
-
args = parts[1:]
|
|
170
|
-
if command == "ssh":
|
|
171
|
-
entry["ssh"] = _parse_ssh_command(args)
|
|
172
|
-
elif _looks_like_shell_script(command, args):
|
|
173
|
-
entry["script"] = args[0]
|
|
174
|
-
else:
|
|
175
|
-
entry["command"] = command
|
|
176
|
-
entry["args"] = args
|
|
177
|
-
return entry
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
def _parse_ssh_command(args: list[str]) -> dict[str, object]:
|
|
181
|
-
ssh: dict[str, object] = {}
|
|
182
|
-
extra_args: list[str] = []
|
|
183
|
-
targets: list[str] = []
|
|
184
|
-
index = 0
|
|
185
|
-
while index < len(args):
|
|
186
|
-
arg = args[index]
|
|
187
|
-
if arg in ("-J", "-p", "-i", "-o"):
|
|
188
|
-
if index + 1 >= len(args):
|
|
189
|
-
raise ValueError(f"ssh option {arg} requires a value")
|
|
190
|
-
value = args[index + 1]
|
|
191
|
-
if arg == "-J":
|
|
192
|
-
ssh["proxy_jump"] = value
|
|
193
|
-
elif arg == "-p":
|
|
194
|
-
ssh["port"] = int(value)
|
|
195
|
-
elif arg == "-i":
|
|
196
|
-
ssh["identity_file"] = value
|
|
197
|
-
else:
|
|
198
|
-
extra_args.extend([arg, value])
|
|
199
|
-
index += 2
|
|
200
|
-
continue
|
|
201
|
-
if arg.startswith("-J") and len(arg) > 2:
|
|
202
|
-
ssh["proxy_jump"] = arg[2:]
|
|
203
|
-
elif arg.startswith("-p") and len(arg) > 2:
|
|
204
|
-
ssh["port"] = int(arg[2:])
|
|
205
|
-
elif arg.startswith("-i") and len(arg) > 2:
|
|
206
|
-
ssh["identity_file"] = arg[2:]
|
|
207
|
-
elif arg.startswith("-"):
|
|
208
|
-
extra_args.append(arg)
|
|
209
|
-
else:
|
|
210
|
-
targets.append(arg)
|
|
211
|
-
index += 1
|
|
212
|
-
|
|
213
|
-
if not targets:
|
|
214
|
-
raise ValueError("ssh command must include a target host")
|
|
215
|
-
ssh["host"] = targets[-1]
|
|
216
|
-
if extra_args:
|
|
217
|
-
ssh["extra_args"] = extra_args
|
|
218
|
-
return _ordered_ssh_config(ssh)
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
def _ordered_ssh_config(ssh: dict[str, object]) -> dict[str, object]:
|
|
222
|
-
ordered: dict[str, object] = {"host": ssh["host"]}
|
|
223
|
-
for key in ("proxy_jump", "port", "identity_file", "extra_args"):
|
|
224
|
-
if key in ssh:
|
|
225
|
-
ordered[key] = ssh[key]
|
|
226
|
-
return ordered
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
def _looks_like_shell_script(command: str, args: list[str]) -> bool:
|
|
230
|
-
shell_names = {"sh", "bash", "zsh", "/bin/sh", "/bin/bash", "/bin/zsh"}
|
|
231
|
-
return command in shell_names and len(args) == 1 and args[0].endswith((".sh", ".bash", ".zsh"))
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
def _default_mcp_command_args() -> tuple[str, list[str]]:
|
|
235
|
-
env_command = os.environ.get("HOSTBRIDGE_DEFAULT_MCP_COMMAND")
|
|
236
|
-
if env_command:
|
|
237
|
-
raw_args = os.environ.get("HOSTBRIDGE_DEFAULT_MCP_ARGS", "[]")
|
|
238
|
-
try:
|
|
239
|
-
parsed_args = json.loads(raw_args)
|
|
240
|
-
except json.JSONDecodeError:
|
|
241
|
-
parsed_args = []
|
|
242
|
-
if not isinstance(parsed_args, list) or not all(isinstance(arg, str) for arg in parsed_args):
|
|
243
|
-
parsed_args = []
|
|
244
|
-
return env_command, parsed_args
|
|
245
|
-
|
|
246
|
-
repo_root = Path(__file__).resolve().parents[2]
|
|
247
|
-
run_server = repo_root / "run_server.py"
|
|
248
|
-
if run_server.exists():
|
|
249
|
-
return "python3", [str(run_server)]
|
|
250
|
-
command = Path(sys.argv[0]).name or PUBLIC_NAME
|
|
251
|
-
if command in (LEGACY_PUBLIC_NAME, LEGACY_PACKAGE_NAME, "__main__.py"):
|
|
252
|
-
command = PUBLIC_NAME
|
|
253
|
-
return command, []
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
def _resolve_hosts_config_path(config_path: Path | str | None) -> Path:
|
|
257
|
-
if config_path is None:
|
|
258
|
-
configured = os.environ.get("HOSTBRIDGE_HOSTS_FILE") or os.environ.get("SERVER_CONTROL_MCP_HOSTS_FILE")
|
|
259
|
-
if configured:
|
|
260
|
-
return Path(configured).expanduser()
|
|
261
|
-
if DEFAULT_HOSTS_FILE.exists() or not LEGACY_HOSTS_FILE.exists():
|
|
262
|
-
return DEFAULT_HOSTS_FILE
|
|
263
|
-
return LEGACY_HOSTS_FILE
|
|
264
|
-
return Path(config_path).expanduser()
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
def _toml_quote(value: str) -> str:
|
|
268
|
-
return json.dumps(value, ensure_ascii=False)
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
def _toml_array(values: list[str]) -> str:
|
|
272
|
-
return "[" + ", ".join(_toml_quote(value) for value in values) + "]"
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
def _server_control_block(command: str, args: list[str], *, agent_id: str) -> str:
|
|
276
|
-
return "\n".join([
|
|
277
|
-
f"[mcp_servers.{PUBLIC_NAME}]",
|
|
278
|
-
f"command = {_toml_quote(command)}",
|
|
279
|
-
f"args = {_toml_array(args)}",
|
|
280
|
-
f"[mcp_servers.{PUBLIC_NAME}.env]",
|
|
281
|
-
f"HOSTBRIDGE_AGENT_ID = {_toml_quote(agent_id)}",
|
|
282
|
-
"",
|
|
283
|
-
])
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
def _replace_toml_table(text: str, table_header: str, block: str) -> str:
|
|
287
|
-
lines = text.splitlines()
|
|
288
|
-
start = None
|
|
289
|
-
for index, line in enumerate(lines):
|
|
290
|
-
if line.strip() == table_header:
|
|
291
|
-
start = index
|
|
292
|
-
break
|
|
293
|
-
if start is None:
|
|
294
|
-
prefix = text.rstrip()
|
|
295
|
-
return f"{prefix}\n\n{block}" if prefix else block
|
|
296
|
-
|
|
297
|
-
end = len(lines)
|
|
298
|
-
for index in range(start + 1, len(lines)):
|
|
299
|
-
stripped = lines[index].strip()
|
|
300
|
-
if stripped.startswith("[") and stripped.endswith("]"):
|
|
301
|
-
end = index
|
|
302
|
-
break
|
|
303
|
-
new_lines = lines[:start] + block.rstrip().splitlines() + lines[end:]
|
|
304
|
-
return "\n".join(new_lines).rstrip() + "\n"
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
def _remove_toml_table(text: str, table_header: str) -> str:
|
|
308
|
-
lines = text.splitlines()
|
|
309
|
-
start = None
|
|
310
|
-
for index, line in enumerate(lines):
|
|
311
|
-
if line.strip() == table_header:
|
|
312
|
-
start = index
|
|
313
|
-
break
|
|
314
|
-
if start is None:
|
|
315
|
-
return text
|
|
316
|
-
end = len(lines)
|
|
317
|
-
for index in range(start + 1, len(lines)):
|
|
318
|
-
stripped = lines[index].strip()
|
|
319
|
-
if stripped.startswith("[") and stripped.endswith("]"):
|
|
320
|
-
end = index
|
|
321
|
-
break
|
|
322
|
-
return "\n".join(lines[:start] + lines[end:]).rstrip() + ("\n" if lines[:start] + lines[end:] else "")
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
def _read_json_object(path: Path) -> dict[str, object]:
|
|
326
|
-
if not path.exists():
|
|
327
|
-
return {}
|
|
328
|
-
data = json.loads(path.read_text(encoding="utf-8"))
|
|
329
|
-
if not isinstance(data, dict):
|
|
330
|
-
raise ValueError(f"{path} must contain a JSON object")
|
|
331
|
-
return data
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
def _write_json_object(path: Path, data: dict[str, object]) -> None:
|
|
30
|
+
def _write_config(path: Path, payload: dict[str, object]) -> None:
|
|
31
|
+
validated = validate_v1_config(payload, source=path)
|
|
335
32
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
def _replace_managed_block(text: str, start: str, end: str, block: str) -> str:
|
|
340
|
-
start_index = text.find(start)
|
|
341
|
-
end_index = text.find(end)
|
|
342
|
-
if start_index != -1 and end_index != -1 and end_index > start_index:
|
|
343
|
-
end_index += len(end)
|
|
344
|
-
return (text[:start_index].rstrip() + "\n\n" + block.strip() + "\n" + text[end_index:].lstrip()).rstrip() + "\n"
|
|
345
|
-
prefix = text.rstrip()
|
|
346
|
-
return (prefix + "\n\n" + block.strip() + "\n") if prefix else (block.strip() + "\n")
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
def _install_guidance_file(path: Path) -> None:
|
|
350
|
-
existing = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
351
|
-
updated = _replace_managed_block(existing, HOSTBRIDGE_GUIDANCE_START, HOSTBRIDGE_GUIDANCE_END, HOSTBRIDGE_AGENT_GUIDANCE)
|
|
352
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
353
|
-
path.write_text(updated, encoding="utf-8")
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
def _remove_legacy_mcp_server_keys(value: object) -> None:
|
|
357
|
-
if isinstance(value, dict):
|
|
358
|
-
servers = value.get("mcpServers")
|
|
359
|
-
if isinstance(servers, dict):
|
|
360
|
-
servers.pop(LEGACY_PUBLIC_NAME, None)
|
|
361
|
-
servers.pop(LEGACY_PACKAGE_NAME, None)
|
|
362
|
-
for child in value.values():
|
|
363
|
-
_remove_legacy_mcp_server_keys(child)
|
|
364
|
-
elif isinstance(value, list):
|
|
365
|
-
for child in value:
|
|
366
|
-
_remove_legacy_mcp_server_keys(child)
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
def _install_codex_config(config_path: Path, command: str, args: list[str], *, agent_id: str) -> None:
|
|
370
|
-
block = _server_control_block(command, args, agent_id=agent_id)
|
|
371
|
-
existing = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
|
|
372
|
-
updated = _remove_toml_table(existing, f"[mcp_servers.{LEGACY_PUBLIC_NAME}]")
|
|
373
|
-
updated = _remove_toml_table(updated, f"[mcp_servers.{LEGACY_PACKAGE_NAME}]")
|
|
374
|
-
updated = _remove_toml_table(updated, f"[mcp_servers.{PUBLIC_NAME}.env]")
|
|
375
|
-
updated = _replace_toml_table(updated, f"[mcp_servers.{PUBLIC_NAME}]", block)
|
|
376
|
-
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
377
|
-
config_path.write_text(updated, encoding="utf-8")
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
def _install_mcp_servers_json(config_path: Path, command: str, args: list[str], *, agent_id: str) -> None:
|
|
381
|
-
data = _read_json_object(config_path)
|
|
382
|
-
_remove_legacy_mcp_server_keys(data)
|
|
383
|
-
servers = data.setdefault("mcpServers", {})
|
|
384
|
-
if not isinstance(servers, dict):
|
|
385
|
-
raise ValueError(f"{config_path} mcpServers must be an object")
|
|
386
|
-
servers[PUBLIC_NAME] = {"command": command, "args": args, "env": {"HOSTBRIDGE_AGENT_ID": agent_id}}
|
|
387
|
-
_write_json_object(config_path, data)
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
def _install_opencode_config(config_path: Path, command: str, args: list[str], *, agent_id: str) -> None:
|
|
391
|
-
data = _read_json_object(config_path)
|
|
392
|
-
servers = data.setdefault("mcp", {})
|
|
393
|
-
if not isinstance(servers, dict):
|
|
394
|
-
raise ValueError(f"{config_path} mcp must be an object")
|
|
395
|
-
servers.pop(LEGACY_PUBLIC_NAME, None)
|
|
396
|
-
servers.pop(LEGACY_PACKAGE_NAME, None)
|
|
397
|
-
servers[PUBLIC_NAME] = {
|
|
398
|
-
"type": "local",
|
|
399
|
-
"command": [command, *args],
|
|
400
|
-
"enabled": True,
|
|
401
|
-
"env": {"HOSTBRIDGE_AGENT_ID": agent_id},
|
|
402
|
-
}
|
|
403
|
-
_write_json_object(config_path, data)
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
@dataclass(frozen=True, slots=True)
|
|
407
|
-
class AgentInstallSpec:
|
|
408
|
-
target: str
|
|
409
|
-
label: str
|
|
410
|
-
path: Path
|
|
411
|
-
kind: str
|
|
412
|
-
detect_path: Path
|
|
413
|
-
guidance_path: Path | None = None
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
def _agent_install_specs(home: Path) -> list[AgentInstallSpec]:
|
|
417
|
-
return [
|
|
418
|
-
AgentInstallSpec("codex", "Codex CLI", home / ".codex" / "config.toml", "codex_toml", home / ".codex", home / ".codex" / "AGENTS.md"),
|
|
419
|
-
AgentInstallSpec("claude-code", "Claude Code", home / ".claude.json", "mcp_json", home / ".claude", home / ".claude" / "CLAUDE.md"),
|
|
420
|
-
AgentInstallSpec("cursor", "Cursor", home / ".cursor" / "mcp.json", "mcp_json", home / ".cursor", home / ".cursor" / "rules" / "hostbridge.mdc"),
|
|
421
|
-
AgentInstallSpec("gemini", "Gemini CLI", home / ".gemini" / "settings.json", "mcp_json", home / ".gemini", home / ".gemini" / "GEMINI.md"),
|
|
422
|
-
AgentInstallSpec("opencode", "opencode", home / ".config" / "opencode" / "opencode.json", "opencode_json", home / ".config" / "opencode", home / ".config" / "opencode" / "AGENTS.md"),
|
|
423
|
-
AgentInstallSpec("hermes", "Hermes Agent", home / ".hermes" / "mcp.json", "mcp_json", home / ".hermes", home / ".hermes" / "AGENTS.md"),
|
|
424
|
-
AgentInstallSpec("antigravity", "Antigravity IDE", home / ".antigravity" / "mcp.json", "mcp_json", home / ".antigravity", home / ".antigravity" / "AGENTS.md"),
|
|
425
|
-
AgentInstallSpec("kiro", "Kiro", home / ".kiro" / "settings" / "mcp.json", "mcp_json", home / ".kiro", home / ".kiro" / "steering" / "hostbridge.md"),
|
|
426
|
-
]
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
def _install_spec(spec: AgentInstallSpec, command: str, args: list[str]) -> None:
|
|
430
|
-
if spec.kind == "codex_toml":
|
|
431
|
-
_install_codex_config(spec.path, command, args, agent_id=spec.target)
|
|
432
|
-
elif spec.kind == "opencode_json":
|
|
433
|
-
_install_opencode_config(spec.path, command, args, agent_id=spec.target)
|
|
434
|
-
else:
|
|
435
|
-
_install_mcp_servers_json(spec.path, command, args, agent_id=spec.target)
|
|
436
|
-
if spec.guidance_path is not None:
|
|
437
|
-
_install_guidance_file(spec.guidance_path)
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
def run_install(
|
|
441
|
-
*,
|
|
442
|
-
target: str = "all",
|
|
443
|
-
config_path: Path | str | None = None,
|
|
444
|
-
command: str | None = None,
|
|
445
|
-
args: list[str] | None = None,
|
|
446
|
-
home_path: Path | str | None = None,
|
|
447
|
-
) -> int:
|
|
448
|
-
if target not in SUPPORTED_INSTALL_TARGETS:
|
|
449
|
-
print(f"Install target '{target}' is not supported; supported targets: {', '.join(SUPPORTED_INSTALL_TARGETS)}")
|
|
450
|
-
return 1
|
|
451
|
-
resolved_command, resolved_args = (command, args or []) if command else _default_mcp_command_args()
|
|
452
|
-
|
|
453
|
-
if target == "all":
|
|
454
|
-
home = Path(home_path or Path.home()).expanduser()
|
|
455
|
-
specs = [spec for spec in _agent_install_specs(home) if spec.detect_path.exists() or spec.path.exists()]
|
|
456
|
-
if not specs:
|
|
457
|
-
print(f"No supported agent configs detected under {home}")
|
|
458
|
-
return 1
|
|
459
|
-
for spec in specs:
|
|
460
|
-
_install_spec(spec, resolved_command, resolved_args)
|
|
461
|
-
print(f"Registered {PUBLIC_NAME} MCP for {spec.label} in {spec.path}")
|
|
462
|
-
print(f"Installed {PUBLIC_NAME} MCP into {len(specs)} agent config(s)")
|
|
463
|
-
print(f"Command: {resolved_command} {' '.join(resolved_args)}".rstrip())
|
|
464
|
-
print("Restart open agent clients or start new sessions if this MCP was not already loaded.")
|
|
465
|
-
return 0
|
|
466
|
-
|
|
467
|
-
if target == "codex":
|
|
468
|
-
resolved_config = Path(config_path or DEFAULT_CODEX_CONFIG).expanduser()
|
|
469
|
-
_install_codex_config(resolved_config, resolved_command, resolved_args, agent_id="codex")
|
|
470
|
-
label = "Codex"
|
|
471
|
-
else:
|
|
472
|
-
home = Path(home_path or Path.home()).expanduser()
|
|
473
|
-
spec_by_target = {spec.target: spec for spec in _agent_install_specs(home)}
|
|
474
|
-
spec = spec_by_target[target]
|
|
475
|
-
resolved_config = Path(config_path).expanduser() if config_path else spec.path
|
|
476
|
-
spec = AgentInstallSpec(spec.target, spec.label, resolved_config, spec.kind, spec.detect_path, spec.guidance_path)
|
|
477
|
-
_install_spec(spec, resolved_command, resolved_args)
|
|
478
|
-
label = spec.label
|
|
479
|
-
|
|
480
|
-
print(f"Registered {PUBLIC_NAME} MCP for {label} in {resolved_config}")
|
|
481
|
-
print(f"Command: {resolved_command} {' '.join(resolved_args)}".rstrip())
|
|
482
|
-
print("Restart the agent client or start a new session if this MCP was not already loaded.")
|
|
483
|
-
return 0
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
def upsert_host_entry(config_path: Path | str, entry: dict[str, object], *, replace: bool) -> str:
|
|
487
|
-
path = _resolve_hosts_config_path(config_path)
|
|
488
|
-
config = _read_config(path)
|
|
489
|
-
hosts = config.setdefault("hosts", [])
|
|
490
|
-
if not isinstance(hosts, list):
|
|
491
|
-
raise ValueError(f"{path} must contain a top-level 'hosts' list")
|
|
492
|
-
|
|
493
|
-
host_id = str(entry["id"])
|
|
494
|
-
for index, existing in enumerate(hosts):
|
|
495
|
-
if isinstance(existing, dict) and existing.get("id") == host_id:
|
|
496
|
-
if not replace:
|
|
497
|
-
raise ValueError(f"host '{host_id}' already exists")
|
|
498
|
-
hosts[index] = entry
|
|
499
|
-
_write_config(path, config)
|
|
500
|
-
return "replaced"
|
|
501
|
-
|
|
502
|
-
hosts.append(entry)
|
|
503
|
-
_write_config(path, config)
|
|
504
|
-
return "added"
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
def remove_host_entry(config_path: Path | str, host_id: str) -> bool:
|
|
508
|
-
path = _resolve_hosts_config_path(config_path)
|
|
509
|
-
config = _read_config(path)
|
|
510
|
-
hosts = config.setdefault("hosts", [])
|
|
511
|
-
if not isinstance(hosts, list):
|
|
512
|
-
raise ValueError(f"{path} must contain a top-level 'hosts' list")
|
|
513
|
-
new_hosts = [host for host in hosts if not (isinstance(host, dict) and host.get("id") == host_id)]
|
|
514
|
-
if len(new_hosts) == len(hosts):
|
|
515
|
-
return False
|
|
516
|
-
config["hosts"] = new_hosts
|
|
517
|
-
_write_config(path, config)
|
|
518
|
-
return True
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
def _yes(answer: str) -> bool:
|
|
524
|
-
return answer.strip().lower() in ("y", "yes")
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
def configure_login_automation(
|
|
528
|
-
entry: dict[str, object],
|
|
529
|
-
*,
|
|
530
|
-
ask: AskFunc = input,
|
|
531
|
-
secret_ask: SecretAskFunc = getpass.getpass,
|
|
532
|
-
) -> None:
|
|
533
|
-
"""Add optional encrypted pre-login automation to a host entry."""
|
|
534
|
-
|
|
535
|
-
print("\nRuntime login automation")
|
|
536
|
-
print("Use this when the MCP process must answer SSH or jump-host prompts before a shell is ready.")
|
|
537
|
-
if not _yes(ask("Add encrypted login automation? [y/N]: ")):
|
|
538
|
-
return
|
|
539
|
-
|
|
540
|
-
secrets: dict[str, object] = {}
|
|
541
|
-
steps: list[dict[str, str]] = []
|
|
542
|
-
|
|
543
|
-
if _yes(ask("Store an encrypted password/passphrase for a prompt? [y/N]: ")):
|
|
544
|
-
value = secret_ask("Password/passphrase to encrypt locally: ")
|
|
545
|
-
secrets["password"] = encrypt_secret(value)
|
|
546
|
-
password_prompt_raw = ask("Password prompt regex [(?i)password:]: ")
|
|
547
|
-
password_prompt = password_prompt_raw if password_prompt_raw.strip() else "(?i)password:"
|
|
548
|
-
steps.append({"expect": password_prompt, "send_secret": "password"})
|
|
549
|
-
|
|
550
|
-
if _yes(ask("Add a jump-host/menu selection step? [y/N]: ")):
|
|
551
|
-
menu_prompt_raw = ask("Menu prompt regex [Opt>]: ")
|
|
552
|
-
menu_prompt = menu_prompt_raw if menu_prompt_raw.strip() else "Opt>"
|
|
553
|
-
selector = ask("Menu selection to send (host/IP/name): ").strip()
|
|
554
|
-
if not selector:
|
|
555
|
-
raise ValueError("menu selection must not be empty")
|
|
556
|
-
steps.append({"expect": menu_prompt, "send": selector})
|
|
557
|
-
|
|
558
|
-
final_prompt_raw = ask("Remote shell prompt regex before readiness probe [skip]: ")
|
|
559
|
-
final_prompt = final_prompt_raw if final_prompt_raw.strip() else ""
|
|
560
|
-
if final_prompt:
|
|
561
|
-
steps.append({"expect": final_prompt, "send": ""})
|
|
562
|
-
|
|
563
|
-
if not steps:
|
|
564
|
-
raise ValueError("login automation enabled but no steps were configured")
|
|
565
|
-
if secrets:
|
|
566
|
-
entry["secrets"] = secrets
|
|
567
|
-
entry["login_steps"] = steps
|
|
568
|
-
|
|
569
|
-
def set_host_secret(config_path: Path | str, host_id: str, name: str, value: str) -> None:
|
|
570
|
-
if not name.strip():
|
|
571
|
-
raise ValueError("secret name must not be empty")
|
|
572
|
-
path = _resolve_hosts_config_path(config_path)
|
|
573
|
-
config = _read_config(path)
|
|
574
|
-
host = _require_raw_host_entry(config, path, host_id)
|
|
575
|
-
secrets = host.setdefault("secrets", {})
|
|
576
|
-
if not isinstance(secrets, dict):
|
|
577
|
-
raise ValueError(f"{path} host '{host_id}' secrets must be an object")
|
|
578
|
-
secrets[name] = encrypt_secret(value)
|
|
579
|
-
_write_config(path, config)
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
def add_login_step(
|
|
583
|
-
config_path: Path | str,
|
|
584
|
-
host_id: str,
|
|
585
|
-
*,
|
|
586
|
-
expect: str,
|
|
587
|
-
send: str | None = None,
|
|
588
|
-
send_secret: str | None = None,
|
|
589
|
-
) -> None:
|
|
590
|
-
expect = expect.strip()
|
|
591
|
-
if not expect:
|
|
592
|
-
raise ValueError("expect pattern must not be empty")
|
|
593
|
-
if (send is None) == (send_secret is None):
|
|
594
|
-
raise ValueError("login step must define exactly one of send or send_secret")
|
|
595
|
-
path = _resolve_hosts_config_path(config_path)
|
|
596
|
-
config = _read_config(path)
|
|
597
|
-
host = _require_raw_host_entry(config, path, host_id)
|
|
598
|
-
steps = host.setdefault("login_steps", [])
|
|
599
|
-
if not isinstance(steps, list):
|
|
600
|
-
raise ValueError(f"{path} host '{host_id}' login_steps must be a list")
|
|
601
|
-
step: dict[str, str] = {"expect": expect}
|
|
602
|
-
if send is not None:
|
|
603
|
-
step["send"] = send
|
|
604
|
-
else:
|
|
605
|
-
step["send_secret"] = str(send_secret)
|
|
606
|
-
steps.append(step)
|
|
607
|
-
_write_config(path, config)
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
def _require_raw_host_entry(config: dict[str, object], path: Path, host_id: str) -> dict[str, object]:
|
|
611
|
-
hosts = config.get("hosts", [])
|
|
612
|
-
if not isinstance(hosts, list):
|
|
613
|
-
raise ValueError(f"{path} must contain a top-level 'hosts' list")
|
|
614
|
-
for host in hosts:
|
|
615
|
-
if isinstance(host, dict) and host.get("id") == host_id:
|
|
616
|
-
return host
|
|
617
|
-
raise ValueError(f"host '{host_id}' not found in {path}")
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
def clone_jump_host_entry(
|
|
621
|
-
config_path: Path | str,
|
|
622
|
-
*,
|
|
623
|
-
source_host_id: str,
|
|
624
|
-
host_id: str,
|
|
625
|
-
label: str | None = None,
|
|
626
|
-
menu_selection: str,
|
|
627
|
-
menu_prompt: str = "Opt>",
|
|
628
|
-
final_prompt: str | None = None,
|
|
629
|
-
) -> dict[str, object]:
|
|
630
|
-
"""Create a new jump-host entry by reusing a known bastion path and secrets."""
|
|
631
|
-
|
|
632
|
-
if not menu_selection.strip():
|
|
633
|
-
raise ValueError("menu selection must not be empty")
|
|
634
|
-
path = _resolve_hosts_config_path(config_path)
|
|
635
|
-
source = _find_raw_host_entry(path, source_host_id)
|
|
636
|
-
if source is None:
|
|
637
|
-
raise ValueError(f"source host '{source_host_id}' not found in {path}")
|
|
638
|
-
|
|
639
|
-
entry: dict[str, object] = {
|
|
640
|
-
"id": host_id.strip(),
|
|
641
|
-
"label": (label or host_id).strip() or host_id.strip(),
|
|
642
|
-
}
|
|
643
|
-
if not HOST_ID_PATTERN.fullmatch(entry["id"]):
|
|
644
|
-
raise ValueError("host id may contain only letters, numbers, '_', '-', and '.'")
|
|
645
|
-
|
|
646
|
-
styles = [name for name in ("script", "ssh", "command") if name in source]
|
|
647
|
-
if len(styles) != 1:
|
|
648
|
-
raise ValueError(f"source host '{source_host_id}' must define exactly one connection style")
|
|
649
|
-
style = styles[0]
|
|
650
|
-
entry[style] = copy.deepcopy(source[style])
|
|
651
|
-
if style == "command" and "args" in source:
|
|
652
|
-
entry["args"] = copy.deepcopy(source["args"])
|
|
653
|
-
if isinstance(source.get("secrets"), dict):
|
|
654
|
-
entry["secrets"] = copy.deepcopy(source["secrets"])
|
|
655
|
-
|
|
656
|
-
steps: list[dict[str, str]] = []
|
|
657
|
-
raw_steps = source.get("login_steps", [])
|
|
658
|
-
if isinstance(raw_steps, list):
|
|
659
|
-
for raw_step in raw_steps:
|
|
660
|
-
if isinstance(raw_step, dict) and "send_secret" in raw_step:
|
|
661
|
-
steps.append(copy.deepcopy(raw_step))
|
|
662
|
-
steps.append({"expect": menu_prompt.strip() or "Opt>", "send": menu_selection.strip()})
|
|
663
|
-
if final_prompt and final_prompt.strip():
|
|
664
|
-
steps.append({"expect": final_prompt, "send": ""})
|
|
665
|
-
entry["login_steps"] = steps
|
|
666
|
-
return entry
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
def _spawn_entry(entry: dict[str, object], timeout: int) -> object:
|
|
670
|
-
command, args = _entry_to_command(entry)
|
|
671
|
-
return pexpect.spawn(command, args, encoding="utf-8", codec_errors="replace", timeout=timeout, echo=False)
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
def _prompt_regex_from_output(output: str) -> str:
|
|
675
|
-
lines = [line.strip() for line in output.replace("\r", "\n").split("\n") if line.strip()]
|
|
676
|
-
prompt = lines[-1] if lines else output.strip()
|
|
677
|
-
if not prompt:
|
|
678
|
-
prompt = ".+"
|
|
679
|
-
if _looks_shell_prompt(prompt) and _contains_terminal_control(prompt):
|
|
680
|
-
return r"(?m)[^\r\n]*[#$]\s*$"
|
|
681
|
-
return re.escape(prompt) + r"\s*$"
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
def _looks_secret_prompt(output: str) -> bool:
|
|
685
|
-
return re.search(r"(?i)(password|passphrase|token|otp|verification code|验证码|密码)", output) is not None
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
def _contains_terminal_control(text: str) -> bool:
|
|
689
|
-
return "\x1b" in text or "\x07" in text or "\\x1b" in text or "\\u001b" in text
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
def _looks_shell_prompt(text: str) -> bool:
|
|
693
|
-
return re.search(r"[#$](?:\\s\*)?$", text.strip()) is not None
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
def _runtime_expect_pattern(pattern: str) -> str:
|
|
697
|
-
if _contains_terminal_control(pattern) and _looks_shell_prompt(pattern):
|
|
698
|
-
return r"(?m)[^\r\n]*[#$]\s*$"
|
|
699
|
-
return pattern
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
def _read_setup_output(child: object, timeout: int) -> str:
|
|
703
|
-
try:
|
|
704
|
-
return str(child.read_nonblocking(4096, timeout=timeout)) # type: ignore[attr-defined]
|
|
705
|
-
except pexpect.TIMEOUT:
|
|
706
|
-
pass
|
|
707
|
-
|
|
708
|
-
try:
|
|
709
|
-
child.expect(r"(?s).+", timeout=timeout) # type: ignore[attr-defined]
|
|
710
|
-
except pexpect.TIMEOUT:
|
|
711
|
-
return str(getattr(child, "before", "") or "")
|
|
712
|
-
after = getattr(child, "after", "")
|
|
713
|
-
before = getattr(child, "before", "")
|
|
714
|
-
chunks = [str(after or before or "")]
|
|
715
|
-
while True:
|
|
716
|
-
try:
|
|
717
|
-
chunk = str(child.read_nonblocking(4096, timeout=0.2)) # type: ignore[attr-defined]
|
|
718
|
-
if chunk:
|
|
719
|
-
chunks.append(chunk)
|
|
720
|
-
except pexpect.TIMEOUT:
|
|
721
|
-
break
|
|
722
|
-
return "".join(chunks)
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
def terminal_setup_probe(
|
|
726
|
-
entry: dict[str, object],
|
|
727
|
-
timeout: int = DEFAULT_VERIFY_TIMEOUT,
|
|
728
|
-
*,
|
|
729
|
-
spawn: SpawnFunc = _spawn_entry,
|
|
730
|
-
stdin: object = sys.stdin,
|
|
731
|
-
stdout: object = sys.stdout,
|
|
732
|
-
terminal_io: object | None = None,
|
|
733
|
-
) -> VerificationResult:
|
|
734
|
-
"""Proxy a real terminal login while recording replayable login steps."""
|
|
735
|
-
|
|
736
|
-
marker = "__HOSTBRIDGE_READY_SETUP__"
|
|
737
|
-
child = spawn(entry, timeout)
|
|
738
|
-
recorder = InteractiveLoginRecorder(entry)
|
|
739
|
-
child_fd = child.child_fd # type: ignore[attr-defined]
|
|
740
|
-
input_buffer = bytearray()
|
|
741
|
-
secret_input = False
|
|
742
|
-
io = terminal_io or RealTerminalIO(stdin, stdout, child_fd)
|
|
743
|
-
|
|
744
|
-
print("\nRecording interactive login. Type /ready when the final shell is ready.", file=stdout)
|
|
745
|
-
print("Type /abort to cancel. Your input is forwarded to the setup session.\n", file=stdout)
|
|
33
|
+
fd, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
34
|
+
temporary = Path(name)
|
|
746
35
|
try:
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
""
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
36
|
+
os.fchmod(fd, 0o600)
|
|
37
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
38
|
+
json.dump(validated, handle, ensure_ascii=False, indent=2)
|
|
39
|
+
handle.write("\n")
|
|
40
|
+
handle.flush()
|
|
41
|
+
os.fsync(handle.fileno())
|
|
42
|
+
temporary.replace(path)
|
|
43
|
+
path.chmod(0o600)
|
|
44
|
+
except Exception:
|
|
45
|
+
temporary.unlink(missing_ok=True)
|
|
46
|
+
raise
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _add_config_argument(parser: argparse.ArgumentParser) -> None:
|
|
50
|
+
parser.add_argument("--config", help=f"schema-v1 config path (default: {DEFAULT_HOSTS_FILE})")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
54
|
+
parser = argparse.ArgumentParser(prog="hostbridge")
|
|
55
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
56
|
+
|
|
57
|
+
setup = commands.add_parser("setup", help="create an empty schema-v1 configuration")
|
|
58
|
+
_add_config_argument(setup)
|
|
59
|
+
setup.add_argument("--force", action="store_true")
|
|
60
|
+
|
|
61
|
+
migrate = commands.add_parser("migrate", help="migrate a legacy hosts file to schema v1")
|
|
62
|
+
migrate.add_argument("source")
|
|
63
|
+
migrate.add_argument("destination")
|
|
64
|
+
|
|
65
|
+
doctor = commands.add_parser("doctor", help="run deterministic installation diagnostics")
|
|
66
|
+
_add_config_argument(doctor)
|
|
67
|
+
doctor.add_argument("--json", action="store_true", dest="as_json")
|
|
68
|
+
|
|
69
|
+
hosts = commands.add_parser("hosts", help="manage allowlisted hosts")
|
|
70
|
+
_add_config_argument(hosts)
|
|
71
|
+
host_commands = hosts.add_subparsers(dest="hosts_command", required=True)
|
|
72
|
+
host_commands.add_parser("list")
|
|
73
|
+
add = host_commands.add_parser("add")
|
|
74
|
+
add.add_argument("host_json", help="one schema-v1 host object as JSON")
|
|
75
|
+
remove = host_commands.add_parser("remove")
|
|
76
|
+
remove.add_argument("host_id")
|
|
77
|
+
check = host_commands.add_parser("check")
|
|
78
|
+
check.add_argument("host_id")
|
|
79
|
+
check.add_argument("--timeout", type=int, default=30)
|
|
80
|
+
|
|
81
|
+
daemon = commands.add_parser("daemon", help="manage the protocol-v1 daemon")
|
|
82
|
+
_add_config_argument(daemon)
|
|
83
|
+
daemon_commands = daemon.add_subparsers(dest="daemon_command", required=True)
|
|
84
|
+
start = daemon_commands.add_parser("start")
|
|
85
|
+
start.add_argument("--foreground", action="store_true")
|
|
86
|
+
start.add_argument("--socket")
|
|
87
|
+
stop = daemon_commands.add_parser("stop")
|
|
88
|
+
stop.add_argument("--socket")
|
|
89
|
+
status = daemon_commands.add_parser("status")
|
|
90
|
+
status.add_argument("--socket")
|
|
91
|
+
logs = daemon_commands.add_parser("logs")
|
|
92
|
+
logs.add_argument("--lines", type=int, default=100)
|
|
93
|
+
|
|
94
|
+
mock = commands.add_parser("mock-ssh", help="expose a host through local SSH/SFTP/SCP")
|
|
95
|
+
mock_commands = mock.add_subparsers(dest="mock_command", required=True)
|
|
96
|
+
mock_start = mock_commands.add_parser("start")
|
|
97
|
+
mock_start.add_argument("host_id")
|
|
98
|
+
mock_start.add_argument("--listen-host", default="127.0.0.1")
|
|
99
|
+
mock_start.add_argument("--port", type=int)
|
|
100
|
+
mock_start.add_argument("--key-dir")
|
|
101
|
+
mock_start.add_argument("--ssh-host-alias")
|
|
102
|
+
mock_start.add_argument("--ssh-config")
|
|
103
|
+
mock_start.add_argument("--no-ssh-config", action="store_true")
|
|
104
|
+
mock_start.add_argument("--connect-timeout", type=int, default=60)
|
|
105
|
+
mock_start.add_argument("--command-timeout", type=int, default=60)
|
|
106
|
+
mock_start.add_argument("--output-limit", type=int, default=1024 * 1024 * 1024)
|
|
107
|
+
mock_stop = mock_commands.add_parser("stop")
|
|
108
|
+
mock_stop.add_argument("host_id")
|
|
109
|
+
mock_status = mock_commands.add_parser("status")
|
|
110
|
+
mock_status.add_argument("host_id")
|
|
111
|
+
mock_remove = mock_commands.add_parser("remove")
|
|
112
|
+
mock_remove.add_argument("host_id")
|
|
113
|
+
mock_remove.add_argument("--ssh-host-alias")
|
|
114
|
+
mock_remove.add_argument("--ssh-config")
|
|
115
|
+
|
|
116
|
+
commands.add_parser("version", help="print the HostBridge version")
|
|
117
|
+
return parser
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _run_setup(args: argparse.Namespace) -> int:
|
|
121
|
+
path = _config_path(args.config)
|
|
122
|
+
if path.exists() and not args.force:
|
|
123
|
+
raise FileExistsError(f"{path} already exists; use --force to replace it")
|
|
124
|
+
_write_config(path, {"schema_version": SCHEMA_VERSION, "hosts": []})
|
|
125
|
+
print(path)
|
|
126
|
+
return EXIT_OK
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _run_hosts(args: argparse.Namespace) -> int:
|
|
130
|
+
path = _config_path(args.config)
|
|
131
|
+
document = load_v1_config(path)
|
|
132
|
+
if args.hosts_command == "list":
|
|
133
|
+
print(json.dumps(document.hosts, ensure_ascii=False, indent=2))
|
|
134
|
+
return EXIT_OK
|
|
135
|
+
if args.hosts_command == "add":
|
|
136
|
+
host = json.loads(args.host_json)
|
|
137
|
+
payload = dict(document.data)
|
|
138
|
+
payload["hosts"] = [item for item in document.hosts if item.get("id") != host.get("id")] + [host]
|
|
139
|
+
_write_config(path, payload)
|
|
140
|
+
print(f"added {host.get('id')}")
|
|
141
|
+
return EXIT_OK
|
|
142
|
+
if args.hosts_command == "remove":
|
|
143
|
+
hosts = [item for item in document.hosts if item.get("id") != args.host_id]
|
|
144
|
+
if len(hosts) == len(document.hosts):
|
|
145
|
+
raise ValueError(f"unknown host {args.host_id!r}")
|
|
146
|
+
payload = dict(document.data)
|
|
147
|
+
payload["hosts"] = hosts
|
|
148
|
+
_write_config(path, payload)
|
|
149
|
+
print(f"removed {args.host_id}")
|
|
150
|
+
return EXIT_OK
|
|
151
|
+
client = HostBridgeClient(default_timeout=max(1, args.timeout))
|
|
152
|
+
session = client.open_session(args.host_id, owner="cli-check")
|
|
815
153
|
try:
|
|
816
|
-
|
|
817
|
-
observed = _read_setup_output(child, timeout)
|
|
818
|
-
if observed:
|
|
819
|
-
print(observed, end="" if observed.endswith("\n") else "\n")
|
|
820
|
-
if not observed.strip():
|
|
821
|
-
return VerificationResult(
|
|
822
|
-
False,
|
|
823
|
-
"no output observed from the connection before setup input; "
|
|
824
|
-
"check the command or retry with a larger --timeout",
|
|
825
|
-
)
|
|
826
|
-
|
|
827
|
-
expect = _prompt_regex_from_output(observed)
|
|
828
|
-
if _looks_secret_prompt(observed):
|
|
829
|
-
secret_name = f"password_{secret_index}"
|
|
830
|
-
secret_index += 1
|
|
831
|
-
secret_value = secret_ask("Secret response: ")
|
|
832
|
-
secrets[secret_name] = encrypt_secret(secret_value)
|
|
833
|
-
steps.append({"expect": expect, "send_secret": secret_name})
|
|
834
|
-
child.sendline(secret_value) # type: ignore[attr-defined]
|
|
835
|
-
else:
|
|
836
|
-
response = ask("Send input (/ready when shell is ready): ").strip()
|
|
837
|
-
if response == "/abort":
|
|
838
|
-
return VerificationResult(False, "setup aborted")
|
|
839
|
-
if response == "/ready":
|
|
840
|
-
child.sendline(f"printf '\\n{marker}\\n'") # type: ignore[attr-defined]
|
|
841
|
-
child.expect(marker, timeout=timeout) # type: ignore[attr-defined]
|
|
842
|
-
if steps:
|
|
843
|
-
entry["login_steps"] = steps
|
|
844
|
-
if secrets:
|
|
845
|
-
entry["secrets"] = secrets
|
|
846
|
-
return VerificationResult(True, "remote shell accepted a readiness probe")
|
|
847
|
-
steps.append({"expect": expect, "send": response})
|
|
848
|
-
child.sendline(response) # type: ignore[attr-defined]
|
|
849
|
-
except Exception as exc:
|
|
850
|
-
return VerificationResult(False, str(exc))
|
|
154
|
+
result = client.exec(session.session_id, "printf hostbridge-ok", timeout=args.timeout, owner="cli-check")
|
|
851
155
|
finally:
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
print("HostBridge Setup")
|
|
874
|
-
print("This wizard opens a terminal-style setup session and records the real login path.")
|
|
875
|
-
print("Secret-looking prompts do not echo locally and are encrypted before saving.\n")
|
|
876
|
-
|
|
877
|
-
try:
|
|
878
|
-
resolved_host_id = (host_id or ask("Host id: ")).strip()
|
|
879
|
-
if from_host:
|
|
880
|
-
if menu_selection is None:
|
|
881
|
-
menu_selection = ask("Menu selection to send (host/IP/name): ").strip()
|
|
882
|
-
entry = clone_jump_host_entry(
|
|
883
|
-
config_path,
|
|
884
|
-
source_host_id=from_host,
|
|
885
|
-
host_id=resolved_host_id,
|
|
886
|
-
label=label,
|
|
887
|
-
menu_selection=menu_selection,
|
|
888
|
-
menu_prompt=menu_prompt,
|
|
889
|
-
final_prompt=final_prompt,
|
|
890
|
-
)
|
|
891
|
-
else:
|
|
892
|
-
resolved_label = label if label is not None else ask("Display label: ").strip()
|
|
893
|
-
resolved_label = resolved_label or resolved_host_id
|
|
894
|
-
if command_text is None:
|
|
895
|
-
print("\nPaste the command you normally use to connect.")
|
|
896
|
-
print("Examples: ssh user@host | ssh -J bastion user@host | bash ~/.ssh/connect.sh")
|
|
897
|
-
command_text = ask("Connection command: ")
|
|
898
|
-
entry = build_host_entry(resolved_host_id, resolved_label, command_text)
|
|
899
|
-
result = probe(entry, timeout, ask, secret_ask)
|
|
900
|
-
if not result.ok:
|
|
901
|
-
print(f"Connection verification failed: {result.message}")
|
|
902
|
-
return 1
|
|
903
|
-
print(f"Connection verified: {result.message}")
|
|
904
|
-
if entry.get("login_steps"):
|
|
905
|
-
print("\nVerifying recorded login steps with a fresh connection...")
|
|
906
|
-
result = verify(entry, timeout)
|
|
907
|
-
if not result.ok:
|
|
908
|
-
print(f"Recorded login verification failed: {result.message}")
|
|
909
|
-
return 1
|
|
910
|
-
print(f"Recorded login verified: {result.message}")
|
|
911
|
-
except Exception as exc:
|
|
912
|
-
print(f"Setup failed: {exc}")
|
|
913
|
-
return 1
|
|
914
|
-
|
|
915
|
-
if from_host:
|
|
916
|
-
print("\nTesting connection now with the saved runtime entry...")
|
|
917
|
-
result = verify(entry, timeout)
|
|
918
|
-
if not result.ok:
|
|
919
|
-
print(f"Connection verification failed: {result.message}")
|
|
920
|
-
return 1
|
|
921
|
-
print(f"Connection verified: {result.message}")
|
|
922
|
-
|
|
923
|
-
replace = False
|
|
924
|
-
try:
|
|
925
|
-
status = upsert_host_entry(config_path, entry, replace=False)
|
|
926
|
-
except ValueError as exc:
|
|
927
|
-
if "already exists" not in str(exc):
|
|
928
|
-
print(f"Save failed: {exc}")
|
|
929
|
-
return 1
|
|
930
|
-
answer = ask(f"Host '{entry['id']}' already exists. Replace it? [y/N]: ").strip().lower()
|
|
931
|
-
if answer not in ("y", "yes"):
|
|
932
|
-
print("Cancelled; no changes written.")
|
|
933
|
-
return 1
|
|
934
|
-
replace = True
|
|
935
|
-
status = upsert_host_entry(config_path, entry, replace=True)
|
|
936
|
-
|
|
937
|
-
saved_path = _resolve_hosts_config_path(config_path)
|
|
938
|
-
_secure_config_permissions(saved_path)
|
|
939
|
-
print(f"Saved host '{entry['id']}' ({status}) to {saved_path}")
|
|
940
|
-
if entry.get("login_steps"):
|
|
941
|
-
print(f"Configured {len(entry['login_steps'])} encrypted/automated login step(s).")
|
|
942
|
-
print("Next: MCP hosts_list/session_open will reload this config automatically.")
|
|
943
|
-
if replace:
|
|
944
|
-
print("Restart your MCP client if it is already running.")
|
|
945
|
-
return 0
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
def run_check(
|
|
949
|
-
host_id: str,
|
|
950
|
-
*,
|
|
951
|
-
config_path: Path | str | None = None,
|
|
952
|
-
verify: VerifyFunc | None = None,
|
|
953
|
-
timeout: int = DEFAULT_VERIFY_TIMEOUT,
|
|
954
|
-
) -> int:
|
|
955
|
-
verify = verify or verify_connection
|
|
956
|
-
entry = _find_raw_host_entry(config_path, host_id)
|
|
957
|
-
if entry is None:
|
|
958
|
-
print(f"Host '{host_id}' not found in {_resolve_hosts_config_path(config_path)}")
|
|
959
|
-
return 1
|
|
960
|
-
result = verify(entry, timeout)
|
|
961
|
-
if result.ok:
|
|
962
|
-
print(f"Connection verified: {result.message}")
|
|
963
|
-
return 0
|
|
964
|
-
print(f"Connection verification failed: {result.message}")
|
|
965
|
-
return 1
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
def run_list(*, config_path: Path | str | None = None) -> int:
|
|
969
|
-
resolved_path = _resolve_hosts_config_path(config_path)
|
|
970
|
-
hosts = load_hosts(resolved_path)
|
|
971
|
-
if not hosts:
|
|
972
|
-
print(f"No hosts configured in {resolved_path}")
|
|
973
|
-
return 0
|
|
974
|
-
for host in hosts:
|
|
975
|
-
print(f"{host.id}\t{host.label}\t{host.connection_type}\t{host.command} {' '.join(host.args)}".rstrip())
|
|
976
|
-
return 0
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
def run_remove(host_id: str, *, config_path: Path | str | None = None) -> int:
|
|
980
|
-
if remove_host_entry(config_path, host_id):
|
|
981
|
-
print(f"Removed host '{host_id}' from {_resolve_hosts_config_path(config_path)}")
|
|
982
|
-
return 0
|
|
983
|
-
print(f"Host '{host_id}' not found in {_resolve_hosts_config_path(config_path)}")
|
|
984
|
-
return 1
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
def run_reload() -> int:
|
|
988
|
-
print("No manual CLI reload is required: the MCP server automatically reloads hosts.json during hosts_list and session_open.")
|
|
989
|
-
print("If your current MCP process predates this feature, restart it once to load the updated server code.")
|
|
990
|
-
return 0
|
|
156
|
+
client.close_session(session.session_id, owner="cli-check", force=True)
|
|
157
|
+
if result.exit_code != 0 or result.stdout != b"hostbridge-ok":
|
|
158
|
+
raise HostBridgeError("remote_error", "host check command failed")
|
|
159
|
+
print(f"{args.host_id}: ok")
|
|
160
|
+
return EXIT_OK
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _run_doctor(args: argparse.Namespace) -> int:
|
|
164
|
+
executable = shutil.which("hostbridge")
|
|
165
|
+
report = collect_diagnostics(
|
|
166
|
+
_config_path(args.config),
|
|
167
|
+
client=HostBridgeClient(default_timeout=2),
|
|
168
|
+
executable_paths=[executable] if executable else [],
|
|
169
|
+
)
|
|
170
|
+
rows = [asdict(result) for result in report.results]
|
|
171
|
+
if args.as_json:
|
|
172
|
+
print(json.dumps({"ok": report.ok, "results": rows}, ensure_ascii=False, indent=2))
|
|
173
|
+
else:
|
|
174
|
+
for result in report.results:
|
|
175
|
+
print(f"{result.status.upper():4} {result.name}: {result.message}")
|
|
176
|
+
return EXIT_OK if report.ok else EXIT_UNAVAILABLE
|
|
991
177
|
|
|
992
178
|
|
|
993
|
-
def
|
|
179
|
+
def _run_daemon(args: argparse.Namespace) -> int:
|
|
994
180
|
from . import daemon
|
|
995
181
|
|
|
996
|
-
if
|
|
997
|
-
if foreground:
|
|
998
|
-
return daemon.run_daemon(socket_file=
|
|
999
|
-
return daemon.start_background(socket_file=
|
|
1000
|
-
if
|
|
1001
|
-
return daemon.stop_background(socket_file=
|
|
1002
|
-
if
|
|
1003
|
-
if daemon.is_running(socket_file=
|
|
1004
|
-
print(f"hostbridge daemon running at unix://{daemon.socket_path(
|
|
1005
|
-
return
|
|
182
|
+
if args.daemon_command == "start":
|
|
183
|
+
if args.foreground:
|
|
184
|
+
return daemon.run_daemon(socket_file=args.socket, config_path=_config_path(args.config))
|
|
185
|
+
return daemon.start_background(socket_file=args.socket, config_path=_config_path(args.config))
|
|
186
|
+
if args.daemon_command == "stop":
|
|
187
|
+
return daemon.stop_background(socket_file=args.socket)
|
|
188
|
+
if args.daemon_command == "status":
|
|
189
|
+
if daemon.is_running(socket_file=args.socket):
|
|
190
|
+
print(f"hostbridge daemon running at unix://{daemon.socket_path(args.socket)}")
|
|
191
|
+
return EXIT_OK
|
|
1006
192
|
print("hostbridge daemon is not running")
|
|
1007
|
-
return
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
def run_entry_login_steps(child: object, entry: dict[str, object], timeout: int) -> None:
|
|
1013
|
-
raw_steps = entry.get("login_steps", [])
|
|
1014
|
-
if not isinstance(raw_steps, list):
|
|
1015
|
-
raise ValueError("login_steps must be a list")
|
|
1016
|
-
raw_secrets = entry.get("secrets", {})
|
|
1017
|
-
if not isinstance(raw_secrets, dict):
|
|
1018
|
-
raise ValueError("secrets must be an object")
|
|
1019
|
-
for index, raw_step in enumerate(raw_steps):
|
|
1020
|
-
if not isinstance(raw_step, dict):
|
|
1021
|
-
raise ValueError(f"login_steps[{index}] must be an object")
|
|
1022
|
-
expect = str(raw_step.get("expect", ""))
|
|
1023
|
-
if not expect.strip():
|
|
1024
|
-
raise ValueError(f"login_steps[{index}].expect must not be empty")
|
|
1025
|
-
child.expect(_runtime_expect_pattern(expect), timeout=timeout)
|
|
1026
|
-
if "send_secret" in raw_step:
|
|
1027
|
-
secret_name = str(raw_step["send_secret"])
|
|
1028
|
-
if secret_name not in raw_secrets:
|
|
1029
|
-
raise ValueError(f"login step references missing secret '{secret_name}'")
|
|
1030
|
-
text = decrypt_secret(raw_secrets[secret_name])
|
|
1031
|
-
elif "send" in raw_step:
|
|
1032
|
-
text = str(raw_step["send"])
|
|
1033
|
-
else:
|
|
1034
|
-
raise ValueError(f"login_steps[{index}] must define send or send_secret")
|
|
1035
|
-
child.send(f"{text}\r")
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
def wait_for_ready_prompt(child: object, entry: dict[str, object], timeout: int) -> None:
|
|
1039
|
-
ready_expect = entry.get("ready_expect")
|
|
1040
|
-
pattern = str(ready_expect) if isinstance(ready_expect, str) and ready_expect.strip() else r"(?m)[^\r\n]*[#$]\s*$"
|
|
1041
|
-
pattern = _runtime_expect_pattern(pattern)
|
|
1042
|
-
child.expect(pattern, timeout=timeout)
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
def verify_connection(entry: dict[str, object], timeout: int = DEFAULT_VERIFY_TIMEOUT) -> VerificationResult:
|
|
1046
|
-
command, args = _entry_to_command(entry)
|
|
1047
|
-
marker = "__SCM_READY_SETUP__"
|
|
1048
|
-
if entry.get("login_steps"):
|
|
1049
|
-
print("\nRunning configured login automation before the readiness probe.\n")
|
|
1050
|
-
else:
|
|
1051
|
-
print("\nThe connection process is now attached to your terminal.")
|
|
1052
|
-
print("Complete any password, passphrase, host-key, or 2FA prompts normally.")
|
|
1053
|
-
print("When you see the remote shell prompt, press Ctrl-] to return to this wizard.\n")
|
|
1054
|
-
child = pexpect.spawn(command, args, encoding="utf-8", codec_errors="replace", timeout=timeout, echo=False)
|
|
1055
|
-
try:
|
|
1056
|
-
if entry.get("login_steps"):
|
|
1057
|
-
run_entry_login_steps(child, entry, timeout)
|
|
1058
|
-
wait_for_ready_prompt(child, entry, timeout)
|
|
1059
|
-
else:
|
|
1060
|
-
child.interact(escape_character=chr(29))
|
|
1061
|
-
child.send(f"printf '\\n{marker}\\n'\r")
|
|
1062
|
-
child.expect(marker, timeout=timeout)
|
|
1063
|
-
child.send("pwd\r")
|
|
1064
|
-
child.expect(r"\r?\n", timeout=5)
|
|
1065
|
-
return VerificationResult(True, "remote shell accepted a readiness probe")
|
|
1066
|
-
except Exception as exc:
|
|
1067
|
-
return VerificationResult(False, str(exc))
|
|
1068
|
-
finally:
|
|
1069
|
-
child.close(force=True)
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
def _entry_to_command(entry: dict[str, object]) -> tuple[str, list[str]]:
|
|
1073
|
-
if "script" in entry:
|
|
1074
|
-
return "/bin/sh", [str(entry["script"])]
|
|
1075
|
-
if "ssh" in entry:
|
|
1076
|
-
ssh = entry["ssh"]
|
|
1077
|
-
if not isinstance(ssh, dict):
|
|
1078
|
-
raise ValueError("ssh entry must be an object")
|
|
1079
|
-
args: list[str] = []
|
|
1080
|
-
if ssh.get("port") not in (None, ""):
|
|
1081
|
-
args.extend(["-p", str(ssh["port"])])
|
|
1082
|
-
if ssh.get("identity_file"):
|
|
1083
|
-
args.extend(["-i", str(ssh["identity_file"])])
|
|
1084
|
-
if ssh.get("proxy_jump"):
|
|
1085
|
-
args.extend(["-J", str(ssh["proxy_jump"])])
|
|
1086
|
-
extra_args = ssh.get("extra_args", [])
|
|
1087
|
-
if isinstance(extra_args, Iterable) and not isinstance(extra_args, (str, bytes)):
|
|
1088
|
-
args.extend(str(arg) for arg in extra_args)
|
|
1089
|
-
args.append(str(ssh["host"]))
|
|
1090
|
-
return "ssh", args
|
|
1091
|
-
return str(entry["command"]), [str(arg) for arg in entry.get("args", [])]
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
def _find_raw_host_entry(config_path: Path | str, host_id: str) -> dict[str, object] | None:
|
|
1095
|
-
config = _read_config(_resolve_hosts_config_path(config_path))
|
|
1096
|
-
hosts = config.get("hosts", [])
|
|
1097
|
-
if not isinstance(hosts, list):
|
|
1098
|
-
raise ValueError(f"{config_path} must contain a top-level 'hosts' list")
|
|
1099
|
-
for host in hosts:
|
|
1100
|
-
if isinstance(host, dict) and host.get("id") == host_id:
|
|
1101
|
-
return host
|
|
1102
|
-
return None
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
def _read_config(path: Path) -> dict[str, object]:
|
|
193
|
+
return EXIT_UNAVAILABLE
|
|
194
|
+
path = daemon.log_file()
|
|
1106
195
|
if not path.exists():
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
return data
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
def _write_config(path: Path, config: dict[str, object]) -> None:
|
|
1116
|
-
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1117
|
-
path.write_text(json.dumps(config, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
1118
|
-
_secure_config_permissions(path)
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
def _secure_config_permissions(path: Path) -> None:
|
|
1122
|
-
with suppress(OSError):
|
|
1123
|
-
path.chmod(0o600)
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
def main(argv: list[str] | None = None) -> int:
|
|
1127
|
-
parser = argparse.ArgumentParser(prog=PUBLIC_NAME)
|
|
1128
|
-
parser.add_argument("--config", help=f"Path to hosts.json (default: {DEFAULT_HOSTS_FILE})")
|
|
1129
|
-
config_parent = argparse.ArgumentParser(add_help=False)
|
|
1130
|
-
config_parent.add_argument("--config", help=argparse.SUPPRESS)
|
|
1131
|
-
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
1132
|
-
|
|
1133
|
-
install_parser = subparsers.add_parser("install", help="Register this MCP server with an agent client")
|
|
1134
|
-
install_parser.add_argument("--target", choices=list(SUPPORTED_INSTALL_TARGETS), default="all")
|
|
1135
|
-
install_parser.add_argument("--config", help="Override target config path for a single target")
|
|
1136
|
-
install_parser.add_argument("--home", help="Home directory to scan for agent configs")
|
|
1137
|
-
install_parser.add_argument("--command", dest="install_command", help="MCP server command to register")
|
|
1138
|
-
install_parser.add_argument("--arg", dest="install_args", action="append", default=[], help="Argument for --command; repeatable")
|
|
1139
|
-
|
|
1140
|
-
setup_parser = subparsers.add_parser("setup", parents=[config_parent], help="Add and verify an allowlisted host")
|
|
1141
|
-
setup_parser.add_argument("host_id", nargs="?", help="Stable host id to create")
|
|
1142
|
-
setup_parser.add_argument("--label", help="Display label; defaults to host id")
|
|
1143
|
-
setup_parser.add_argument("--command", dest="command_text", help="Connection command, e.g. 'ssh bastion'")
|
|
1144
|
-
setup_parser.add_argument("--from-host", help="Clone connection style and encrypted secrets from an existing host")
|
|
1145
|
-
setup_parser.add_argument("--menu-selection", help="JumpServer/menu selection to send for the cloned host")
|
|
1146
|
-
setup_parser.add_argument("--menu-prompt", default="Opt>", help="Prompt regex before sending --menu-selection")
|
|
1147
|
-
setup_parser.add_argument("--final-prompt", help="Remote shell prompt regex before readiness probe")
|
|
1148
|
-
setup_parser.add_argument("--timeout", type=int, default=DEFAULT_VERIFY_TIMEOUT)
|
|
1149
|
-
|
|
1150
|
-
check_parser = subparsers.add_parser("check", parents=[config_parent], help="Verify a configured host")
|
|
1151
|
-
check_parser.add_argument("host_id")
|
|
1152
|
-
check_parser.add_argument("--timeout", type=int, default=DEFAULT_VERIFY_TIMEOUT)
|
|
196
|
+
print(f"no daemon log at {path}")
|
|
197
|
+
return EXIT_UNAVAILABLE
|
|
198
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
199
|
+
print("\n".join(lines[-max(1, min(args.lines, 10000)) :]))
|
|
200
|
+
return EXIT_OK
|
|
1153
201
|
|
|
1154
|
-
subparsers.add_parser("list", parents=[config_parent], help="List configured hosts")
|
|
1155
|
-
subparsers.add_parser("reload", help="Explain runtime host reload behavior")
|
|
1156
202
|
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
daemon_start = daemon_subparsers.add_parser("start", help="Start the HostBridge daemon")
|
|
1160
|
-
daemon_start.add_argument("--foreground", action="store_true", help="Run daemon in the foreground")
|
|
1161
|
-
daemon_start.add_argument("--socket", dest="socket_file", default=None, help="Unix socket path; default comes from HOSTBRIDGE_DAEMON_SOCKET or HOSTBRIDGE_DAEMON_DIR")
|
|
1162
|
-
daemon_stop = daemon_subparsers.add_parser("stop", help="Stop the HostBridge daemon")
|
|
1163
|
-
daemon_stop.add_argument("--socket", dest="socket_file", default=None, help="Unix socket path; default comes from HOSTBRIDGE_DAEMON_SOCKET or HOSTBRIDGE_DAEMON_DIR")
|
|
1164
|
-
daemon_status = daemon_subparsers.add_parser("status", help="Show daemon status")
|
|
1165
|
-
daemon_status.add_argument("--socket", dest="socket_file", default=None, help="Unix socket path; default comes from HOSTBRIDGE_DAEMON_SOCKET or HOSTBRIDGE_DAEMON_DIR")
|
|
1166
|
-
|
|
1167
|
-
mock_ssh_parser = subparsers.add_parser("mock-ssh", parents=[config_parent], help="Expose a HostBridge host as a local SSH server")
|
|
1168
|
-
mock_ssh_parser.add_argument("host_id")
|
|
1169
|
-
mock_ssh_parser.add_argument("--listen-host", default="127.0.0.1")
|
|
1170
|
-
mock_ssh_parser.add_argument("--port", type=int, default=None, help="Local SSH port; default auto-selects a free port from 2222-2299")
|
|
1171
|
-
mock_ssh_parser.add_argument("--key-dir", help="Directory for generated mock SSH host/client keys")
|
|
1172
|
-
mock_ssh_parser.add_argument("--ssh-host-alias", help="Host alias to manage in ~/.ssh/config; default hostbridge-<host_id>")
|
|
1173
|
-
mock_ssh_parser.add_argument("--ssh-config", help="SSH config path; default ~/.ssh/config")
|
|
1174
|
-
mock_ssh_parser.add_argument("--no-ssh-config", action="store_true", help="Do not write a managed Host block to SSH config")
|
|
1175
|
-
mock_ssh_parser.add_argument("--remove", action="store_true", help="Remove the managed SSH config block and exit")
|
|
1176
|
-
mock_ssh_parser.add_argument("--connect-timeout", type=int, default=60)
|
|
1177
|
-
mock_ssh_parser.add_argument("--command-timeout", type=int, default=DEFAULT_VERIFY_TIMEOUT)
|
|
1178
|
-
mock_ssh_parser.add_argument("--output-limit", type=int, default=DEFAULT_MAX_TRANSFER_BYTES)
|
|
1179
|
-
|
|
1180
|
-
remove_parser = subparsers.add_parser("remove", parents=[config_parent], help="Remove a configured host")
|
|
1181
|
-
remove_parser.add_argument("host_id")
|
|
1182
|
-
|
|
1183
|
-
secret_parser = subparsers.add_parser("secret", parents=[config_parent], help="Manage encrypted host secrets")
|
|
1184
|
-
secret_subparsers = secret_parser.add_subparsers(dest="secret_command", required=True)
|
|
1185
|
-
secret_set = secret_subparsers.add_parser("set", help="Encrypt and save a host secret")
|
|
1186
|
-
secret_set.add_argument("host_id")
|
|
1187
|
-
secret_set.add_argument("name")
|
|
1188
|
-
|
|
1189
|
-
step_parser = subparsers.add_parser("step", parents=[config_parent], help="Manage pre-login automation steps")
|
|
1190
|
-
step_subparsers = step_parser.add_subparsers(dest="step_command", required=True)
|
|
1191
|
-
step_add = step_subparsers.add_parser("add", help="Append an expect/send login step")
|
|
1192
|
-
step_add.add_argument("host_id")
|
|
1193
|
-
step_add.add_argument("--expect", required=True)
|
|
1194
|
-
action = step_add.add_mutually_exclusive_group(required=True)
|
|
1195
|
-
action.add_argument("--send")
|
|
1196
|
-
action.add_argument("--send-secret")
|
|
1197
|
-
|
|
1198
|
-
args = parser.parse_args(argv)
|
|
1199
|
-
if args.command == "install":
|
|
1200
|
-
return run_install(
|
|
1201
|
-
target=args.target,
|
|
1202
|
-
config_path=Path(args.config).expanduser() if args.config else None,
|
|
1203
|
-
command=args.install_command,
|
|
1204
|
-
args=args.install_args,
|
|
1205
|
-
home_path=Path(args.home).expanduser() if args.home else None,
|
|
1206
|
-
)
|
|
1207
|
-
config_path = Path(args.config).expanduser() if getattr(args, "config", None) else None
|
|
1208
|
-
if args.command == "setup":
|
|
1209
|
-
return run_setup(
|
|
1210
|
-
host_id=args.host_id,
|
|
1211
|
-
label=args.label,
|
|
1212
|
-
command_text=args.command_text,
|
|
1213
|
-
from_host=args.from_host,
|
|
1214
|
-
menu_selection=args.menu_selection,
|
|
1215
|
-
menu_prompt=args.menu_prompt,
|
|
1216
|
-
final_prompt=args.final_prompt,
|
|
1217
|
-
config_path=config_path,
|
|
1218
|
-
timeout=args.timeout,
|
|
1219
|
-
)
|
|
1220
|
-
if args.command == "check":
|
|
1221
|
-
return run_check(args.host_id, config_path=config_path, timeout=args.timeout)
|
|
1222
|
-
if args.command == "list":
|
|
1223
|
-
return run_list(config_path=config_path)
|
|
1224
|
-
if args.command == "remove":
|
|
1225
|
-
return run_remove(args.host_id, config_path=config_path)
|
|
1226
|
-
if args.command == "reload":
|
|
1227
|
-
return run_reload()
|
|
1228
|
-
if args.command == "daemon":
|
|
1229
|
-
return run_daemon_command(
|
|
1230
|
-
args.daemon_command,
|
|
1231
|
-
foreground=getattr(args, "foreground", False),
|
|
1232
|
-
socket_file=getattr(args, "socket_file", None),
|
|
1233
|
-
)
|
|
1234
|
-
if args.command == "mock-ssh":
|
|
1235
|
-
from .mock_ssh import run_mock_ssh, uninstall_mock_ssh
|
|
1236
|
-
|
|
1237
|
-
ssh_config_path = Path(args.ssh_config).expanduser() if args.ssh_config else None
|
|
1238
|
-
if args.remove:
|
|
1239
|
-
return uninstall_mock_ssh(
|
|
1240
|
-
args.host_id,
|
|
1241
|
-
ssh_host_alias=args.ssh_host_alias,
|
|
1242
|
-
ssh_config_path=ssh_config_path,
|
|
1243
|
-
)
|
|
203
|
+
def _run_mock_ssh(args: argparse.Namespace) -> int:
|
|
204
|
+
from .mock_ssh import run_mock_ssh, uninstall_mock_ssh
|
|
1244
205
|
|
|
206
|
+
if args.mock_command == "start":
|
|
1245
207
|
return run_mock_ssh(
|
|
1246
208
|
host_id=args.host_id,
|
|
1247
|
-
config_path=config_path,
|
|
1248
209
|
listen_host=args.listen_host,
|
|
1249
210
|
port=args.port,
|
|
1250
211
|
key_dir=Path(args.key_dir).expanduser() if args.key_dir else None,
|
|
1251
212
|
ssh_host_alias=args.ssh_host_alias,
|
|
1252
|
-
ssh_config_path=
|
|
213
|
+
ssh_config_path=Path(args.ssh_config).expanduser() if args.ssh_config else None,
|
|
1253
214
|
install_ssh_config=not args.no_ssh_config,
|
|
1254
215
|
connect_timeout=args.connect_timeout,
|
|
1255
216
|
command_timeout=args.command_timeout,
|
|
1256
217
|
output_limit=args.output_limit,
|
|
1257
218
|
)
|
|
1258
|
-
if args.
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
219
|
+
if args.mock_command == "remove":
|
|
220
|
+
return uninstall_mock_ssh(
|
|
221
|
+
args.host_id,
|
|
222
|
+
ssh_host_alias=args.ssh_host_alias,
|
|
223
|
+
ssh_config_path=Path(args.ssh_config).expanduser() if args.ssh_config else None,
|
|
224
|
+
)
|
|
225
|
+
print("mock-ssh runs in the foreground; stop it with Ctrl-C and inspect it with your process supervisor")
|
|
226
|
+
return EXIT_USAGE
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _exit_for(exc: Exception) -> int:
|
|
230
|
+
if isinstance(exc, HostBridgeError):
|
|
231
|
+
if exc.code in {"connection_lost", "timeout"}:
|
|
232
|
+
return EXIT_UNAVAILABLE
|
|
233
|
+
if exc.code == "denied":
|
|
234
|
+
return EXIT_DENIED
|
|
235
|
+
return EXIT_REMOTE
|
|
236
|
+
return EXIT_USAGE
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def main(argv: list[str] | None = None) -> int:
|
|
240
|
+
args = build_parser().parse_args(argv)
|
|
241
|
+
try:
|
|
242
|
+
if args.command == "version":
|
|
243
|
+
print(f"hostbridge {__version__}")
|
|
244
|
+
return EXIT_OK
|
|
245
|
+
if args.command == "setup":
|
|
246
|
+
return _run_setup(args)
|
|
247
|
+
if args.command == "migrate":
|
|
248
|
+
migrated = migrate_config(args.source, args.destination)
|
|
249
|
+
print(migrated.path)
|
|
250
|
+
return EXIT_OK
|
|
251
|
+
if args.command == "doctor":
|
|
252
|
+
return _run_doctor(args)
|
|
253
|
+
if args.command == "hosts":
|
|
254
|
+
return _run_hosts(args)
|
|
255
|
+
if args.command == "daemon":
|
|
256
|
+
return _run_daemon(args)
|
|
257
|
+
if args.command == "mock-ssh":
|
|
258
|
+
return _run_mock_ssh(args)
|
|
259
|
+
except (HostBridgeError, OSError, ValueError, json.JSONDecodeError) as exc:
|
|
260
|
+
print(f"hostbridge: {exc}", file=sys.stderr)
|
|
261
|
+
return _exit_for(exc)
|
|
262
|
+
return EXIT_USAGE
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
if __name__ == "__main__":
|
|
266
|
+
raise SystemExit(main())
|