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