hermes-github-app-plugin 0.1.2__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.
Files changed (26) hide show
  1. hermes_github_app_plugin-0.3.0/.github/dependabot.yml +13 -0
  2. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.3.0}/.github/workflows/cd.yaml +6 -6
  3. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.3.0}/.github/workflows/ci.yaml +2 -2
  4. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.3.0}/PKG-INFO +43 -5
  5. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.3.0}/README.md +41 -3
  6. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.3.0}/plugin.yaml +9 -3
  7. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.3.0}/pyproject.toml +1 -1
  8. {hermes_github_app_plugin-0.1.2/src/hermes_github_app_plugin → hermes_github_app_plugin-0.3.0}/skills/github-app-workflow/SKILL.md +19 -1
  9. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.3.0}/src/hermes_github_app_plugin/auth.py +67 -17
  10. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.3.0}/src/hermes_github_app_plugin/cli.py +113 -27
  11. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.3.0}/src/hermes_github_app_plugin/config.py +89 -6
  12. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.3.0/src/hermes_github_app_plugin}/skills/github-app-workflow/SKILL.md +19 -1
  13. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.3.0}/src/hermes_github_app_plugin/tools.py +6 -2
  14. hermes_github_app_plugin-0.3.0/tests/conftest.py +18 -0
  15. hermes_github_app_plugin-0.3.0/tests/test_auth.py +235 -0
  16. hermes_github_app_plugin-0.3.0/tests/test_cli.py +363 -0
  17. hermes_github_app_plugin-0.3.0/tests/test_config.py +156 -0
  18. hermes_github_app_plugin-0.1.2/tests/test_auth.py +0 -98
  19. hermes_github_app_plugin-0.1.2/tests/test_cli.py +0 -160
  20. hermes_github_app_plugin-0.1.2/tests/test_config.py +0 -51
  21. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.3.0}/.gitignore +0 -0
  22. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.3.0}/LICENSE +0 -0
  23. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.3.0}/src/hermes_github_app_plugin/__init__.py +0 -0
  24. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.3.0}/src/hermes_github_app_plugin/py.typed +0 -0
  25. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.3.0}/src/hermes_github_app_plugin/schemas.py +0 -0
  26. {hermes_github_app_plugin-0.1.2 → hermes_github_app_plugin-0.3.0}/tests/test_plugin.py +0 -0
@@ -0,0 +1,13 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "pip"
4
+ directory: "/"
5
+ schedule:
6
+ interval: "weekly"
7
+ open-pull-requests-limit: 10
8
+
9
+ - package-ecosystem: "github-actions"
10
+ directory: "/"
11
+ schedule:
12
+ interval: "weekly"
13
+ open-pull-requests-limit: 10
@@ -15,8 +15,8 @@ jobs:
15
15
  outputs:
16
16
  release_tag: ${{ steps.validate.outputs.release_tag }}
17
17
  steps:
18
- - uses: actions/checkout@v4
19
- - uses: actions/setup-python@v5
18
+ - uses: actions/checkout@v7
19
+ - uses: actions/setup-python@v7
20
20
  with:
21
21
  python-version: '3.12'
22
22
  - name: Validate tag and package version
@@ -47,8 +47,8 @@ jobs:
47
47
  if: needs.validate-tag.outputs.release_tag == 'true'
48
48
  runs-on: ubuntu-latest
49
49
  steps:
50
- - uses: actions/checkout@v4
51
- - uses: actions/setup-python@v5
50
+ - uses: actions/checkout@v7
51
+ - uses: actions/setup-python@v7
52
52
  with:
53
53
  python-version: '3.12'
54
54
  cache: pip
@@ -59,7 +59,7 @@ jobs:
59
59
  - name: Check distribution metadata
60
60
  run: python -m twine check dist/*
61
61
  - name: Upload distribution artifact
62
- uses: actions/upload-artifact@v4
62
+ uses: actions/upload-artifact@v7
63
63
  with:
64
64
  name: python-distribution
65
65
  path: dist/*
@@ -75,7 +75,7 @@ jobs:
75
75
  id-token: write
76
76
  steps:
77
77
  - name: Download distribution artifact
78
- uses: actions/download-artifact@v4
78
+ uses: actions/download-artifact@v8
79
79
  with:
80
80
  name: python-distribution
81
81
  path: dist
@@ -14,8 +14,8 @@ jobs:
14
14
  matrix:
15
15
  python-version: ["3.10", "3.12"]
16
16
  steps:
17
- - uses: actions/checkout@v4
18
- - uses: actions/setup-python@v5
17
+ - uses: actions/checkout@v7
18
+ - uses: actions/setup-python@v7
19
19
  with:
20
20
  python-version: ${{ matrix.python-version }}
21
21
  cache: pip
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: hermes-github-app-plugin
3
- Version: 0.1.2
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
@@ -26,7 +26,12 @@ Each Hermes agent runs the same package but is configured with its own GitHub Ap
26
26
  ```yaml
27
27
  github_app:
28
28
  client_id: "Iv1.exampleclientid"
29
+ # Optional default used when a command cannot infer OWNER/REPO.
29
30
  installation_id: "987654"
31
+ # Optional owner-specific installation IDs for apps installed in multiple orgs.
32
+ installation_ids:
33
+ ExampleOrg: "123456"
34
+ ExampleInfra: "987654"
30
35
  private_key_path: "~/.hermes/secrets/agent-github-app.private-key.pem"
31
36
  app_slug: "hermes-agent"
32
37
  ```
@@ -35,8 +40,10 @@ Environment variables with the same meaning are also supported:
35
40
 
36
41
  - `GITHUB_APP_CLIENT_ID`
37
42
  - `GITHUB_APP_INSTALLATION_ID`
43
+ - `GITHUB_APP_INSTALLATION_IDS` (JSON object or `OWNER=ID,OWNER2=ID2` mapping)
38
44
  - `GITHUB_APP_PRIVATE_KEY_PATH`
39
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))
40
47
 
41
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.
42
49
 
@@ -46,7 +53,7 @@ Repository access is controlled by the GitHub App installation scope in GitHub.
46
53
 
47
54
  `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`.
48
55
 
49
- 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.
56
+ 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.
50
57
 
51
58
  ## Install
52
59
 
@@ -72,6 +79,8 @@ For scripted installs, pass flags and skip the network verification until secret
72
79
  hermes-github-app setup --non-interactive --skip-verify \
73
80
  --client-id Iv1.exampleclientid \
74
81
  --installation-id 987654 \
82
+ --installation-id-for ExampleOrg=123456 \
83
+ --installation-id-for ExampleInfra=987654 \
75
84
  --private-key-path ~/.hermes/secrets/agent-github-app.private-key.pem \
76
85
  --app-slug hermes-agent
77
86
  ```
@@ -94,6 +103,35 @@ git-app --repo OWNER/REPO push origin my-branch
94
103
  `gh-app` injects an ephemeral installation token as `GH_TOKEN` and `GITHUB_TOKEN` for the child `gh` process.
95
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.
96
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
+
97
135
  ## Migrating existing Hermes skills and jobs
98
136
 
99
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:
@@ -127,8 +165,8 @@ Before the first release, configure PyPI Trusted Publishing for this repository
127
165
  Release example:
128
166
 
129
167
  ```bash
130
- git tag 0.1.2
131
- git push origin 0.1.2
168
+ git tag 0.3.0
169
+ git push origin 0.3.0
132
170
  ```
133
171
 
134
172
  Tags like `v0.1.0`, `0.1`, or `0.1.0rc1` will not publish.
@@ -7,7 +7,12 @@ Each Hermes agent runs the same package but is configured with its own GitHub Ap
7
7
  ```yaml
8
8
  github_app:
9
9
  client_id: "Iv1.exampleclientid"
10
+ # Optional default used when a command cannot infer OWNER/REPO.
10
11
  installation_id: "987654"
12
+ # Optional owner-specific installation IDs for apps installed in multiple orgs.
13
+ installation_ids:
14
+ ExampleOrg: "123456"
15
+ ExampleInfra: "987654"
11
16
  private_key_path: "~/.hermes/secrets/agent-github-app.private-key.pem"
12
17
  app_slug: "hermes-agent"
13
18
  ```
@@ -16,8 +21,10 @@ Environment variables with the same meaning are also supported:
16
21
 
17
22
  - `GITHUB_APP_CLIENT_ID`
18
23
  - `GITHUB_APP_INSTALLATION_ID`
24
+ - `GITHUB_APP_INSTALLATION_IDS` (JSON object or `OWNER=ID,OWNER2=ID2` mapping)
19
25
  - `GITHUB_APP_PRIVATE_KEY_PATH`
20
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))
21
28
 
22
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.
23
30
 
@@ -27,7 +34,7 @@ Repository access is controlled by the GitHub App installation scope in GitHub.
27
34
 
28
35
  `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`.
29
36
 
30
- 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.
37
+ 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.
31
38
 
32
39
  ## Install
33
40
 
@@ -53,6 +60,8 @@ For scripted installs, pass flags and skip the network verification until secret
53
60
  hermes-github-app setup --non-interactive --skip-verify \
54
61
  --client-id Iv1.exampleclientid \
55
62
  --installation-id 987654 \
63
+ --installation-id-for ExampleOrg=123456 \
64
+ --installation-id-for ExampleInfra=987654 \
56
65
  --private-key-path ~/.hermes/secrets/agent-github-app.private-key.pem \
57
66
  --app-slug hermes-agent
58
67
  ```
@@ -75,6 +84,35 @@ git-app --repo OWNER/REPO push origin my-branch
75
84
  `gh-app` injects an ephemeral installation token as `GH_TOKEN` and `GITHUB_TOKEN` for the child `gh` process.
76
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.
77
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
+
78
116
  ## Migrating existing Hermes skills and jobs
79
117
 
80
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:
@@ -108,8 +146,8 @@ Before the first release, configure PyPI Trusted Publishing for this repository
108
146
  Release example:
109
147
 
110
148
  ```bash
111
- git tag 0.1.2
112
- git push origin 0.1.2
149
+ git tag 0.3.0
150
+ git push origin 0.3.0
113
151
  ```
114
152
 
115
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.1.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:
@@ -20,8 +20,14 @@ requires_env:
20
20
  description: "GitHub App client ID for this Hermes agent. Can also be set in ~/.hermes/config.yaml under github_app.client_id."
21
21
  secret: false
22
22
  - name: GITHUB_APP_INSTALLATION_ID
23
- description: "GitHub App installation ID for this Hermes agent. Can also be set in ~/.hermes/config.yaml under github_app.installation_id."
23
+ description: "Default GitHub App installation ID for this Hermes agent. Can also be set in ~/.hermes/config.yaml under github_app.installation_id."
24
+ secret: false
25
+ - name: GITHUB_APP_INSTALLATION_IDS
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."
24
27
  secret: false
25
28
  - name: GITHUB_APP_PRIVATE_KEY_PATH
26
- 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."
27
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.1.2"
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"
@@ -11,7 +11,17 @@ Use this skill for GitHub operations from Hermes agents that are configured with
11
11
 
12
12
  ## First-time setup
13
13
 
14
- 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.
14
+ 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:
15
+
16
+ ```yaml
17
+ github_app:
18
+ installation_id: "123456" # default/fallback
19
+ installation_ids:
20
+ ExampleOrg: "123456"
21
+ ExampleInfra: "789012"
22
+ ```
23
+
24
+ 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.
15
25
 
16
26
  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.
17
27
 
@@ -26,6 +36,14 @@ After setup, run `hermes-github-app doctor --repo OWNER/REPO` to verify console
26
36
  7. Expect comments, PRs, and API writes to appear as `<app-slug>[bot]`.
27
37
  8. Treat the GitHub App installation scope as the repository access boundary. The plugin does not maintain a separate local repository/owner allowlist.
28
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
+
29
47
  ## Updating existing Hermes skills
30
48
 
31
49
  Patch any GitHub-related skill, cron prompt, or subagent instruction that mentions `gh`, `git push`, GitHub SSH remotes, or `@me` assumptions:
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import re
5
6
  import time
6
7
  from dataclasses import dataclass
7
8
  from datetime import datetime, timedelta, timezone
@@ -11,9 +12,10 @@ from urllib.parse import urlparse
11
12
  import httpx
12
13
  import jwt
13
14
 
14
- from .config import GitHubAppConfig
15
+ from .config import NONO_PHANTOM_TOKEN, GitHubAppConfig
15
16
 
16
17
  _MIN_REDACT_LENGTH = 8
18
+ _BROKERED_TOKEN_LIFETIME = timedelta(hours=1)
17
19
 
18
20
 
19
21
  @dataclass(frozen=True)
@@ -25,6 +27,7 @@ class InstallationToken:
25
27
  installation_id: str
26
28
  client_id: str
27
29
  app_slug: str | None
30
+ token_broker: str | None = None
28
31
 
29
32
  @property
30
33
  def redacted(self) -> str:
@@ -40,31 +43,60 @@ class GitHubAppAuth:
40
43
  def __init__(self, config: GitHubAppConfig, client: httpx.Client | None = None) -> None:
41
44
  self._config = config
42
45
  self._client = client or httpx.Client(timeout=20)
43
- self._cached_token: InstallationToken | None = None
46
+ self._cached_tokens: dict[str, InstallationToken] = {}
44
47
 
45
48
  @property
46
49
  def config(self) -> GitHubAppConfig:
47
50
  return self._config
48
51
 
49
52
  def create_jwt(self) -> str:
50
- """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
51
60
  now = int(time.time())
52
61
  payload = {"iat": now - 60, "exp": now + 9 * 60, "iss": self._config.client_id}
53
62
  encoded = jwt.encode(payload, self._config.private_key, algorithm="RS256")
54
63
  return str(encoded)
55
64
 
56
- def get_installation_token(self, *, force_refresh: bool = False) -> InstallationToken:
65
+ def get_installation_token(
66
+ self, *, repo: str | None = None, force_refresh: bool = False
67
+ ) -> InstallationToken:
57
68
  """Return a valid installation token, refreshing when near expiry."""
69
+ installation_id = self._config.installation_id_for_repo(repo)
70
+ cached_token = self._cached_tokens.get(installation_id)
58
71
  if (
59
72
  not force_refresh
60
- and self._cached_token is not None
61
- and self._cached_token.expires_at > datetime.now(timezone.utc) + timedelta(minutes=5)
73
+ and cached_token is not None
74
+ and cached_token.expires_at > datetime.now(timezone.utc) + timedelta(minutes=5)
62
75
  ):
63
- return self._cached_token
76
+ return cached_token
64
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."""
65
98
  response = self._client.post(
66
- f"{self._config.github_api_url}/app/installations/"
67
- f"{self._config.installation_id}/access_tokens",
99
+ f"{self._config.github_api_url}/app/installations/{installation_id}/access_tokens",
68
100
  headers={
69
101
  "Accept": "application/vnd.github+json",
70
102
  "Authorization": f"Bearer {self.create_jwt()}",
@@ -75,15 +107,13 @@ class GitHubAppAuth:
75
107
  data = response.json()
76
108
  expires_at_raw = str(data["expires_at"])
77
109
  expires_at = datetime.fromisoformat(expires_at_raw.replace("Z", "+00:00"))
78
- token = InstallationToken(
110
+ return InstallationToken(
79
111
  token=str(data["token"]),
80
112
  expires_at=expires_at,
81
- installation_id=self._config.installation_id,
113
+ installation_id=installation_id,
82
114
  client_id=self._config.client_id,
83
115
  app_slug=self._config.app_slug,
84
116
  )
85
- self._cached_token = token
86
- return token
87
117
 
88
118
  def app_request(
89
119
  self,
@@ -120,6 +150,8 @@ class GitHubAppAuth:
120
150
  "client_id": self._config.client_id,
121
151
  "app_slug": self._config.app_slug,
122
152
  "installation_id": self._config.installation_id,
153
+ "installation_ids": dict(self._config.installation_ids),
154
+ "token_broker": self._config.token_broker,
123
155
  },
124
156
  "status_code": response.status_code,
125
157
  "result": response.json() if response.content else {"ok": True},
@@ -135,7 +167,8 @@ class GitHubAppAuth:
135
167
  params: dict[str, Any] | None = None,
136
168
  ) -> dict[str, Any]:
137
169
  """Call the GitHub REST API using the installation token."""
138
- token = self.get_installation_token()
170
+ repo = repo or repo_from_path(path)
171
+ token = self.get_installation_token(repo=repo)
139
172
  url = (
140
173
  path if path.startswith("http") else f"{self._config.github_api_url}/{path.lstrip('/')}"
141
174
  )
@@ -158,9 +191,11 @@ class GitHubAppAuth:
158
191
  "result": parsed,
159
192
  }
160
193
 
161
- def graphql(self, query: str, variables: dict[str, Any] | None = None) -> dict[str, Any]:
194
+ def graphql(
195
+ self, query: str, variables: dict[str, Any] | None = None, *, repo: str | None = None
196
+ ) -> dict[str, Any]:
162
197
  """Call GitHub GraphQL API using the installation token."""
163
- token = self.get_installation_token()
198
+ token = self.get_installation_token(repo=repo)
164
199
  response = self._client.post(
165
200
  f"{self._config.github_api_url}/graphql",
166
201
  headers={
@@ -172,7 +207,7 @@ class GitHubAppAuth:
172
207
  )
173
208
  response.raise_for_status()
174
209
  return {
175
- "auth": auth_metadata(token),
210
+ "auth": auth_metadata(token, repo=repo),
176
211
  "status_code": response.status_code,
177
212
  "result": response.json(),
178
213
  }
@@ -189,6 +224,7 @@ def auth_metadata(token: InstallationToken, *, repo: str | None = None) -> dict[
189
224
  "actor_expected": actor,
190
225
  "repository": repo,
191
226
  "token": token.redacted,
227
+ "token_broker": token.token_broker,
192
228
  "expires_at": token.expires_at.isoformat(),
193
229
  }
194
230
 
@@ -203,3 +239,17 @@ def requires_app_jwt(path: str) -> bool:
203
239
  return False
204
240
  normalized = "/" + path_only.lstrip("/")
205
241
  return normalized == "/app" or normalized.startswith("/app/")
242
+
243
+
244
+ def repo_from_path(path: str) -> str | None:
245
+ """Infer OWNER/REPO from common REST API repository paths."""
246
+ path_only = path.split("?", 1)[0]
247
+ if path_only.startswith("http"):
248
+ try:
249
+ path_only = urlparse(path_only).path
250
+ except ValueError:
251
+ return None
252
+ match = re.match(r"^/?repos/([^/]+)/([^/]+)(?:/|$)", path_only)
253
+ if not match:
254
+ return None
255
+ return f"{match.group(1)}/{match.group(2)}"