diff --git a/README.md b/README.md index 0360998..7b72d64 100644 --- a/README.md +++ b/README.md @@ -108,8 +108,8 @@ Before the first release, configure PyPI Trusted Publishing for this repository Release example: ```bash -git tag 0.1.1 -git push origin 0.1.1 +git tag 0.1.2 +git push origin 0.1.2 ``` Tags like `v0.1.0`, `0.1`, or `0.1.0rc1` will not publish. diff --git a/pyproject.toml b/pyproject.toml index 793f700..50cb24e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "hermes-github-app-plugin" -version = "0.1.1" +version = "0.1.2" description = "Hermes plugin for per-agent GitHub App identity, gh/git wrappers, and GitHub App-aware tools." readme = "README.md" requires-python = ">=3.10" diff --git a/src/hermes_github_app_plugin/auth.py b/src/hermes_github_app_plugin/auth.py index b92b252..b835402 100644 --- a/src/hermes_github_app_plugin/auth.py +++ b/src/hermes_github_app_plugin/auth.py @@ -84,6 +84,46 @@ def get_installation_token(self, *, force_refresh: bool = False) -> Installation self._cached_token = token return token + def app_request( + self, + method: str, + path: str, + *, + json_body: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, + ) -> dict[str, Any]: + """Call a GitHub App endpoint using the app JWT. + + Endpoints like `GET /app` authenticate as the GitHub App itself and + reject installation access tokens. Repository and user endpoints should + continue to use `request()`, which authenticates as the installation. + """ + 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 {self.create_jwt()}", + "X-GitHub-Api-Version": "2022-11-28", + }, + json=json_body, + params=params, + ) + response.raise_for_status() + return { + "auth": { + "auth_mode": "github_app_jwt", + "client_id": self._config.client_id, + "app_slug": self._config.app_slug, + "installation_id": self._config.installation_id, + }, + "status_code": response.status_code, + "result": response.json() if response.content else {"ok": True}, + } + def request( self, method: str, diff --git a/src/hermes_github_app_plugin/cli.py b/src/hermes_github_app_plugin/cli.py index d53b476..c569b47 100644 --- a/src/hermes_github_app_plugin/cli.py +++ b/src/hermes_github_app_plugin/cli.py @@ -196,7 +196,7 @@ def _doctor(repo: str | None, *, skip_network: bool) -> int: 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"] + app_result = auth.app_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"] @@ -225,7 +225,7 @@ 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"] + app = auth.app_request("GET", "/app")["result"] repo_probe = auth.request("GET", f"/repos/{repo}", repo=repo)["result"] if repo else None print( json.dumps( diff --git a/tests/test_auth.py b/tests/test_auth.py index dd980fb..6d80f2c 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -59,3 +59,28 @@ def handler(request: httpx.Request) -> httpx.Response: 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" + + +def test_app_request_uses_app_jwt(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) + return httpx.Response(200, json={"slug": "hermes-test-agent"}) + + 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).app_request("GET", "/app") + + assert result["result"] == {"slug": "hermes-test-agent"} + assert seen[0].url.path == "/app" + assert seen[0].headers["authorization"] == "Bearer jwt-token" + assert result["auth"]["auth_mode"] == "github_app_jwt"