@neoline/hostbridge 1.4.4 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 re
9
- import select
10
- import shlex
6
+ import shutil
11
7
  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
8
+ import tempfile
9
+ from dataclasses import asdict
17
10
  from pathlib import Path
18
11
 
19
- import pexpect
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
- from .hosts import DEFAULT_HOSTS_FILE, HOST_ID_PATTERN, LEGACY_HOSTS_FILE, load_hosts
22
- from .manager import DEFAULT_MAX_TRANSFER_BYTES
23
- from .secrets import decrypt_secret, encrypt_secret
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
- Use HostBridge when the user asks to connect to, inspect, or operate an allowlisted remote host/server.
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
- @dataclass(frozen=True, slots=True)
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
- path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
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
- if hasattr(io, "start"):
748
- io.start() # type: ignore[attr-defined]
749
- while True:
750
- source, data = io.read_event() # type: ignore[attr-defined]
751
- if source == "child":
752
- if not data:
753
- return VerificationResult(False, "connection closed during setup")
754
- io.write_stdout(data) # type: ignore[attr-defined]
755
- recorder.observe(data.decode("utf-8", errors="replace"))
756
-
757
- if source == "stdin":
758
- for byte in data:
759
- if not input_buffer:
760
- secret_input = recorder.current_prompt_is_secret()
761
- if byte == 3:
762
- return VerificationResult(False, "setup aborted")
763
- if byte in (8, 127):
764
- if input_buffer:
765
- input_buffer.pop()
766
- if not secret_input:
767
- io.write_stdout(b"\b \b") # type: ignore[attr-defined]
768
- continue
769
- if byte in (10, 13):
770
- line = input_buffer.decode("utf-8", errors="replace")
771
- input_buffer.clear()
772
- if not secret_input:
773
- io.write_stdout(b"\n") # type: ignore[attr-defined]
774
- action = recorder.finish_line(line)
775
- if action.kind == "abort":
776
- return VerificationResult(False, "setup aborted")
777
- if action.kind == "ready":
778
- child.send(f"printf '\\n{marker}\\n'\r") # type: ignore[attr-defined]
779
- child.expect(marker, timeout=timeout) # type: ignore[attr-defined]
780
- recorder.apply_to_entry()
781
- return VerificationResult(True, "remote shell accepted a readiness probe")
782
- child.send(f"{action.text}\r") # type: ignore[attr-defined]
783
- continue
784
- input_buffer.append(byte)
785
- if not secret_input:
786
- io.write_stdout(bytes([byte])) # type: ignore[attr-defined]
787
- except Exception as exc:
788
- return VerificationResult(False, str(exc))
789
- finally:
790
- if hasattr(io, "close"):
791
- io.close() # type: ignore[attr-defined]
792
- child.close(force=True) # type: ignore[attr-defined]
793
-
794
-
795
- def live_setup_probe(
796
- entry: dict[str, object],
797
- timeout: int = DEFAULT_VERIFY_TIMEOUT,
798
- *,
799
- ask: AskFunc = input,
800
- secret_ask: SecretAskFunc = getpass.getpass,
801
- spawn: SpawnFunc = _spawn_entry,
802
- ) -> VerificationResult:
803
- """Drive the real connection once and record replayable login steps from observed prompts."""
804
-
805
- marker = "__HOSTBRIDGE_READY_SETUP__"
806
- child = spawn(entry, timeout)
807
- steps: list[dict[str, str]] = []
808
- secrets: dict[str, object] = {}
809
- secret_index = 1
810
-
811
- print("\nStarting a hidden setup session.")
812
- print("Respond to the prompts shown below exactly as you normally would.")
813
- print("Type /ready when the remote shell prompt is ready, or /abort to cancel.\n")
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
- while True:
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
- child.close(force=True) # type: ignore[attr-defined]
853
-
854
-
855
- def run_setup(
856
- *,
857
- host_id: str | None = None,
858
- label: str | None = None,
859
- command_text: str | None = None,
860
- from_host: str | None = None,
861
- menu_selection: str | None = None,
862
- menu_prompt: str = "Opt>",
863
- final_prompt: str | None = None,
864
- config_path: Path | str | None = None,
865
- ask: AskFunc = input,
866
- secret_ask: SecretAskFunc = getpass.getpass,
867
- verify: VerifyFunc | None = None,
868
- probe: SetupProbeFunc | None = None,
869
- timeout: int = DEFAULT_VERIFY_TIMEOUT,
870
- ) -> int:
871
- verify = verify or verify_connection
872
- probe = probe or (lambda entry, probe_timeout, probe_ask, probe_secret_ask: terminal_setup_probe(entry, probe_timeout))
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 run_daemon_command(command: str, *, foreground: bool = False, socket_file: Path | str | None = None) -> int:
179
+ def _run_daemon(args: argparse.Namespace) -> int:
994
180
  from . import daemon
995
181
 
996
- if command == "start":
997
- if foreground:
998
- return daemon.run_daemon(socket_file=socket_file)
999
- return daemon.start_background(socket_file=socket_file)
1000
- if command == "stop":
1001
- return daemon.stop_background(socket_file=socket_file)
1002
- if command == "status":
1003
- if daemon.is_running(socket_file=socket_file):
1004
- print(f"hostbridge daemon running at unix://{daemon.socket_path(socket_file)}")
1005
- return 0
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 1
1008
- raise ValueError(f"unknown daemon command: {command}")
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
- return {"hosts": []}
1108
- data = json.loads(path.read_text(encoding="utf-8"))
1109
- if not isinstance(data, dict):
1110
- raise ValueError(f"{path} must contain a JSON object")
1111
- data.setdefault("hosts", [])
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
- daemon_parser = subparsers.add_parser("daemon", help="Manage the HostBridge daemon")
1158
- daemon_subparsers = daemon_parser.add_subparsers(dest="daemon_command", required=True)
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=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.command == "secret" and args.secret_command == "set":
1259
- value = getpass.getpass(f"Secret value for {args.host_id}/{args.name}: ")
1260
- set_host_secret(config_path, args.host_id, args.name, value)
1261
- print(f"Saved encrypted secret '{args.name}' for host '{args.host_id}'")
1262
- return 0
1263
- if args.command == "step" and args.step_command == "add":
1264
- add_login_step(config_path, args.host_id, expect=args.expect, send=args.send, send_secret=args.send_secret)
1265
- print(f"Added login step for host '{args.host_id}'")
1266
- return 0
1267
- parser.error("unknown command")
1268
- return 2
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())