hermes-github-app-plugin 0.1.1__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (21) hide show
  1. {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/PKG-INFO +3 -3
  2. {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/README.md +2 -2
  3. {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/pyproject.toml +1 -1
  4. {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/src/hermes_github_app_plugin/auth.py +53 -0
  5. {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/src/hermes_github_app_plugin/cli.py +8 -4
  6. {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/src/hermes_github_app_plugin/tools.py +8 -3
  7. {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/tests/test_auth.py +38 -1
  8. {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/tests/test_cli.py +53 -1
  9. {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/.github/workflows/cd.yaml +0 -0
  10. {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/.github/workflows/ci.yaml +0 -0
  11. {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/.gitignore +0 -0
  12. {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/LICENSE +0 -0
  13. {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/plugin.yaml +0 -0
  14. {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/skills/github-app-workflow/SKILL.md +0 -0
  15. {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/src/hermes_github_app_plugin/__init__.py +0 -0
  16. {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/src/hermes_github_app_plugin/config.py +0 -0
  17. {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/src/hermes_github_app_plugin/py.typed +0 -0
  18. {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/src/hermes_github_app_plugin/schemas.py +0 -0
  19. {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/src/hermes_github_app_plugin/skills/github-app-workflow/SKILL.md +0 -0
  20. {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/tests/test_config.py +0 -0
  21. {hermes_github_app_plugin-0.1.1 → hermes_github_app_plugin-0.1.2}/tests/test_plugin.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hermes-github-app-plugin
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Hermes plugin for per-agent GitHub App identity, gh/git wrappers, and GitHub App-aware tools.
5
5
  Author: Hermes GitHub App Plugin Contributors
6
6
  License: MIT
@@ -127,8 +127,8 @@ Before the first release, configure PyPI Trusted Publishing for this repository
127
127
  Release example:
128
128
 
129
129
  ```bash
130
- git tag 0.1.1
131
- git push origin 0.1.1
130
+ git tag 0.1.2
131
+ git push origin 0.1.2
132
132
  ```
133
133
 
134
134
  Tags like `v0.1.0`, `0.1`, or `0.1.0rc1` will not publish.
@@ -108,8 +108,8 @@ Before the first release, configure PyPI Trusted Publishing for this repository
108
108
  Release example:
109
109
 
110
110
  ```bash
111
- git tag 0.1.1
112
- git push origin 0.1.1
111
+ git tag 0.1.2
112
+ git push origin 0.1.2
113
113
  ```
114
114
 
115
115
  Tags like `v0.1.0`, `0.1`, or `0.1.0rc1` will not publish.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hermes-github-app-plugin"
7
- version = "0.1.1"
7
+ version = "0.1.2"
8
8
  description = "Hermes plugin for per-agent GitHub App identity, gh/git wrappers, and GitHub App-aware tools."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -6,6 +6,7 @@ import time
6
6
  from dataclasses import dataclass
7
7
  from datetime import datetime, timedelta, timezone
8
8
  from typing import Any
9
+ from urllib.parse import urlparse
9
10
 
10
11
  import httpx
11
12
  import jwt
@@ -84,6 +85,46 @@ class GitHubAppAuth:
84
85
  self._cached_token = token
85
86
  return token
86
87
 
88
+ def app_request(
89
+ self,
90
+ method: str,
91
+ path: str,
92
+ *,
93
+ json_body: dict[str, Any] | None = None,
94
+ params: dict[str, Any] | None = None,
95
+ ) -> dict[str, Any]:
96
+ """Call a GitHub App endpoint using the app JWT.
97
+
98
+ Endpoints like `GET /app` authenticate as the GitHub App itself and
99
+ reject installation access tokens. Repository and user endpoints should
100
+ continue to use `request()`, which authenticates as the installation.
101
+ """
102
+ url = (
103
+ path if path.startswith("http") else f"{self._config.github_api_url}/{path.lstrip('/')}"
104
+ )
105
+ response = self._client.request(
106
+ method.upper(),
107
+ url,
108
+ headers={
109
+ "Accept": "application/vnd.github+json",
110
+ "Authorization": f"Bearer {self.create_jwt()}",
111
+ "X-GitHub-Api-Version": "2022-11-28",
112
+ },
113
+ json=json_body,
114
+ params=params,
115
+ )
116
+ response.raise_for_status()
117
+ return {
118
+ "auth": {
119
+ "auth_mode": "github_app_jwt",
120
+ "client_id": self._config.client_id,
121
+ "app_slug": self._config.app_slug,
122
+ "installation_id": self._config.installation_id,
123
+ },
124
+ "status_code": response.status_code,
125
+ "result": response.json() if response.content else {"ok": True},
126
+ }
127
+
87
128
  def request(
88
129
  self,
89
130
  method: str,
@@ -150,3 +191,15 @@ def auth_metadata(token: InstallationToken, *, repo: str | None = None) -> dict[
150
191
  "token": token.redacted,
151
192
  "expires_at": token.expires_at.isoformat(),
152
193
  }
194
+
195
+
196
+ def requires_app_jwt(path: str) -> bool:
197
+ """Return True for GitHub App endpoints that require app JWT auth."""
198
+ path_only = path.split("?", 1)[0]
199
+ if path_only.startswith("http"):
200
+ try:
201
+ path_only = urlparse(path_only).path
202
+ except ValueError:
203
+ return False
204
+ normalized = "/" + path_only.lstrip("/")
205
+ return normalized == "/app" or normalized.startswith("/app/")
@@ -15,7 +15,7 @@ from typing import Any, NoReturn
15
15
 
16
16
  import httpx
17
17
 
18
- from .auth import GitHubAppAuth, auth_metadata
18
+ from .auth import GitHubAppAuth, auth_metadata, requires_app_jwt
19
19
  from .config import ConfigurationError, load_config, write_github_app_config
20
20
 
21
21
 
@@ -196,7 +196,7 @@ def _doctor(repo: str | None, *, skip_network: bool) -> int:
196
196
  auth = GitHubAppAuth(config)
197
197
  token = auth.get_installation_token(force_refresh=True)
198
198
  checks.append(("installation token minted", True, token.redacted))
199
- app_result = auth.request("GET", "/app")["result"]
199
+ app_result = auth.app_request("GET", "/app")["result"]
200
200
  checks.append(("/app API reachable", True, str(app_result.get("slug", "ok"))))
201
201
  if repo:
202
202
  repo_result = auth.request("GET", f"/repos/{repo}", repo=repo)["result"]
@@ -225,7 +225,7 @@ def _status(repo: str | None) -> int:
225
225
  config = load_config()
226
226
  auth = GitHubAppAuth(config)
227
227
  token = auth.get_installation_token(force_refresh=True)
228
- app = auth.request("GET", "/app")["result"]
228
+ app = auth.app_request("GET", "/app")["result"]
229
229
  repo_probe = auth.request("GET", f"/repos/{repo}", repo=repo)["result"] if repo else None
230
230
  print(
231
231
  json.dumps(
@@ -253,7 +253,11 @@ def _token(repo: str | None, *, json_output: bool) -> int:
253
253
 
254
254
 
255
255
  def _api(method: str, path: str, *, repo: str | None, body: dict[str, Any] | None) -> int:
256
- result = GitHubAppAuth(load_config()).request(method, path, repo=repo, json_body=body)
256
+ auth = GitHubAppAuth(load_config())
257
+ if requires_app_jwt(path):
258
+ result = auth.app_request(method, path, json_body=body)
259
+ else:
260
+ result = auth.request(method, path, repo=repo, json_body=body)
257
261
  print(json.dumps(result, indent=2, sort_keys=True))
258
262
  return 0
259
263
 
@@ -7,7 +7,7 @@ from typing import Any
7
7
 
8
8
  import httpx
9
9
 
10
- from .auth import GitHubAppAuth, auth_metadata
10
+ from .auth import GitHubAppAuth, auth_metadata, requires_app_jwt
11
11
  from .config import ConfigurationError, load_config
12
12
 
13
13
 
@@ -55,7 +55,7 @@ def github_app_verify_identity(params: dict[str, Any], **_: Any) -> str:
55
55
  repo = _repo(params)
56
56
  auth = _auth()
57
57
  token = auth.get_installation_token(force_refresh=True)
58
- app = auth.request("GET", "/app")
58
+ app = auth.app_request("GET", "/app")
59
59
  repo_probe = auth.request("GET", f"/repos/{repo}", repo=repo) if repo else None
60
60
  return {
61
61
  "auth": auth_metadata(token, repo=repo),
@@ -75,7 +75,12 @@ def github_app_api(params: dict[str, Any], **_: Any) -> str:
75
75
  repo = _repo(params)
76
76
  body = params.get("json_body")
77
77
  json_body = body if isinstance(body, dict) else None
78
- result = _auth().request(method, path, repo=repo, json_body=json_body)
78
+ auth = _auth()
79
+ result = (
80
+ auth.app_request(method, path, json_body=json_body)
81
+ if requires_app_jwt(path)
82
+ else auth.request(method, path, repo=repo, json_body=json_body)
83
+ )
79
84
  return result
80
85
 
81
86
  return _handle_errors(run)
@@ -7,7 +7,12 @@ from datetime import datetime, timezone
7
7
 
8
8
  import httpx
9
9
 
10
- from hermes_github_app_plugin.auth import GitHubAppAuth, InstallationToken, auth_metadata
10
+ from hermes_github_app_plugin.auth import (
11
+ GitHubAppAuth,
12
+ InstallationToken,
13
+ auth_metadata,
14
+ requires_app_jwt,
15
+ )
11
16
  from hermes_github_app_plugin.config import GitHubAppConfig
12
17
 
13
18
  PRIVATE_KEY = "[REDACTED PRIVATE KEY]\n"
@@ -59,3 +64,35 @@ def test_request_includes_installation_token(monkeypatch) -> None: # type: igno
59
64
  assert seen[0].headers["authorization"] == "Bearer jwt-token"
60
65
  assert seen[1].headers["authorization"] == "Bearer ghu_installation_token"
61
66
  assert json.loads(json.dumps(result))["auth"]["auth_mode"] == "github_app"
67
+
68
+
69
+ def test_app_request_uses_app_jwt(monkeypatch) -> None: # type: ignore[no-untyped-def]
70
+ monkeypatch.setattr("jwt.encode", lambda *_, **__: "jwt-token")
71
+ seen: list[httpx.Request] = []
72
+
73
+ def handler(request: httpx.Request) -> httpx.Response:
74
+ seen.append(request)
75
+ return httpx.Response(200, json={"slug": "hermes-test-agent"})
76
+
77
+ client = httpx.Client(transport=httpx.MockTransport(handler))
78
+ config = GitHubAppConfig(
79
+ client_id="123",
80
+ installation_id="456",
81
+ private_key=PRIVATE_KEY,
82
+ private_key_source="env",
83
+ app_slug="hermes-test-agent",
84
+ )
85
+
86
+ result = GitHubAppAuth(config, client=client).app_request("GET", "/app")
87
+
88
+ assert result["result"] == {"slug": "hermes-test-agent"}
89
+ assert seen[0].url.path == "/app"
90
+ assert seen[0].headers["authorization"] == "Bearer jwt-token"
91
+ assert result["auth"]["auth_mode"] == "github_app_jwt"
92
+
93
+
94
+ def test_requires_app_jwt_detects_app_endpoints() -> None:
95
+ assert requires_app_jwt("/app")
96
+ assert requires_app_jwt("/app/installations")
97
+ assert requires_app_jwt("https://api.github.com/app")
98
+ assert not requires_app_jwt("/repos/OWNER/REPO")
@@ -88,7 +88,7 @@ def test_doctor_skip_network_reports_local_checks(
88
88
  key_path.write_text(PRIVATE_KEY, encoding="utf-8")
89
89
  key_path.chmod(0o600)
90
90
  monkeypatch.setenv("HERMES_HOME", str(hermes_home))
91
- (hermes_home).mkdir()
91
+ hermes_home.mkdir()
92
92
  (hermes_home / "config.yaml").write_text(
93
93
  f"""
94
94
  github_app:
@@ -106,3 +106,55 @@ github_app:
106
106
  assert "✓ GitHub App config loaded" in output
107
107
  assert "✓ private key file permissions: 0o600" in output
108
108
  assert "Local doctor passed" in output
109
+
110
+
111
+ def test_api_routes_app_paths_to_app_jwt(
112
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
113
+ ) -> None:
114
+ class FakeAuth:
115
+ def __init__(self, config: object) -> None:
116
+ self.config = config
117
+
118
+ def app_request(
119
+ self, method: str, path: str, json_body: dict[str, object] | None = None
120
+ ) -> dict[str, object]:
121
+ return {"status_code": 200, "result": {"path": path, "method": method}}
122
+
123
+ def request(self, *args: object, **kwargs: object) -> dict[str, object]:
124
+ raise AssertionError("installation-token request should not be used for /app")
125
+
126
+ monkeypatch.setattr(cli, "load_config", object)
127
+ monkeypatch.setattr(cli, "GitHubAppAuth", FakeAuth)
128
+
129
+ assert cli._api("GET", "/app", repo=None, body=None) == 0
130
+
131
+ output = capsys.readouterr().out
132
+ assert '"status_code": 200' in output
133
+
134
+
135
+ def test_api_routes_repo_paths_to_installation_token(
136
+ monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
137
+ ) -> None:
138
+ class FakeAuth:
139
+ def __init__(self, config: object) -> None:
140
+ self.config = config
141
+
142
+ def app_request(self, *args: object, **kwargs: object) -> dict[str, object]:
143
+ raise AssertionError("app JWT request should not be used for repo APIs")
144
+
145
+ def request(
146
+ self,
147
+ method: str,
148
+ path: str,
149
+ repo: str | None = None,
150
+ json_body: dict[str, object] | None = None,
151
+ ) -> dict[str, object]:
152
+ return {"status_code": 200, "result": {"path": path, "repo": repo, "method": method}}
153
+
154
+ monkeypatch.setattr(cli, "load_config", object)
155
+ monkeypatch.setattr(cli, "GitHubAppAuth", FakeAuth)
156
+
157
+ assert cli._api("GET", "/repos/OWNER/REPO", repo="OWNER/REPO", body=None) == 0
158
+
159
+ output = capsys.readouterr().out
160
+ assert '"repo": "OWNER/REPO"' in output