hermes-github-app-plugin 0.2.0__tar.gz → 0.3.0__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.
- {hermes_github_app_plugin-0.2.0 → hermes_github_app_plugin-0.3.0}/.github/workflows/cd.yaml +2 -2
- {hermes_github_app_plugin-0.2.0 → hermes_github_app_plugin-0.3.0}/.github/workflows/ci.yaml +1 -1
- {hermes_github_app_plugin-0.2.0 → hermes_github_app_plugin-0.3.0}/PKG-INFO +34 -4
- {hermes_github_app_plugin-0.2.0 → hermes_github_app_plugin-0.3.0}/README.md +32 -2
- {hermes_github_app_plugin-0.2.0 → hermes_github_app_plugin-0.3.0}/plugin.yaml +5 -2
- {hermes_github_app_plugin-0.2.0 → hermes_github_app_plugin-0.3.0}/pyproject.toml +1 -1
- {hermes_github_app_plugin-0.2.0/src/hermes_github_app_plugin → hermes_github_app_plugin-0.3.0}/skills/github-app-workflow/SKILL.md +8 -0
- {hermes_github_app_plugin-0.2.0 → hermes_github_app_plugin-0.3.0}/src/hermes_github_app_plugin/auth.py +33 -5
- {hermes_github_app_plugin-0.2.0 → hermes_github_app_plugin-0.3.0}/src/hermes_github_app_plugin/cli.py +76 -18
- {hermes_github_app_plugin-0.2.0 → hermes_github_app_plugin-0.3.0}/src/hermes_github_app_plugin/config.py +36 -2
- {hermes_github_app_plugin-0.2.0 → hermes_github_app_plugin-0.3.0/src/hermes_github_app_plugin}/skills/github-app-workflow/SKILL.md +8 -0
- {hermes_github_app_plugin-0.2.0 → hermes_github_app_plugin-0.3.0}/src/hermes_github_app_plugin/tools.py +1 -0
- hermes_github_app_plugin-0.3.0/tests/conftest.py +18 -0
- {hermes_github_app_plugin-0.2.0 → hermes_github_app_plugin-0.3.0}/tests/test_auth.py +94 -2
- hermes_github_app_plugin-0.3.0/tests/test_cli.py +363 -0
- hermes_github_app_plugin-0.3.0/tests/test_config.py +156 -0
- hermes_github_app_plugin-0.2.0/tests/test_cli.py +0 -192
- hermes_github_app_plugin-0.2.0/tests/test_config.py +0 -76
- {hermes_github_app_plugin-0.2.0 → hermes_github_app_plugin-0.3.0}/.github/dependabot.yml +0 -0
- {hermes_github_app_plugin-0.2.0 → hermes_github_app_plugin-0.3.0}/.gitignore +0 -0
- {hermes_github_app_plugin-0.2.0 → hermes_github_app_plugin-0.3.0}/LICENSE +0 -0
- {hermes_github_app_plugin-0.2.0 → hermes_github_app_plugin-0.3.0}/src/hermes_github_app_plugin/__init__.py +0 -0
- {hermes_github_app_plugin-0.2.0 → hermes_github_app_plugin-0.3.0}/src/hermes_github_app_plugin/py.typed +0 -0
- {hermes_github_app_plugin-0.2.0 → hermes_github_app_plugin-0.3.0}/src/hermes_github_app_plugin/schemas.py +0 -0
- {hermes_github_app_plugin-0.2.0 → hermes_github_app_plugin-0.3.0}/tests/test_plugin.py +0 -0
|
@@ -16,7 +16,7 @@ jobs:
|
|
|
16
16
|
release_tag: ${{ steps.validate.outputs.release_tag }}
|
|
17
17
|
steps:
|
|
18
18
|
- uses: actions/checkout@v7
|
|
19
|
-
- uses: actions/setup-python@
|
|
19
|
+
- uses: actions/setup-python@v7
|
|
20
20
|
with:
|
|
21
21
|
python-version: '3.12'
|
|
22
22
|
- name: Validate tag and package version
|
|
@@ -48,7 +48,7 @@ jobs:
|
|
|
48
48
|
runs-on: ubuntu-latest
|
|
49
49
|
steps:
|
|
50
50
|
- uses: actions/checkout@v7
|
|
51
|
-
- uses: actions/setup-python@
|
|
51
|
+
- uses: actions/setup-python@v7
|
|
52
52
|
with:
|
|
53
53
|
python-version: '3.12'
|
|
54
54
|
cache: pip
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: hermes-github-app-plugin
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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
|
|
@@ -43,6 +43,7 @@ Environment variables with the same meaning are also supported:
|
|
|
43
43
|
- `GITHUB_APP_INSTALLATION_IDS` (JSON object or `OWNER=ID,OWNER2=ID2` mapping)
|
|
44
44
|
- `GITHUB_APP_PRIVATE_KEY_PATH`
|
|
45
45
|
- `GITHUB_APP_PRIVATE_KEY` (PEM contents; useful for CI)
|
|
46
|
+
- `GITHUB_APP_TOKEN_BROKER=nono` (no private key in this environment; see [Running inside nono](#running-inside-nono-token-broker-mode))
|
|
46
47
|
|
|
47
48
|
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.
|
|
48
49
|
|
|
@@ -102,6 +103,35 @@ git-app --repo OWNER/REPO push origin my-branch
|
|
|
102
103
|
`gh-app` injects an ephemeral installation token as `GH_TOKEN` and `GITHUB_TOKEN` for the child `gh` process.
|
|
103
104
|
`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.
|
|
104
105
|
|
|
106
|
+
## Running inside nono (token broker mode)
|
|
107
|
+
|
|
108
|
+
[nono](https://nono.sh) can run the agent in a sandbox that never holds the GitHub App private key. Enable broker mode with `GITHUB_APP_TOKEN_BROKER=nono` or `github_app.token_broker: nono`:
|
|
109
|
+
|
|
110
|
+
```yaml
|
|
111
|
+
github_app:
|
|
112
|
+
client_id: "Iv1.exampleclientid"
|
|
113
|
+
installation_ids:
|
|
114
|
+
ExampleOrg: "123456"
|
|
115
|
+
token_broker: nono
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
hermes-github-app setup --token-broker nono --client-id Iv1.exampleclientid --installation-id 987654
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
In broker mode the plugin reads no private key; `private_key_path` and `GITHUB_APP_PRIVATE_KEY*` are ignored. Installation tokens are not minted locally. The plugin sends the placeholder `nono-phantom-github-app` as its token, and nono's supervisor, outside the sandbox, handles the real credentials:
|
|
123
|
+
|
|
124
|
+
- Its TLS-intercepting proxy replaces the `Authorization` header on requests to `api.github.com`, `uploads.github.com`, and `github.com` with an installation token chosen by owner from the request path. Git HTTPS uses the same token as Basic `x-access-token`.
|
|
125
|
+
- The app JWT is injected for exactly `GET /app`.
|
|
126
|
+
|
|
127
|
+
What this means for agents:
|
|
128
|
+
|
|
129
|
+
- `github_app_*` tools, `hermes-github-app api`, `gh-app`, and `git-app` work unchanged; the placeholder flows through.
|
|
130
|
+
- Plain `git push` over HTTPS and plain `gh` also work, because the proxy injects credentials into those requests. Keep remotes HTTPS. SSH remotes use local SSH keys and are not covered.
|
|
131
|
+
- The agent cannot obtain a real token, by design. `hermes-github-app token` prints the placeholder with a note on stderr.
|
|
132
|
+
- `client_id` is still required in broker mode. It is non-secret and is reported in auth metadata.
|
|
133
|
+
- `hermes-github-app doctor` shows a `token broker` check and an `HTTPS_PROXY set` check. If the proxy variable is missing, the agent is not running inside nono and no credentials will be injected.
|
|
134
|
+
|
|
105
135
|
## Migrating existing Hermes skills and jobs
|
|
106
136
|
|
|
107
137
|
To keep agents from falling back to local human credentials, update existing GitHub-related Hermes skills, cron jobs, and subagent prompts with these rules:
|
|
@@ -135,8 +165,8 @@ Before the first release, configure PyPI Trusted Publishing for this repository
|
|
|
135
165
|
Release example:
|
|
136
166
|
|
|
137
167
|
```bash
|
|
138
|
-
git tag 0.
|
|
139
|
-
git push origin 0.
|
|
168
|
+
git tag 0.3.0
|
|
169
|
+
git push origin 0.3.0
|
|
140
170
|
```
|
|
141
171
|
|
|
142
172
|
Tags like `v0.1.0`, `0.1`, or `0.1.0rc1` will not publish.
|
|
@@ -24,6 +24,7 @@ Environment variables with the same meaning are also supported:
|
|
|
24
24
|
- `GITHUB_APP_INSTALLATION_IDS` (JSON object or `OWNER=ID,OWNER2=ID2` mapping)
|
|
25
25
|
- `GITHUB_APP_PRIVATE_KEY_PATH`
|
|
26
26
|
- `GITHUB_APP_PRIVATE_KEY` (PEM contents; useful for CI)
|
|
27
|
+
- `GITHUB_APP_TOKEN_BROKER=nono` (no private key in this environment; see [Running inside nono](#running-inside-nono-token-broker-mode))
|
|
27
28
|
|
|
28
29
|
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.
|
|
29
30
|
|
|
@@ -83,6 +84,35 @@ git-app --repo OWNER/REPO push origin my-branch
|
|
|
83
84
|
`gh-app` injects an ephemeral installation token as `GH_TOKEN` and `GITHUB_TOKEN` for the child `gh` process.
|
|
84
85
|
`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.
|
|
85
86
|
|
|
87
|
+
## Running inside nono (token broker mode)
|
|
88
|
+
|
|
89
|
+
[nono](https://nono.sh) can run the agent in a sandbox that never holds the GitHub App private key. Enable broker mode with `GITHUB_APP_TOKEN_BROKER=nono` or `github_app.token_broker: nono`:
|
|
90
|
+
|
|
91
|
+
```yaml
|
|
92
|
+
github_app:
|
|
93
|
+
client_id: "Iv1.exampleclientid"
|
|
94
|
+
installation_ids:
|
|
95
|
+
ExampleOrg: "123456"
|
|
96
|
+
token_broker: nono
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
hermes-github-app setup --token-broker nono --client-id Iv1.exampleclientid --installation-id 987654
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
In broker mode the plugin reads no private key; `private_key_path` and `GITHUB_APP_PRIVATE_KEY*` are ignored. Installation tokens are not minted locally. The plugin sends the placeholder `nono-phantom-github-app` as its token, and nono's supervisor, outside the sandbox, handles the real credentials:
|
|
104
|
+
|
|
105
|
+
- Its TLS-intercepting proxy replaces the `Authorization` header on requests to `api.github.com`, `uploads.github.com`, and `github.com` with an installation token chosen by owner from the request path. Git HTTPS uses the same token as Basic `x-access-token`.
|
|
106
|
+
- The app JWT is injected for exactly `GET /app`.
|
|
107
|
+
|
|
108
|
+
What this means for agents:
|
|
109
|
+
|
|
110
|
+
- `github_app_*` tools, `hermes-github-app api`, `gh-app`, and `git-app` work unchanged; the placeholder flows through.
|
|
111
|
+
- Plain `git push` over HTTPS and plain `gh` also work, because the proxy injects credentials into those requests. Keep remotes HTTPS. SSH remotes use local SSH keys and are not covered.
|
|
112
|
+
- The agent cannot obtain a real token, by design. `hermes-github-app token` prints the placeholder with a note on stderr.
|
|
113
|
+
- `client_id` is still required in broker mode. It is non-secret and is reported in auth metadata.
|
|
114
|
+
- `hermes-github-app doctor` shows a `token broker` check and an `HTTPS_PROXY set` check. If the proxy variable is missing, the agent is not running inside nono and no credentials will be injected.
|
|
115
|
+
|
|
86
116
|
## Migrating existing Hermes skills and jobs
|
|
87
117
|
|
|
88
118
|
To keep agents from falling back to local human credentials, update existing GitHub-related Hermes skills, cron jobs, and subagent prompts with these rules:
|
|
@@ -116,8 +146,8 @@ Before the first release, configure PyPI Trusted Publishing for this repository
|
|
|
116
146
|
Release example:
|
|
117
147
|
|
|
118
148
|
```bash
|
|
119
|
-
git tag 0.
|
|
120
|
-
git push origin 0.
|
|
149
|
+
git tag 0.3.0
|
|
150
|
+
git push origin 0.3.0
|
|
121
151
|
```
|
|
122
152
|
|
|
123
153
|
Tags like `v0.1.0`, `0.1`, or `0.1.0rc1` will not publish.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
name: github-app
|
|
2
|
-
version: 0.
|
|
2
|
+
version: 0.3.0
|
|
3
3
|
description: "GitHub App identity integration for Hermes: per-agent app token minting, GitHub API tools, gh/git wrappers, CLI, and workflow skill."
|
|
4
4
|
author: Hermes GitHub App Plugin Contributors
|
|
5
5
|
provides_tools:
|
|
@@ -26,5 +26,8 @@ requires_env:
|
|
|
26
26
|
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."
|
|
27
27
|
secret: false
|
|
28
28
|
- name: GITHUB_APP_PRIVATE_KEY_PATH
|
|
29
|
-
description: "Path to the GitHub App private key PEM. Can also be set in ~/.hermes/config.yaml under github_app.private_key_path."
|
|
29
|
+
description: "Path to the GitHub App private key PEM. Can also be set in ~/.hermes/config.yaml under github_app.private_key_path. Not used when GITHUB_APP_TOKEN_BROKER is set."
|
|
30
30
|
secret: true
|
|
31
|
+
- name: GITHUB_APP_TOKEN_BROKER
|
|
32
|
+
description: "Set to nono when the agent runs in the nono sandbox and the token broker injects credentials. No private key is read in this mode. Can also be set in ~/.hermes/config.yaml under github_app.token_broker."
|
|
33
|
+
secret: false
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hermes-github-app-plugin"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
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"
|
|
@@ -36,6 +36,14 @@ After setup, run `hermes-github-app doctor --repo OWNER/REPO` to verify console
|
|
|
36
36
|
7. Expect comments, PRs, and API writes to appear as `<app-slug>[bot]`.
|
|
37
37
|
8. Treat the GitHub App installation scope as the repository access boundary. The plugin does not maintain a separate local repository/owner allowlist.
|
|
38
38
|
|
|
39
|
+
## Running inside nono (token broker mode)
|
|
40
|
+
|
|
41
|
+
If `hermes-github-app doctor` reports `token broker: nono`, this agent runs in the nono sandbox. By design it holds no GitHub App private key and cannot obtain a real installation token.
|
|
42
|
+
|
|
43
|
+
- Do not search for, copy, or request a private key or a real token. `hermes-github-app token` prints a placeholder; nono's proxy outside the sandbox injects the real credentials into GitHub requests.
|
|
44
|
+
- `github_app_*` tools, `gh-app`, and `git-app` work as described above. Plain `gh` and HTTPS `git push` also work because the proxy injects credentials, so keep remotes HTTPS.
|
|
45
|
+
- If `doctor` reports `HTTPS_PROXY set` as failed, the agent is not running inside nono and no credentials will be injected. Stop and report that. Do not fall back to local human credentials or SSH keys.
|
|
46
|
+
|
|
39
47
|
## Updating existing Hermes skills
|
|
40
48
|
|
|
41
49
|
Patch any GitHub-related skill, cron prompt, or subagent instruction that mentions `gh`, `git push`, GitHub SSH remotes, or `@me` assumptions:
|
|
@@ -12,9 +12,10 @@ from urllib.parse import urlparse
|
|
|
12
12
|
import httpx
|
|
13
13
|
import jwt
|
|
14
14
|
|
|
15
|
-
from .config import GitHubAppConfig
|
|
15
|
+
from .config import NONO_PHANTOM_TOKEN, GitHubAppConfig
|
|
16
16
|
|
|
17
17
|
_MIN_REDACT_LENGTH = 8
|
|
18
|
+
_BROKERED_TOKEN_LIFETIME = timedelta(hours=1)
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
@dataclass(frozen=True)
|
|
@@ -26,6 +27,7 @@ class InstallationToken:
|
|
|
26
27
|
installation_id: str
|
|
27
28
|
client_id: str
|
|
28
29
|
app_slug: str | None
|
|
30
|
+
token_broker: str | None = None
|
|
29
31
|
|
|
30
32
|
@property
|
|
31
33
|
def redacted(self) -> str:
|
|
@@ -48,7 +50,13 @@ class GitHubAppAuth:
|
|
|
48
50
|
return self._config
|
|
49
51
|
|
|
50
52
|
def create_jwt(self) -> str:
|
|
51
|
-
"""Create a GitHub App JWT for installation-token exchange.
|
|
53
|
+
"""Create a GitHub App JWT for installation-token exchange.
|
|
54
|
+
|
|
55
|
+
In token broker mode there is no key to sign with; the placeholder is sent and the
|
|
56
|
+
broker replaces it outside the sandbox.
|
|
57
|
+
"""
|
|
58
|
+
if self._config.token_broker:
|
|
59
|
+
return NONO_PHANTOM_TOKEN
|
|
52
60
|
now = int(time.time())
|
|
53
61
|
payload = {"iat": now - 60, "exp": now + 9 * 60, "iss": self._config.client_id}
|
|
54
62
|
encoded = jwt.encode(payload, self._config.private_key, algorithm="RS256")
|
|
@@ -67,6 +75,26 @@ class GitHubAppAuth:
|
|
|
67
75
|
):
|
|
68
76
|
return cached_token
|
|
69
77
|
|
|
78
|
+
if self._config.token_broker:
|
|
79
|
+
token = self._brokered_token(installation_id)
|
|
80
|
+
else:
|
|
81
|
+
token = self._exchange_app_jwt(installation_id)
|
|
82
|
+
self._cached_tokens[installation_id] = token
|
|
83
|
+
return token
|
|
84
|
+
|
|
85
|
+
def _brokered_token(self, installation_id: str) -> InstallationToken:
|
|
86
|
+
"""Return the placeholder token with no network call; the broker injects the real one."""
|
|
87
|
+
return InstallationToken(
|
|
88
|
+
token=NONO_PHANTOM_TOKEN,
|
|
89
|
+
expires_at=datetime.now(timezone.utc) + _BROKERED_TOKEN_LIFETIME,
|
|
90
|
+
installation_id=installation_id,
|
|
91
|
+
client_id=self._config.client_id,
|
|
92
|
+
app_slug=self._config.app_slug,
|
|
93
|
+
token_broker=self._config.token_broker,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def _exchange_app_jwt(self, installation_id: str) -> InstallationToken:
|
|
97
|
+
"""Exchange the app JWT for an installation access token."""
|
|
70
98
|
response = self._client.post(
|
|
71
99
|
f"{self._config.github_api_url}/app/installations/{installation_id}/access_tokens",
|
|
72
100
|
headers={
|
|
@@ -79,15 +107,13 @@ class GitHubAppAuth:
|
|
|
79
107
|
data = response.json()
|
|
80
108
|
expires_at_raw = str(data["expires_at"])
|
|
81
109
|
expires_at = datetime.fromisoformat(expires_at_raw.replace("Z", "+00:00"))
|
|
82
|
-
|
|
110
|
+
return InstallationToken(
|
|
83
111
|
token=str(data["token"]),
|
|
84
112
|
expires_at=expires_at,
|
|
85
113
|
installation_id=installation_id,
|
|
86
114
|
client_id=self._config.client_id,
|
|
87
115
|
app_slug=self._config.app_slug,
|
|
88
116
|
)
|
|
89
|
-
self._cached_tokens[installation_id] = token
|
|
90
|
-
return token
|
|
91
117
|
|
|
92
118
|
def app_request(
|
|
93
119
|
self,
|
|
@@ -125,6 +151,7 @@ class GitHubAppAuth:
|
|
|
125
151
|
"app_slug": self._config.app_slug,
|
|
126
152
|
"installation_id": self._config.installation_id,
|
|
127
153
|
"installation_ids": dict(self._config.installation_ids),
|
|
154
|
+
"token_broker": self._config.token_broker,
|
|
128
155
|
},
|
|
129
156
|
"status_code": response.status_code,
|
|
130
157
|
"result": response.json() if response.content else {"ok": True},
|
|
@@ -197,6 +224,7 @@ def auth_metadata(token: InstallationToken, *, repo: str | None = None) -> dict[
|
|
|
197
224
|
"actor_expected": actor,
|
|
198
225
|
"repository": repo,
|
|
199
226
|
"token": token.redacted,
|
|
227
|
+
"token_broker": token.token_broker,
|
|
200
228
|
"expires_at": token.expires_at.isoformat(),
|
|
201
229
|
}
|
|
202
230
|
|
|
@@ -16,7 +16,13 @@ from typing import Any, NoReturn
|
|
|
16
16
|
import httpx
|
|
17
17
|
|
|
18
18
|
from .auth import GitHubAppAuth, auth_metadata, requires_app_jwt
|
|
19
|
-
from .config import
|
|
19
|
+
from .config import (
|
|
20
|
+
ConfigurationError,
|
|
21
|
+
GitHubAppConfig,
|
|
22
|
+
load_config,
|
|
23
|
+
parse_token_broker,
|
|
24
|
+
write_github_app_config,
|
|
25
|
+
)
|
|
20
26
|
|
|
21
27
|
|
|
22
28
|
def register_cli(parser: argparse.ArgumentParser) -> None:
|
|
@@ -38,6 +44,12 @@ def register_cli(parser: argparse.ArgumentParser) -> None:
|
|
|
38
44
|
help="Owner-specific installation ID mapping; repeat for multiple orgs",
|
|
39
45
|
)
|
|
40
46
|
setup.add_argument("--private-key-path", help="Path to GitHub App private key PEM")
|
|
47
|
+
setup.add_argument(
|
|
48
|
+
"--token-broker",
|
|
49
|
+
metavar="NAME",
|
|
50
|
+
help="Credentials are injected outside the sandbox by this broker (supported: nono); "
|
|
51
|
+
"no private key is configured",
|
|
52
|
+
)
|
|
41
53
|
setup.add_argument("--app-slug", help="Optional GitHub App slug, e.g. my-agent")
|
|
42
54
|
setup.add_argument(
|
|
43
55
|
"--skip-verify", action="store_true", help="Write config without minting a token"
|
|
@@ -134,6 +146,15 @@ def _setup(args: argparse.Namespace) -> int:
|
|
|
134
146
|
"""Interactively write GitHub App configuration."""
|
|
135
147
|
print("Hermes GitHub App setup")
|
|
136
148
|
print("Required values are unmarked. Optional prompts include '(optional)'.")
|
|
149
|
+
token_broker = parse_token_broker(
|
|
150
|
+
getattr(args, "token_broker", None) or os.environ.get("GITHUB_APP_TOKEN_BROKER"),
|
|
151
|
+
source="--token-broker or GITHUB_APP_TOKEN_BROKER",
|
|
152
|
+
)
|
|
153
|
+
if token_broker and args.private_key_path:
|
|
154
|
+
raise ConfigurationError(
|
|
155
|
+
"--private-key-path cannot be combined with --token-broker; "
|
|
156
|
+
"the private key must stay outside the agent's sandbox"
|
|
157
|
+
)
|
|
137
158
|
installation_ids = _parse_installation_id_for_args(list(args.installation_id_for))
|
|
138
159
|
values = {
|
|
139
160
|
"client_id": _value_or_prompt(
|
|
@@ -151,7 +172,11 @@ def _setup(args: argparse.Namespace) -> int:
|
|
|
151
172
|
non_interactive=bool(args.non_interactive),
|
|
152
173
|
),
|
|
153
174
|
"installation_ids": installation_ids,
|
|
154
|
-
"
|
|
175
|
+
"token_broker": token_broker,
|
|
176
|
+
# An empty value removes any private_key_path left by an earlier non-brokered setup.
|
|
177
|
+
"private_key_path": ""
|
|
178
|
+
if token_broker
|
|
179
|
+
else _value_or_prompt(
|
|
155
180
|
args.private_key_path,
|
|
156
181
|
"GitHub App private key path",
|
|
157
182
|
env="GITHUB_APP_PRIVATE_KEY_PATH",
|
|
@@ -166,10 +191,11 @@ def _setup(args: argparse.Namespace) -> int:
|
|
|
166
191
|
non_interactive=bool(args.non_interactive),
|
|
167
192
|
),
|
|
168
193
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
194
|
+
if not token_broker:
|
|
195
|
+
key_path = Path(str(values["private_key_path"])).expanduser()
|
|
196
|
+
if not key_path.exists():
|
|
197
|
+
raise ConfigurationError(f"private key file does not exist: {key_path}")
|
|
198
|
+
_warn_private_key_permissions(key_path)
|
|
173
199
|
written = write_github_app_config(values)
|
|
174
200
|
print(f"Wrote GitHub App config to {written}")
|
|
175
201
|
if args.skip_verify:
|
|
@@ -189,22 +215,19 @@ def _doctor(repo: str | None, *, skip_network: bool) -> int:
|
|
|
189
215
|
try:
|
|
190
216
|
config = load_config()
|
|
191
217
|
checks.append(("GitHub App config loaded", True, "client_id and installation_id present"))
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
checks.append(("private key loaded", True, "inline environment variable"))
|
|
218
|
+
if config.token_broker:
|
|
219
|
+
checks.extend(_token_broker_checks(config.token_broker))
|
|
195
220
|
else:
|
|
196
|
-
checks.
|
|
197
|
-
checks.append(
|
|
198
|
-
(
|
|
199
|
-
"private key file permissions",
|
|
200
|
-
_private_key_permissions_ok(key_source),
|
|
201
|
-
_mode(key_source),
|
|
202
|
-
)
|
|
203
|
-
)
|
|
221
|
+
checks.extend(_private_key_checks(config))
|
|
204
222
|
if not skip_network:
|
|
205
223
|
auth = GitHubAppAuth(config)
|
|
206
224
|
token = auth.get_installation_token(repo=repo, force_refresh=True)
|
|
207
|
-
|
|
225
|
+
minted_label = (
|
|
226
|
+
f"installation token brokered by {config.token_broker}"
|
|
227
|
+
if config.token_broker
|
|
228
|
+
else "installation token minted"
|
|
229
|
+
)
|
|
230
|
+
checks.append((minted_label, True, token.redacted))
|
|
208
231
|
app_result = auth.app_request("GET", "/app")["result"]
|
|
209
232
|
checks.append(("/app API reachable", True, str(app_result.get("slug", "ok"))))
|
|
210
233
|
if repo:
|
|
@@ -230,6 +253,35 @@ def _doctor(repo: str | None, *, skip_network: bool) -> int:
|
|
|
230
253
|
return 1
|
|
231
254
|
|
|
232
255
|
|
|
256
|
+
def _private_key_checks(config: GitHubAppConfig) -> list[tuple[str, bool, str]]:
|
|
257
|
+
if config.private_key_source == "GITHUB_APP_PRIVATE_KEY":
|
|
258
|
+
return [("private key loaded", True, "inline environment variable")]
|
|
259
|
+
key_source = Path(config.private_key_source).expanduser()
|
|
260
|
+
return [
|
|
261
|
+
("private key file exists", key_source.exists(), str(key_source)),
|
|
262
|
+
(
|
|
263
|
+
"private key file permissions",
|
|
264
|
+
_private_key_permissions_ok(key_source),
|
|
265
|
+
_mode(key_source),
|
|
266
|
+
),
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _token_broker_checks(broker: str) -> list[tuple[str, bool, str]]:
|
|
271
|
+
proxy_set = bool(os.environ.get("HTTPS_PROXY") or os.environ.get("https_proxy"))
|
|
272
|
+
return [
|
|
273
|
+
("token broker", True, f"{broker}; credentials are injected outside the sandbox"),
|
|
274
|
+
(
|
|
275
|
+
"HTTPS_PROXY set",
|
|
276
|
+
proxy_set,
|
|
277
|
+
"set"
|
|
278
|
+
if proxy_set
|
|
279
|
+
else "not set; agent does not appear to be running inside nono, "
|
|
280
|
+
"so no credentials will be injected",
|
|
281
|
+
),
|
|
282
|
+
]
|
|
283
|
+
|
|
284
|
+
|
|
233
285
|
def _status(repo: str | None) -> int:
|
|
234
286
|
config = load_config()
|
|
235
287
|
auth = GitHubAppAuth(config)
|
|
@@ -258,6 +310,12 @@ def _token(repo: str | None, *, json_output: bool) -> int:
|
|
|
258
310
|
print(json.dumps({"token": token.token, "auth": auth_metadata(token, repo=repo)}, indent=2))
|
|
259
311
|
else:
|
|
260
312
|
print(token.token)
|
|
313
|
+
if config.token_broker:
|
|
314
|
+
print(
|
|
315
|
+
"note: token broker mode. The printed value is a placeholder; the real installation "
|
|
316
|
+
"token lives outside the sandbox and the nono proxy injects it into GitHub requests.",
|
|
317
|
+
file=sys.stderr,
|
|
318
|
+
)
|
|
261
319
|
return 0
|
|
262
320
|
|
|
263
321
|
|
|
@@ -11,6 +11,11 @@ from typing import Any
|
|
|
11
11
|
|
|
12
12
|
import yaml
|
|
13
13
|
|
|
14
|
+
# Token brokers keep the private key outside the agent's sandbox. The broker injects real
|
|
15
|
+
# credentials into outbound requests, so the agent only ever sends this placeholder.
|
|
16
|
+
SUPPORTED_TOKEN_BROKERS: tuple[str, ...] = ("nono",)
|
|
17
|
+
NONO_PHANTOM_TOKEN = "nono-phantom-github-app"
|
|
18
|
+
|
|
14
19
|
|
|
15
20
|
class ConfigurationError(RuntimeError):
|
|
16
21
|
"""Raised when GitHub App configuration is missing or invalid."""
|
|
@@ -18,7 +23,10 @@ class ConfigurationError(RuntimeError):
|
|
|
18
23
|
|
|
19
24
|
@dataclass(frozen=True)
|
|
20
25
|
class GitHubAppConfig:
|
|
21
|
-
"""Per-agent GitHub App configuration.
|
|
26
|
+
"""Per-agent GitHub App configuration.
|
|
27
|
+
|
|
28
|
+
In token broker mode `private_key` is empty and `token_broker` names the broker.
|
|
29
|
+
"""
|
|
22
30
|
|
|
23
31
|
client_id: str
|
|
24
32
|
installation_id: str
|
|
@@ -27,6 +35,7 @@ class GitHubAppConfig:
|
|
|
27
35
|
private_key_source: str
|
|
28
36
|
app_slug: str | None = None
|
|
29
37
|
github_api_url: str = "https://api.github.com"
|
|
38
|
+
token_broker: str | None = None
|
|
30
39
|
|
|
31
40
|
def installation_id_for_repo(self, repo: str | None = None) -> str:
|
|
32
41
|
"""Return the installation ID for an optional OWNER/REPO target."""
|
|
@@ -118,6 +127,21 @@ def _parse_installation_ids(raw: Any) -> dict[str, str]:
|
|
|
118
127
|
return result
|
|
119
128
|
|
|
120
129
|
|
|
130
|
+
def parse_token_broker(raw: object, *, source: str) -> str | None:
|
|
131
|
+
"""Normalize a token broker name; empty values mean no broker."""
|
|
132
|
+
if raw is None:
|
|
133
|
+
return None
|
|
134
|
+
text = str(raw).strip().lower()
|
|
135
|
+
if not text:
|
|
136
|
+
return None
|
|
137
|
+
if text not in SUPPORTED_TOKEN_BROKERS:
|
|
138
|
+
supported = ", ".join(SUPPORTED_TOKEN_BROKERS)
|
|
139
|
+
raise ConfigurationError(
|
|
140
|
+
f"unsupported token broker {str(raw)!r} from {source}; supported: {supported}"
|
|
141
|
+
)
|
|
142
|
+
return text
|
|
143
|
+
|
|
144
|
+
|
|
121
145
|
def _read_private_key(section: Mapping[str, Any]) -> tuple[str, str]:
|
|
122
146
|
inline_key = os.environ.get("GITHUB_APP_PRIVATE_KEY") or str(section.get("private_key", ""))
|
|
123
147
|
if inline_key:
|
|
@@ -159,7 +183,16 @@ def load_config() -> GitHubAppConfig:
|
|
|
159
183
|
if not installation_id:
|
|
160
184
|
# Use a deterministic fallback for commands that cannot infer an OWNER/REPO.
|
|
161
185
|
installation_id = next(iter(installation_ids.values()))
|
|
162
|
-
|
|
186
|
+
broker_env = os.environ.get("GITHUB_APP_TOKEN_BROKER")
|
|
187
|
+
token_broker = parse_token_broker(
|
|
188
|
+
broker_env or section.get("token_broker"),
|
|
189
|
+
source="GITHUB_APP_TOKEN_BROKER" if broker_env else "github_app.token_broker",
|
|
190
|
+
)
|
|
191
|
+
if token_broker:
|
|
192
|
+
# The private key never enters a brokered agent's environment; ignore any key settings.
|
|
193
|
+
private_key, private_key_source = "", f"token broker: {token_broker}"
|
|
194
|
+
else:
|
|
195
|
+
private_key, private_key_source = _read_private_key(section)
|
|
163
196
|
return GitHubAppConfig(
|
|
164
197
|
client_id=client_id,
|
|
165
198
|
installation_id=installation_id,
|
|
@@ -169,4 +202,5 @@ def load_config() -> GitHubAppConfig:
|
|
|
169
202
|
app_slug=os.environ.get("GITHUB_APP_SLUG") or section.get("app_slug"),
|
|
170
203
|
github_api_url=os.environ.get("GITHUB_API_URL")
|
|
171
204
|
or str(section.get("github_api_url", "https://api.github.com")).rstrip("/"),
|
|
205
|
+
token_broker=token_broker,
|
|
172
206
|
)
|
|
@@ -36,6 +36,14 @@ After setup, run `hermes-github-app doctor --repo OWNER/REPO` to verify console
|
|
|
36
36
|
7. Expect comments, PRs, and API writes to appear as `<app-slug>[bot]`.
|
|
37
37
|
8. Treat the GitHub App installation scope as the repository access boundary. The plugin does not maintain a separate local repository/owner allowlist.
|
|
38
38
|
|
|
39
|
+
## Running inside nono (token broker mode)
|
|
40
|
+
|
|
41
|
+
If `hermes-github-app doctor` reports `token broker: nono`, this agent runs in the nono sandbox. By design it holds no GitHub App private key and cannot obtain a real installation token.
|
|
42
|
+
|
|
43
|
+
- Do not search for, copy, or request a private key or a real token. `hermes-github-app token` prints a placeholder; nono's proxy outside the sandbox injects the real credentials into GitHub requests.
|
|
44
|
+
- `github_app_*` tools, `gh-app`, and `git-app` work as described above. Plain `gh` and HTTPS `git push` also work because the proxy injects credentials, so keep remotes HTTPS.
|
|
45
|
+
- If `doctor` reports `HTTPS_PROXY set` as failed, the agent is not running inside nono and no credentials will be injected. Stop and report that. Do not fall back to local human credentials or SSH keys.
|
|
46
|
+
|
|
39
47
|
## Updating existing Hermes skills
|
|
40
48
|
|
|
41
49
|
Patch any GitHub-related skill, cron prompt, or subagent instruction that mentions `gh`, `git push`, GitHub SSH remotes, or `@me` assumptions:
|
|
@@ -42,6 +42,7 @@ def github_app_status(params: dict[str, Any], **_: Any) -> str:
|
|
|
42
42
|
"installation_ids": dict(config.installation_ids),
|
|
43
43
|
"app_slug": config.app_slug,
|
|
44
44
|
"private_key_source": config.private_key_source,
|
|
45
|
+
"token_broker": config.token_broker,
|
|
45
46
|
"github_api_url": config.github_api_url,
|
|
46
47
|
"scope_management": "github_app_installation",
|
|
47
48
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Shared test isolation: never read the developer's real Hermes config or GitHub App env."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture(autouse=True)
|
|
12
|
+
def isolated_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
13
|
+
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "isolated-hermes-home"))
|
|
14
|
+
for name in list(os.environ):
|
|
15
|
+
if name.startswith(("GITHUB_APP_", "GITHUB_API_URL")):
|
|
16
|
+
monkeypatch.delenv(name)
|
|
17
|
+
for name in ("HTTPS_PROXY", "https_proxy"):
|
|
18
|
+
monkeypatch.delenv(name, raising=False)
|