Programmatically populate host_key in git-gate #335

Merged
didericis merged 7 commits from git-gate-populate-host-key into main 2026-07-09 13:53:33 -04:00
Collaborator

Closes #333.

Summary

  • Adds bot_bottle/git_gate_host_key.py with preflight_host_keys() which runs during BottleBackend.prepare() after manifest validation.
  • Headless: if any git-gate repo entry lacks host_key, the launch is aborted with a clear error naming the repos.
  • Interactive: fetches the key via ssh-keyscan (Python stdlib only), displays it to the operator, requests confirmation, and optionally persists it to the bottle .md config file on disk. The key is always applied in-memory for the current launch regardless of persistence choice.
  • Adds headless: bool = False to BottleSpec; --headless launches set it to True.
  • 26 new unit tests covering fetch_host_key, frontmatter text editing, file scanning/update, and all preflight_host_keys paths.
Closes #333. ## Summary - Adds `bot_bottle/git_gate_host_key.py` with `preflight_host_keys()` which runs during `BottleBackend.prepare()` after manifest validation. - **Headless**: if any git-gate repo entry lacks `host_key`, the launch is aborted with a clear error naming the repos. - **Interactive**: fetches the key via `ssh-keyscan` (Python stdlib only), displays it to the operator, requests confirmation, and optionally persists it to the bottle `.md` config file on disk. The key is always applied in-memory for the current launch regardless of persistence choice. - Adds `headless: bool = False` to `BottleSpec`; `--headless` launches set it to `True`. - 26 new unit tests covering `fetch_host_key`, frontmatter text editing, file scanning/update, and all `preflight_host_keys` paths.
didericis-claude added 1 commit 2026-07-09 11:52:13 -04:00
feat: preflight git-gate host key population
lint / lint (push) Failing after 2m3s
test / unit (pull_request) Successful in 1m3s
test / integration (pull_request) Successful in 22s
test / coverage (pull_request) Failing after 1m11s
21d03b7cc9
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 <noreply@anthropic.com>
didericis added 1 commit 2026-07-09 12:03:51 -04:00
fix: pyright/pylint clean-up and 100% branch coverage
lint / lint (push) Failing after 2m17s
test / unit (pull_request) Successful in 1m5s
test / integration (pull_request) Successful in 20s
test / coverage (pull_request) Successful in 1m12s
e89dffa899
- Remove unused ManifestBottle/ManifestGitEntry imports
- Add check=False to subprocess.run (pylint subprocess-run-check)
- Add `from e` to TimeoutExpired re-raise (raise-missing-from)
- Catch UnicodeDecodeError alongside OSError/YamlSubsetError in
  _find_and_update_bottle_file
- Add 14 new test cases covering: malformed ssh-keyscan output lines,
  stderr in error messages, blank lines inside frontmatter blocks,
  repo entry with no children (child_indent fallback), missing closing
  `---` delimiter, files without git-gate section, non-dict repos
  section, invalid UTF-8 files, unreadable files, update producing no
  text change, _prompt_tty TTY read and stdin fallback, fetch failure
  in preflight, and the "persist failed / kept in memory" warning path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
didericis added 1 commit 2026-07-09 12:11:27 -04:00
fix: pyright errors in test file, bump pyright floor to 1.1.411
lint / lint (push) Successful in 2m10s
test / unit (pull_request) Successful in 56s
test / integration (pull_request) Successful in 17s
test / coverage (pull_request) Successful in 1m10s
f9662c88a5
- Remove unused `import dataclasses` and stray `die as real_die` import
- Replace lambda side_effect callbacks with list side_effect; lambdas
  with untyped parameters trigger reportUnknownLambdaType in pyright
  >=1.1.400 — side_effect=[...] is both cleaner and type-safe
- Bump pyright floor from >=1.1.300 to >=1.1.411 in requirements-dev.txt
  so CI installs a version that matches what the lint job runs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
didericis added 1 commit 2026-07-09 12:45:58 -04:00
fix(git-gate): accept any key type from ssh-keyscan, prefer ed25519
lint / lint (push) Successful in 2m0s
test / unit (pull_request) Successful in 53s
test / integration (pull_request) Successful in 16s
test / coverage (pull_request) Successful in 1m6s
61740cdb6a
Servers that don't offer ed25519 (ecdsa-only or rsa-only gitea
instances) would raise "ssh-keyscan returned no ed25519 key" because
`-t ed25519` produced no output.

Remove the `-t ed25519` restriction: ssh-keyscan now returns all
supported key types and the function picks the best available via
_KEY_TYPE_PREFERENCE (ed25519 > ecdsa > rsa > first).

Also adds four new fetch_host_key tests covering multi-type preference
logic and updates the stale "no ed25519 key" assertion.
didericis reviewed 2026-07-09 12:54:14 -04:00
@@ -0,0 +85,4 @@
# ---------------------------------------------------------------------------
def _insert_host_key_in_frontmatter(fm_text: str, repo_name: str, host_key: str) -> str:
Owner

This is incorrect (host_key ended up being persisted under git-gate.repos.bot-bottle.key instead of git-gate.repos.bot-bottle), and the implementation is overly complicated: should instead parse the yaml into a nested dict, insert the host_key in the correct location in the nested dict, and then serialize it as yaml again.

This is incorrect (`host_key` ended up being persisted under `git-gate.repos.bot-bottle.key` instead of `git-gate.repos.bot-bottle`), and the implementation is overly complicated: should instead parse the yaml into a nested dict, insert the `host_key` in the correct location in the nested dict, and then serialize it as yaml again.
didericis-claude reviewed 2026-07-09 12:56:36 -04:00
@@ -0,0 +85,4 @@
# ---------------------------------------------------------------------------
def _insert_host_key_in_frontmatter(fm_text: str, repo_name: str, host_key: str) -> str:
Author
Collaborator

Confirmed — the bug is that child_indent is overwritten on every line with indent > repo_indent, including grandchildren, so it ends up at the deepest nesting level (key.provider/key.path indent) rather than the direct child level. That causes host_key to land inside the key: block.

Will replace _insert_host_key_in_frontmatter (and _update_frontmatter_in_file) with a parse → mutate → re-serialize approach: use yaml_subset.parse_frontmatter() to get the dict, set fm["git-gate"]["repos"][repo_name]["host_key"], then serialize back with a new serialize_yaml_subset() added to yaml_subset.py. Since the project is stdlib-only, the serializer will handle the bounded shape (nested dicts and string scalars) that bot-bottle configs use.

Confirmed — the bug is that `child_indent` is overwritten on every line with `indent > repo_indent`, including grandchildren, so it ends up at the deepest nesting level (`key.provider`/`key.path` indent) rather than the direct child level. That causes `host_key` to land inside the `key:` block. Will replace `_insert_host_key_in_frontmatter` (and `_update_frontmatter_in_file`) with a parse → mutate → re-serialize approach: use `yaml_subset.parse_frontmatter()` to get the dict, set `fm["git-gate"]["repos"][repo_name]["host_key"]`, then serialize back with a new `serialize_yaml_subset()` added to `yaml_subset.py`. Since the project is stdlib-only, the serializer will handle the bounded shape (nested dicts and string scalars) that bot-bottle configs use.
Owner

@didericis-claude small DX improvement: show the name of the bottle config file that will be changed during the save host key confirmation step

@didericis-claude small DX improvement: show the name of the bottle config file that will be changed during the save host key confirmation step
didericis added 2 commits 2026-07-09 13:26:30 -04:00
The regex-based _insert_host_key_in_frontmatter had a bug: child_indent
was overwritten on every line with indent > repo_indent (including
grandchildren), so host_key landed inside the key: block instead of at
the repo level.

Replace with a clean parse → mutate → re-serialize approach:
- Add serialize_yaml_subset() to yaml_subset.py (block-style, 2-space
  indent, round-trips through parse_yaml_subset)
- Replace _insert_host_key_in_frontmatter + _update_frontmatter_in_file
  with _add_host_key_to_frontmatter that parses the frontmatter dict,
  sets fm["git-gate"]["repos"][name]["host_key"], and re-serializes
- _find_and_update_bottle_file simplified to read file → delegate to
  _add_host_key_to_frontmatter → write if changed
feat(git-gate): show bottle filename in save-host-key prompt; public API
lint / lint (push) Successful in 1m58s
test / unit (pull_request) Failing after 51s
test / integration (pull_request) Successful in 19s
test / coverage (pull_request) Failing after 54s
e27bd66080
DX improvement: find_repo_bottle_file() is called before the save
prompt so the user sees exactly which file will be updated:
  Save host_key for 'myrepo' to /home/.../bottles/dev.md? [y/N]
If no bottle file is found the prompt is skipped and a clear message
explains that the key is session-only.

Also:
- Use find_repo_bottle_file() inside find_and_update_bottle_file()
  instead of duplicating the scan logic
- Rename all module-level helpers to public names (no underscore prefix)
  since they are imported and tested externally
Author
Collaborator

Done. find_repo_bottle_file() is now called before the save prompt so the filename appears inline:

Save host_key for 'myrepo' to /home/.../bottles/dev.md? [y/N]

If no bottle file is found the prompt is skipped entirely and a message explains the key is session-only. Also used find_repo_bottle_file() inside find_and_update_bottle_file() to eliminate the duplicate scan logic, and renamed all helpers to public names (no underscore prefix) since they are tested via direct import.

Done. `find_repo_bottle_file()` is now called before the save prompt so the filename appears inline: ``` Save host_key for 'myrepo' to /home/.../bottles/dev.md? [y/N] ``` If no bottle file is found the prompt is skipped entirely and a message explains the key is session-only. Also used `find_repo_bottle_file()` inside `find_and_update_bottle_file()` to eliminate the duplicate scan logic, and renamed all helpers to public names (no underscore prefix) since they are tested via direct import.
didericis added 1 commit 2026-07-09 13:35:32 -04:00
fix(tests): replace os.chmod with mock for unreadable-file tests
test / unit (pull_request) Successful in 59s
test / integration (pull_request) Successful in 20s
test / coverage (pull_request) Successful in 1m13s
lint / lint (push) Successful in 2m1s
test / unit (push) Successful in 59s
test / integration (push) Successful in 17s
test / coverage (push) Successful in 1m4s
Update Quality Badges / update-badges (push) Successful in 57s
fa7c6ab9d8
CI runs as root, so chmod 0o000 doesn't prevent reads. Both
test_skips_unreadable_file (TestFindRepoBottleFile) and
test_returns_false_when_file_unreadable_on_write now mock
Path.read_text to raise OSError directly.
didericis merged commit fa7c6ab9d8 into main 2026-07-09 13:53:33 -04:00
didericis deleted branch git-gate-populate-host-key 2026-07-09 13:53:34 -04:00
Sign in to join this conversation.