Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ Each Hermes agent runs the same package but is configured with its own GitHub Ap
```yaml
github_app:
client_id: "Iv1.exampleclientid"
# Optional default used when a command cannot infer OWNER/REPO.
installation_id: "987654"
# Optional owner-specific installation IDs for apps installed in multiple orgs.
installation_ids:
ExampleOrg: "123456"
ExampleInfra: "987654"
private_key_path: "~/.hermes/secrets/agent-github-app.private-key.pem"
app_slug: "hermes-agent"
```
Expand All @@ -16,6 +21,7 @@ Environment variables with the same meaning are also supported:

- `GITHUB_APP_CLIENT_ID`
- `GITHUB_APP_INSTALLATION_ID`
- `GITHUB_APP_INSTALLATION_IDS` (JSON object or `OWNER=ID,OWNER2=ID2` mapping)
- `GITHUB_APP_PRIVATE_KEY_PATH`
- `GITHUB_APP_PRIVATE_KEY` (PEM contents; useful for CI)

Expand All @@ -27,7 +33,7 @@ Repository access is controlled by the GitHub App installation scope in GitHub.

`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.
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. Configure `installation_ids` when one app is installed in multiple organizations; the plugin chooses an owner-specific ID from `--repo OWNER/REPO` or `/repos/OWNER/REPO` REST paths and falls back to `installation_id` when no repository owner is available.

## Install

Expand All @@ -53,6 +59,8 @@ For scripted installs, pass flags and skip the network verification until secret
hermes-github-app setup --non-interactive --skip-verify \
--client-id Iv1.exampleclientid \
--installation-id 987654 \
--installation-id-for ExampleOrg=123456 \
--installation-id-for ExampleInfra=987654 \
--private-key-path ~/.hermes/secrets/agent-github-app.private-key.pem \
--app-slug hermes-agent
```
Expand Down
5 changes: 4 additions & 1 deletion plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ requires_env:
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."
description: "Default 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_INSTALLATION_IDS
description: "Optional owner-to-installation-ID mapping for apps installed in multiple orgs. JSON object or OWNER=ID,OWNER2=ID2. Can also be set under github_app.installation_ids."
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."
Expand Down
12 changes: 11 additions & 1 deletion skills/github-app-workflow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,17 @@ Use this skill for GitHub operations from Hermes agents that are configured with

## 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.
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, a default installation ID or owner-specific `installation_ids`, and private key path. For apps installed in multiple organizations, pass repeated `--installation-id-for OWNER=ID` flags or configure:

```yaml
github_app:
installation_id: "123456" # default/fallback
installation_ids:
ExampleOrg: "123456"
ExampleInfra: "789012"
```

The plugin chooses an owner-specific installation ID from `--repo OWNER/REPO` or `/repos/OWNER/REPO` REST API paths and falls back to `installation_id` when no repository owner is available.

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.

Expand Down
48 changes: 35 additions & 13 deletions src/hermes_github_app_plugin/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import re
import time
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
Expand Down Expand Up @@ -40,7 +41,7 @@ class GitHubAppAuth:
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
self._cached_tokens: dict[str, InstallationToken] = {}

@property
def config(self) -> GitHubAppConfig:
Expand All @@ -53,18 +54,21 @@ def create_jwt(self) -> str:
encoded = jwt.encode(payload, self._config.private_key, algorithm="RS256")
return str(encoded)

def get_installation_token(self, *, force_refresh: bool = False) -> InstallationToken:
def get_installation_token(
self, *, repo: str | None = None, force_refresh: bool = False
) -> InstallationToken:
"""Return a valid installation token, refreshing when near expiry."""
installation_id = self._config.installation_id_for_repo(repo)
cached_token = self._cached_tokens.get(installation_id)
if (
not force_refresh
and self._cached_token is not None
and self._cached_token.expires_at > datetime.now(timezone.utc) + timedelta(minutes=5)
and cached_token is not None
and cached_token.expires_at > datetime.now(timezone.utc) + timedelta(minutes=5)
):
return self._cached_token
return cached_token

response = self._client.post(
f"{self._config.github_api_url}/app/installations/"
f"{self._config.installation_id}/access_tokens",
f"{self._config.github_api_url}/app/installations/{installation_id}/access_tokens",
headers={
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {self.create_jwt()}",
Expand All @@ -78,11 +82,11 @@ def get_installation_token(self, *, force_refresh: bool = False) -> Installation
token = InstallationToken(
token=str(data["token"]),
expires_at=expires_at,
installation_id=self._config.installation_id,
installation_id=installation_id,
client_id=self._config.client_id,
app_slug=self._config.app_slug,
)
self._cached_token = token
self._cached_tokens[installation_id] = token
return token

def app_request(
Expand Down Expand Up @@ -120,6 +124,7 @@ def app_request(
"client_id": self._config.client_id,
"app_slug": self._config.app_slug,
"installation_id": self._config.installation_id,
"installation_ids": dict(self._config.installation_ids),
},
"status_code": response.status_code,
"result": response.json() if response.content else {"ok": True},
Expand All @@ -135,7 +140,8 @@ def request(
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Call the GitHub REST API using the installation token."""
token = self.get_installation_token()
repo = repo or repo_from_path(path)
token = self.get_installation_token(repo=repo)
url = (
path if path.startswith("http") else f"{self._config.github_api_url}/{path.lstrip('/')}"
)
Expand All @@ -158,9 +164,11 @@ def request(
"result": parsed,
}

def graphql(self, query: str, variables: dict[str, Any] | None = None) -> dict[str, Any]:
def graphql(
self, query: str, variables: dict[str, Any] | None = None, *, repo: str | None = None
) -> dict[str, Any]:
"""Call GitHub GraphQL API using the installation token."""
token = self.get_installation_token()
token = self.get_installation_token(repo=repo)
response = self._client.post(
f"{self._config.github_api_url}/graphql",
headers={
Expand All @@ -172,7 +180,7 @@ def graphql(self, query: str, variables: dict[str, Any] | None = None) -> dict[s
)
response.raise_for_status()
return {
"auth": auth_metadata(token),
"auth": auth_metadata(token, repo=repo),
"status_code": response.status_code,
"result": response.json(),
}
Expand Down Expand Up @@ -203,3 +211,17 @@ def requires_app_jwt(path: str) -> bool:
return False
normalized = "/" + path_only.lstrip("/")
return normalized == "/app" or normalized.startswith("/app/")


def repo_from_path(path: str) -> str | None:
"""Infer OWNER/REPO from common REST API repository paths."""
path_only = path.split("?", 1)[0]
if path_only.startswith("http"):
try:
path_only = urlparse(path_only).path
except ValueError:
return None
match = re.match(r"^/?repos/([^/]+)/([^/]+)(?:/|$)", path_only)
if not match:
return None
return f"{match.group(1)}/{match.group(2)}"
46 changes: 37 additions & 9 deletions src/hermes_github_app_plugin/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,14 @@ def register_cli(parser: argparse.ArgumentParser) -> None:
"--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("--installation-id", help="Default GitHub App installation ID")
setup.add_argument(
"--installation-id-for",
action="append",
default=[],
metavar="OWNER=ID",
help="Owner-specific installation ID mapping; repeat for multiple orgs",
)
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(
Expand Down Expand Up @@ -87,9 +94,9 @@ def gh_app_main() -> NoReturn:
print("usage: gh-app [--repo OWNER/REPO] [--] <gh args...>")
print("Runs gh with GH_TOKEN/GITHUB_TOKEN set to a GitHub App installation token.")
raise SystemExit(0)
_, child_args = _extract_repo(args)
repo, child_args = _extract_repo(args)
config = load_config()
token = GitHubAppAuth(config).get_installation_token()
token = GitHubAppAuth(config).get_installation_token(repo=repo)
env = os.environ.copy()
env["GH_TOKEN"] = token.token
env["GITHUB_TOKEN"] = token.token
Expand All @@ -102,9 +109,9 @@ def git_app_main() -> NoReturn:
print("usage: git-app [--repo OWNER/REPO] [--] <git args...>")
print("Runs git with a temporary askpass helper backed by a GitHub App token.")
raise SystemExit(0)
_, child_args = _extract_repo(sys.argv[1:])
repo, child_args = _extract_repo(sys.argv[1:])
config = load_config()
token = GitHubAppAuth(config).get_installation_token()
token = GitHubAppAuth(config).get_installation_token(repo=repo)
with tempfile.TemporaryDirectory(prefix="git-app-") as temp_dir:
askpass = Path(temp_dir) / "askpass.sh"
askpass.write_text(
Expand All @@ -127,6 +134,7 @@ 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)'.")
installation_ids = _parse_installation_id_for_args(list(args.installation_id_for))
values = {
"client_id": _value_or_prompt(
args.client_id,
Expand All @@ -139,9 +147,10 @@ def _setup(args: argparse.Namespace) -> int:
args.installation_id,
"GitHub App installation ID",
env="GITHUB_APP_INSTALLATION_ID",
required=True,
required=not installation_ids,
non_interactive=bool(args.non_interactive),
),
"installation_ids": installation_ids,
"private_key_path": _value_or_prompt(
args.private_key_path,
"GitHub App private key path",
Expand Down Expand Up @@ -194,7 +203,7 @@ def _doctor(repo: str | None, *, skip_network: bool) -> int:
)
if not skip_network:
auth = GitHubAppAuth(config)
token = auth.get_installation_token(force_refresh=True)
token = auth.get_installation_token(repo=repo, force_refresh=True)
checks.append(("installation token minted", True, token.redacted))
app_result = auth.app_request("GET", "/app")["result"]
checks.append(("/app API reachable", True, str(app_result.get("slug", "ok"))))
Expand Down Expand Up @@ -224,7 +233,7 @@ def _doctor(repo: str | None, *, skip_network: bool) -> int:
def _status(repo: str | None) -> int:
config = load_config()
auth = GitHubAppAuth(config)
token = auth.get_installation_token(force_refresh=True)
token = auth.get_installation_token(repo=repo, force_refresh=True)
app = auth.app_request("GET", "/app")["result"]
repo_probe = auth.request("GET", f"/repos/{repo}", repo=repo)["result"] if repo else None
print(
Expand All @@ -244,7 +253,7 @@ def _status(repo: str | None) -> int:

def _token(repo: str | None, *, json_output: bool) -> int:
config = load_config()
token = GitHubAppAuth(config).get_installation_token()
token = GitHubAppAuth(config).get_installation_token(repo=repo)
if json_output:
print(json.dumps({"token": token.token, "auth": auth_metadata(token, repo=repo)}, indent=2))
else:
Expand All @@ -262,6 +271,25 @@ def _api(method: str, path: str, *, repo: str | None, body: dict[str, Any] | Non
return 0


def _parse_installation_id_for_args(values: list[str]) -> dict[str, str]:
"""Parse repeated OWNER=INSTALLATION_ID setup flags."""
installation_ids: dict[str, str] = {}
for value in values:
if "=" not in value:
raise ConfigurationError(
f"invalid --installation-id-for value: {value!r}; expected OWNER=INSTALLATION_ID"
)
owner, installation_id = value.split("=", 1)
owner = owner.strip().lower()
installation_id = installation_id.strip()
if not owner or not installation_id:
raise ConfigurationError(
f"invalid --installation-id-for value: {value!r}; expected OWNER=INSTALLATION_ID"
)
installation_ids[owner] = installation_id
return installation_ids


def _value_or_prompt(
value: str | None,
label: str,
Expand Down
Loading