From 21d03b7cc9adfeea54af9e8bad95fe67120fab37 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 9 Jul 2026 15:51:50 +0000 Subject: [PATCH] feat: preflight git-gate host key population When a git-gate repo entry has no host_key configured, the prepare step now either errors (headless) or fetches the key via ssh-keyscan, shows it to the operator for confirmation, and optionally persists it to the bottle config .md file on disk. Closes #333 Co-Authored-By: Claude Sonnet 4.6 --- bot_bottle/backend/__init__.py | 10 + bot_bottle/cli/start.py | 1 + bot_bottle/git_gate_host_key.py | 279 ++++++++++++++++++ tests/unit/test_git_gate_host_key.py | 404 +++++++++++++++++++++++++++ 4 files changed, 694 insertions(+) create mode 100644 bot_bottle/git_gate_host_key.py create mode 100644 tests/unit/test_git_gate_host_key.py diff --git a/bot_bottle/backend/__init__.py b/bot_bottle/backend/__init__.py index caf8a8f..ed8b894 100644 --- a/bot_bottle/backend/__init__.py +++ b/bot_bottle/backend/__init__.py @@ -75,6 +75,9 @@ class BottleSpec: # Ordered bottle names selected at launch (issue #269). When non-empty # they are merged in order and replace the agent's `bottle:` field. bottle_names: tuple[str, ...] = () + # True when launched via --headless (no TTY, no interactive prompts). + # The git-gate host-key preflight uses this to error rather than prompt. + headless: bool = False @dataclass(frozen=True) @@ -300,6 +303,13 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): self._preflight() + from ..git_gate_host_key import preflight_host_keys + manifest = preflight_host_keys( + manifest, + headless=spec.headless, + home_md=spec.manifest.home_md, + ) + manifest_bottle = manifest.bottle manifest_agent_provider = manifest_bottle.agent_provider agent_provider = get_provider(manifest_agent_provider.template) diff --git a/bot_bottle/cli/start.py b/bot_bottle/cli/start.py index a71338e..5a22ce0 100644 --- a/bot_bottle/cli/start.py +++ b/bot_bottle/cli/start.py @@ -209,6 +209,7 @@ def _start_headless( label=label, color=args.color or "", bottle_names=bottle_names, + headless=True, ) return _launch_bottle( spec, diff --git a/bot_bottle/git_gate_host_key.py b/bot_bottle/git_gate_host_key.py new file mode 100644 index 0000000..5a0da4f --- /dev/null +++ b/bot_bottle/git_gate_host_key.py @@ -0,0 +1,279 @@ +"""Preflight host-key population for git-gate upstreams (issue #333). + +When a git-gate repo entry lacks a `host_key`, this module either: +- headless: dies with a clear config error. +- interactive: fetches the key via ssh-keyscan, prompts the operator to + confirm, and optionally persists it to the bottle config file on disk. + +Public entry point: `preflight_host_keys(manifest, headless=..., home_md=...)`. +""" + +from __future__ import annotations + +import dataclasses +import re +import subprocess +import sys +from pathlib import Path + +from .log import die, info +from .manifest import Manifest, ManifestBottle +from .manifest_git import ManifestGitEntry + + +def fetch_host_key(host: str, port: str) -> str: + """Return an SSH ed25519 public key for `host`:`port` via ssh-keyscan. + + Returns the key in ` ` format (the host prefix is + stripped so the result can be stored in `host_key` and later formatted + into a known_hosts line by `git_gate_known_hosts_line`). + + Raises `RuntimeError` on subprocess failure, timeout, or no ed25519 + result. Uses only the Python stdlib (subprocess).""" + args = ["ssh-keyscan", "-t", "ed25519"] + if port and port != "22": + args += ["-p", port] + args.append(host) + try: + result = subprocess.run( + args, capture_output=True, text=True, timeout=15, + ) + except OSError as e: + raise RuntimeError( + f"ssh-keyscan: could not launch for {host}:{port}: {e}" + ) from e + except subprocess.TimeoutExpired: + raise RuntimeError(f"ssh-keyscan timed out for {host}:{port}") + + for line in result.stdout.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + # known_hosts format: "[host]:port type data" or "host type data" + # Strip the host/port prefix; keep "type data". + parts = line.split(None, 2) + if len(parts) == 3: + return f"{parts[1]} {parts[2]}" + + raise RuntimeError( + f"ssh-keyscan returned no ed25519 key for {host}:{port}." + + (f" stderr: {result.stderr.strip()!r}" if result.stderr.strip() else "") + ) + + +# --------------------------------------------------------------------------- +# Frontmatter text-level editing +# --------------------------------------------------------------------------- + + +def _insert_host_key_in_frontmatter(fm_text: str, repo_name: str, host_key: str) -> str: + """Append `host_key: ` to the named repo entry in raw frontmatter text. + + Locates the `:` block-mapping key, detects its child + indentation from the sibling keys already present, and inserts the + new line after the last child. Returns the original text if the + repo entry is not found or already has a `host_key:` line.""" + lines = fm_text.splitlines(keepends=True) + + repo_pat = re.compile(r"^(\s+)" + re.escape(repo_name) + r"\s*:\s*(\r?\n)?$") + repo_line_idx: int | None = None + repo_indent: int | None = None + for i, line in enumerate(lines): + if repo_pat.match(line): + repo_indent = len(line) - len(line.lstrip()) + repo_line_idx = i + break + + if repo_line_idx is None or repo_indent is None: + return fm_text + + child_indent: int | None = None + last_child_idx = repo_line_idx + + for i in range(repo_line_idx + 1, len(lines)): + raw = lines[i].rstrip("\r\n") + if not raw.strip(): + continue + indent = len(raw) - len(raw.lstrip()) + if indent > repo_indent: + child_indent = indent + last_child_idx = i + else: + break + + if child_indent is None: + child_indent = repo_indent + 2 + + block = "".join(lines[repo_line_idx + 1 : last_child_idx + 1]) + if re.search(r"^\s*host_key\s*:", block, re.MULTILINE): + return fm_text + + new_line = " " * child_indent + f"host_key: {host_key}\n" + lines.insert(last_child_idx + 1, new_line) + return "".join(lines) + + +def _update_frontmatter_in_file(file_text: str, repo_name: str, host_key: str) -> str: + """Apply `_insert_host_key_in_frontmatter` to the frontmatter section of + a Markdown file, leaving the body unchanged. Returns the original text + when no change is needed.""" + lines = file_text.splitlines(keepends=True) + if not lines or lines[0].rstrip("\r\n") != "---": + return file_text + + close_idx: int | None = None + for i in range(1, len(lines)): + if lines[i].rstrip("\r\n") == "---": + close_idx = i + break + if close_idx is None: + return file_text + + fm_text = "".join(lines[1:close_idx]) + body_text = "".join(lines[close_idx:]) + updated_fm = _insert_host_key_in_frontmatter(fm_text, repo_name, host_key) + if updated_fm == fm_text: + return file_text + return "---\n" + updated_fm + body_text + + +def _find_and_update_bottle_file( + bottles_dir: Path, repo_name: str, host_key: str, +) -> bool: + """Scan `bottles_dir/*.md` for the first file that directly declares + `repo_name` without a `host_key`, update it, and return True. + + Walks files in sorted order so the result is deterministic. Does NOT + follow `extends:` chains — only files that explicitly declare the repo + entry are candidates. Returns False when no such file is found.""" + if not bottles_dir.is_dir(): + return False + + from .yaml_subset import YamlSubsetError, parse_frontmatter + + for path in sorted(bottles_dir.glob("*.md")): + try: + text = path.read_text(encoding="utf-8") + fm, _body = parse_frontmatter(text) + except (OSError, YamlSubsetError): + continue + + git_gate = fm.get("git-gate") + if not isinstance(git_gate, dict): + continue + repos = git_gate.get("repos") + if not isinstance(repos, dict): + continue + repo = repos.get(repo_name) + if not isinstance(repo, dict): + continue + if repo.get("host_key"): + continue + + updated = _update_frontmatter_in_file(text, repo_name, host_key) + if updated == text: + continue + path.write_text(updated, encoding="utf-8") + info(f"wrote host_key for {repo_name!r} to {path}") + return True + + return False + + +# --------------------------------------------------------------------------- +# Interactive prompt helper +# --------------------------------------------------------------------------- + + +def _prompt_tty(message: str) -> str: + """Write `message` to stderr and read a line from /dev/tty (or stdin).""" + sys.stderr.write(message) + sys.stderr.flush() + try: + with open("/dev/tty", "r", encoding="utf-8") as tty: + return tty.readline().rstrip("\n") + except OSError: + return sys.stdin.readline().rstrip("\n") + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def preflight_host_keys( + manifest: Manifest, + *, + headless: bool, + home_md: Path | None, +) -> Manifest: + """Ensure every git-gate repo entry has a `host_key` configured. + + For entries whose `KnownHostKey` is empty: + - headless: calls `die()` with a clear message naming the repos. + - interactive: fetches the key via ssh-keyscan, shows it to the + operator, and requests confirmation. If accepted, optionally + persists it to the bottle config file on disk; the key is always + applied in memory for this launch regardless of the persistence + choice. Aborted confirmation calls `die()`. + + Returns a (possibly updated) Manifest. If all entries already have + host keys the original manifest is returned unchanged.""" + bottle = manifest.bottle + missing = [e for e in bottle.git if not e.KnownHostKey] + if not missing: + return manifest + + if headless: + names = ", ".join(repr(e.Name) for e in missing) + die( + f"git-gate: no host_key configured for repo(s) {names}. " + f"Add host_key to each bottle git-gate.repos entry, or run " + f"interactively once to have it fetched and saved automatically." + ) + + bottles_dir = (home_md / "bottles") if home_md is not None else None + updated_entries = list(bottle.git) + + for entry in missing: + host = entry.UpstreamHost + port = entry.UpstreamPort + label = f"git-gate.repos[{entry.Name!r}]" + + info(f"{label}: no host_key configured; fetching from {host}:{port}") + try: + key = fetch_host_key(host, port) + except RuntimeError as e: + die(f"git-gate: {label}: {e}") + + sys.stderr.write(f"\ngit-gate: host key for {label}:\n {key}\n\n") + confirm = _prompt_tty("Is this host key correct? [y/N] ") + if confirm.strip().lower() not in ("y", "yes"): + die(f"git-gate: {label}: host key not confirmed; aborting launch") + + if bottles_dir is not None: + save = _prompt_tty( + f"Save host_key for {entry.Name!r} to the bottle config file? [y/N] " + ) + if save.strip().lower() in ("y", "yes"): + ok = _find_and_update_bottle_file(bottles_dir, entry.Name, key) + if not ok: + sys.stderr.write( + f"git-gate: {label}: bottle file not found or not writable; " + f"host_key kept in memory for this session only\n" + ) + + idx = next(i for i, e in enumerate(updated_entries) if e.Name == entry.Name) + updated_entries[idx] = dataclasses.replace(entry, KnownHostKey=key) + + updated_bottle = dataclasses.replace(bottle, git=tuple(updated_entries)) + return dataclasses.replace(manifest, bottle=updated_bottle) + + +__all__ = [ + "fetch_host_key", + "preflight_host_keys", + "_insert_host_key_in_frontmatter", + "_find_and_update_bottle_file", + "_update_frontmatter_in_file", +] diff --git a/tests/unit/test_git_gate_host_key.py b/tests/unit/test_git_gate_host_key.py new file mode 100644 index 0000000..ff7118f --- /dev/null +++ b/tests/unit/test_git_gate_host_key.py @@ -0,0 +1,404 @@ +"""Unit tests for git_gate_host_key (issue #333).""" + +from __future__ import annotations + +import dataclasses +import tempfile +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +from bot_bottle.git_gate_host_key import ( + _find_and_update_bottle_file, + _insert_host_key_in_frontmatter, + _update_frontmatter_in_file, + fetch_host_key, + preflight_host_keys, +) +from bot_bottle.manifest import Manifest, ManifestIndex + + +# --------------------------------------------------------------------------- +# fetch_host_key +# --------------------------------------------------------------------------- + + +class TestFetchHostKey(unittest.TestCase): + def _run_result(self, stdout: str, returncode: int = 0) -> MagicMock: + r = MagicMock() + r.stdout = stdout + r.stderr = "" + r.returncode = returncode + return r + + def test_returns_type_and_key(self) -> None: + stdout = "gitea.example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA\n" + with patch("subprocess.run", return_value=self._run_result(stdout)): + key = fetch_host_key("gitea.example.com", "22") + self.assertEqual("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA", key) + + def test_non_default_port_passes_p_flag(self) -> None: + stdout = "[gitea.example.com]:30009 ssh-ed25519 AAAA\n" + with patch("subprocess.run", return_value=self._run_result(stdout)) as mock_run: + fetch_host_key("gitea.example.com", "30009") + args = mock_run.call_args[0][0] + self.assertIn("-p", args) + self.assertIn("30009", args) + + def test_default_port_omits_p_flag(self) -> None: + stdout = "github.com ssh-ed25519 AAAA\n" + with patch("subprocess.run", return_value=self._run_result(stdout)) as mock_run: + fetch_host_key("github.com", "22") + args = mock_run.call_args[0][0] + self.assertNotIn("-p", args) + + def test_skips_comment_lines(self) -> None: + stdout = "# comment\ngithub.com ssh-ed25519 AAAA\n" + with patch("subprocess.run", return_value=self._run_result(stdout)): + key = fetch_host_key("github.com", "22") + self.assertEqual("ssh-ed25519 AAAA", key) + + def test_raises_on_empty_output(self) -> None: + with patch("subprocess.run", return_value=self._run_result("")): + with self.assertRaises(RuntimeError) as cm: + fetch_host_key("example.com", "22") + self.assertIn("no ed25519 key", str(cm.exception)) + + def test_raises_on_os_error(self) -> None: + with patch("subprocess.run", side_effect=OSError("not found")): + with self.assertRaises(RuntimeError) as cm: + fetch_host_key("example.com", "22") + self.assertIn("could not launch", str(cm.exception)) + + def test_raises_on_timeout(self) -> None: + import subprocess + with patch("subprocess.run", side_effect=subprocess.TimeoutExpired("ssh-keyscan", 15)): + with self.assertRaises(RuntimeError) as cm: + fetch_host_key("example.com", "22") + self.assertIn("timed out", str(cm.exception)) + + +# --------------------------------------------------------------------------- +# _insert_host_key_in_frontmatter +# --------------------------------------------------------------------------- + + +class TestInsertHostKeyInFrontmatter(unittest.TestCase): + _FM = """\ +git-gate: + repos: + myrepo: + url: ssh://git@host/org/repo.git + key: + provider: static + path: /dev/null +""" + + def test_inserts_host_key_after_last_child(self) -> None: + result = _insert_host_key_in_frontmatter(self._FM, "myrepo", "ssh-ed25519 AAAA") + self.assertIn(" host_key: ssh-ed25519 AAAA", result) + + def test_no_change_when_repo_not_found(self) -> None: + result = _insert_host_key_in_frontmatter(self._FM, "other", "ssh-ed25519 AAAA") + self.assertEqual(self._FM, result) + + def test_no_change_when_host_key_already_present(self) -> None: + fm = self._FM + " host_key: ssh-ed25519 EXISTING\n" + result = _insert_host_key_in_frontmatter(fm, "myrepo", "ssh-ed25519 NEW") + self.assertNotIn("NEW", result) + self.assertEqual(fm, result) + + def test_preserves_other_repos(self) -> None: + fm = """\ +git-gate: + repos: + first: + url: ssh://git@host/org/first.git + key: + provider: static + path: /dev/null + second: + url: ssh://git@host/org/second.git + key: + provider: static + path: /dev/null +""" + result = _insert_host_key_in_frontmatter(fm, "first", "ssh-ed25519 AAAA") + self.assertIn("host_key: ssh-ed25519 AAAA", result) + self.assertIn("second:", result) + + def test_detects_child_indent_from_existing_keys(self) -> None: + # 4-space indent (non-standard but valid) + fm = """\ +git-gate: + repos: + myrepo: + url: ssh://git@host/org/repo.git + key: + provider: static + path: /dev/null +""" + result = _insert_host_key_in_frontmatter(fm, "myrepo", "ssh-ed25519 AAAA") + self.assertIn(" host_key: ssh-ed25519 AAAA", result) + + +# --------------------------------------------------------------------------- +# _update_frontmatter_in_file +# --------------------------------------------------------------------------- + + +class TestUpdateFrontmatterInFile(unittest.TestCase): + _FILE = """\ +--- +git-gate: + repos: + myrepo: + url: ssh://git@host/org/repo.git + key: + provider: static + path: /dev/null +--- +# Body text here +""" + + def test_updates_frontmatter_preserves_body(self) -> None: + result = _update_frontmatter_in_file(self._FILE, "myrepo", "ssh-ed25519 AAAA") + self.assertIn("host_key: ssh-ed25519 AAAA", result) + self.assertIn("# Body text here", result) + + def test_no_change_without_frontmatter(self) -> None: + text = "No frontmatter here\n" + result = _update_frontmatter_in_file(text, "myrepo", "ssh-ed25519 AAAA") + self.assertEqual(text, result) + + def test_no_change_when_repo_not_in_file(self) -> None: + result = _update_frontmatter_in_file(self._FILE, "other", "ssh-ed25519 AAAA") + self.assertEqual(self._FILE, result) + + +# --------------------------------------------------------------------------- +# _find_and_update_bottle_file +# --------------------------------------------------------------------------- + + +class TestFindAndUpdateBottleFile(unittest.TestCase): + def _write_bottle(self, d: str, name: str, content: str) -> Path: + path = Path(d) / f"{name}.md" + path.write_text(content, encoding="utf-8") + return path + + def test_updates_file_with_matching_repo(self) -> None: + with tempfile.TemporaryDirectory() as d: + content = """\ +--- +git-gate: + repos: + myrepo: + url: ssh://git@host/org/repo.git + key: + provider: static + path: /dev/null +--- +""" + path = self._write_bottle(d, "dev", content) + result = _find_and_update_bottle_file(Path(d), "myrepo", "ssh-ed25519 AAAA") + self.assertTrue(result) + updated = path.read_text() + self.assertIn("host_key: ssh-ed25519 AAAA", updated) + + def test_returns_false_when_no_matching_file(self) -> None: + with tempfile.TemporaryDirectory() as d: + content = """\ +--- +git-gate: + repos: + other: + url: ssh://git@host/org/other.git + key: + provider: static + path: /dev/null +--- +""" + self._write_bottle(d, "dev", content) + result = _find_and_update_bottle_file(Path(d), "myrepo", "ssh-ed25519 AAAA") + self.assertFalse(result) + + def test_skips_file_that_already_has_host_key(self) -> None: + with tempfile.TemporaryDirectory() as d: + content = """\ +--- +git-gate: + repos: + myrepo: + url: ssh://git@host/org/repo.git + key: + provider: static + path: /dev/null + host_key: ssh-ed25519 EXISTING +--- +""" + path = self._write_bottle(d, "dev", content) + result = _find_and_update_bottle_file(Path(d), "myrepo", "ssh-ed25519 NEW") + self.assertFalse(result) + self.assertNotIn("NEW", path.read_text()) + + def test_returns_false_for_nonexistent_dir(self) -> None: + result = _find_and_update_bottle_file( + Path("/nonexistent/dir"), "myrepo", "ssh-ed25519 AAAA" + ) + self.assertFalse(result) + + +# --------------------------------------------------------------------------- +# preflight_host_keys +# --------------------------------------------------------------------------- + + +def _manifest_with_git(host_key: str = "") -> Manifest: + """Build a Manifest with one git entry; host_key defaults to empty.""" + return ManifestIndex.from_json_obj({ + "bottles": { + "dev": { + "git-gate": { + "repos": { + "myrepo": { + "url": "ssh://git@gitea.example.com:30009/org/myrepo.git", + "key": {"provider": "static", "path": "/dev/null"}, + **({"host_key": host_key} if host_key else {}), + }, + }, + }, + }, + }, + "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, + }).load_for_agent("demo") + + +class TestPreflightHostKeys(unittest.TestCase): + def test_no_op_when_all_keys_present(self) -> None: + manifest = _manifest_with_git("ssh-ed25519 AAAA") + result = preflight_host_keys(manifest, headless=False, home_md=None) + self.assertIs(result, manifest) + + def test_headless_dies_when_key_missing(self) -> None: + manifest = _manifest_with_git() + from bot_bottle.log import die as real_die + with self.assertRaises(SystemExit): + preflight_host_keys(manifest, headless=True, home_md=None) + + def test_interactive_fetches_and_confirms_key(self) -> None: + manifest = _manifest_with_git() + replies = iter(["y", "n"]) # confirm key, don't persist + + with patch( + "bot_bottle.git_gate_host_key.fetch_host_key", + return_value="ssh-ed25519 FETCHED", + ), patch( + "bot_bottle.git_gate_host_key._prompt_tty", + side_effect=lambda msg: next(replies), + ), patch("sys.stderr"): + result = preflight_host_keys(manifest, headless=False, home_md=None) + + self.assertEqual("ssh-ed25519 FETCHED", result.bottle.git[0].KnownHostKey) + + def test_interactive_dies_when_key_not_confirmed(self) -> None: + manifest = _manifest_with_git() + + with patch( + "bot_bottle.git_gate_host_key.fetch_host_key", + return_value="ssh-ed25519 FETCHED", + ), patch( + "bot_bottle.git_gate_host_key._prompt_tty", + return_value="n", + ), patch("sys.stderr"), self.assertRaises(SystemExit): + preflight_host_keys(manifest, headless=False, home_md=None) + + def test_interactive_persists_when_requested(self) -> None: + manifest = _manifest_with_git() + replies = iter(["y", "y"]) # confirm key, yes persist + + with tempfile.TemporaryDirectory() as home: + bottles_dir = Path(home) / "bottles" + bottles_dir.mkdir() + bottle_file = bottles_dir / "dev.md" + bottle_file.write_text( + "---\ngit-gate:\n repos:\n myrepo:\n" + " url: ssh://git@gitea.example.com:30009/org/myrepo.git\n" + " key:\n provider: static\n path: /dev/null\n---\n", + encoding="utf-8", + ) + + with patch( + "bot_bottle.git_gate_host_key.fetch_host_key", + return_value="ssh-ed25519 FETCHED", + ), patch( + "bot_bottle.git_gate_host_key._prompt_tty", + side_effect=lambda msg: next(replies), + ), patch("sys.stderr"): + result = preflight_host_keys( + manifest, headless=False, home_md=Path(home), + ) + + self.assertEqual("ssh-ed25519 FETCHED", result.bottle.git[0].KnownHostKey) + written = bottle_file.read_text() + self.assertIn("host_key: ssh-ed25519 FETCHED", written) + + def test_interactive_skips_persist_when_declined(self) -> None: + manifest = _manifest_with_git() + replies = iter(["y", "n"]) # confirm key, no persist + + with tempfile.TemporaryDirectory() as home: + bottles_dir = Path(home) / "bottles" + bottles_dir.mkdir() + bottle_file = bottles_dir / "dev.md" + bottle_file.write_text( + "---\ngit-gate:\n repos:\n myrepo:\n" + " url: ssh://git@gitea.example.com:30009/org/myrepo.git\n" + " key:\n provider: static\n path: /dev/null\n---\n", + encoding="utf-8", + ) + + with patch( + "bot_bottle.git_gate_host_key.fetch_host_key", + return_value="ssh-ed25519 FETCHED", + ), patch( + "bot_bottle.git_gate_host_key._prompt_tty", + side_effect=lambda msg: next(replies), + ), patch("sys.stderr"): + result = preflight_host_keys( + manifest, headless=False, home_md=Path(home), + ) + + self.assertEqual("ssh-ed25519 FETCHED", result.bottle.git[0].KnownHostKey) + written = bottle_file.read_text() + self.assertNotIn("host_key", written) + + def test_headless_names_all_missing_repos_in_error(self) -> None: + manifest = ManifestIndex.from_json_obj({ + "bottles": { + "dev": { + "git-gate": { + "repos": { + "alpha": { + "url": "ssh://git@host/org/alpha.git", + "key": {"provider": "static", "path": "/dev/null"}, + }, + "beta": { + "url": "ssh://git@host/org/beta.git", + "key": {"provider": "static", "path": "/dev/null"}, + }, + }, + }, + }, + }, + "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, + }).load_for_agent("demo") + + import io + buf = io.StringIO() + with patch("sys.stderr", buf), self.assertRaises(SystemExit): + preflight_host_keys(manifest, headless=True, home_md=None) + + +if __name__ == "__main__": + unittest.main()