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
27 changes: 19 additions & 8 deletions platform/cluster/flux/apps/agents/GITHUB-APP-SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@ Agent runners use **two** GitHub credentials with a deliberate split:
broker is unavailable.
- **GitHub App installation token (short-lived, repo-scoped)** — the
workspace write path: `git push`, `gh pr create`, `gh pr comment`,
and `gh run rerun`. Permissions are `contents:write`,
`pull_requests:write`, and `actions:write` only; `administration` is
never requested, so this token cannot change repo settings. The
`gh run rerun`, authoring `.github/workflows` (the Pipeline Complete
pipeline), and keeping tracking issues current. Permissions are
`contents:write`, `pull_requests:write`, `actions:write`,
`issues:write`, `workflows:write`, and `packages:read`;
`administration` is never requested, so this token cannot change repo
settings or rulesets. `packages` is read-only — publishing is done by
the release workflow's own `GITHUB_TOKEN`, not by runner tokens. The
`main` ruleset still blocks force-push and branch deletion.

assistant-api mints a fresh, single-repo installation token on demand;
Expand Down Expand Up @@ -42,7 +46,10 @@ and click **Create GitHub App** — or set the same values by hand at
"metadata": "read",
"contents": "write",
"pull_requests": "write",
"actions": "write"
"actions": "write",
"issues": "write",
"workflows": "write",
"packages": "read"
},
"default_events": []
}'
Expand Down Expand Up @@ -153,7 +160,8 @@ but `git push` / `gh pr create` / `gh run rerun` behave read-only, and
Cause: GitHub mints an installation token scoped to **whatever the
installation actually holds**, silently dropping anything the App was
not granted. assistant-api always _requests_ `contents`,
`pull_requests`, and `actions` at `write`
`pull_requests`, `actions`, `issues`, `workflows` at `write` and
`packages` at `read`
(`GitHubAppInstallationTokenClient.REQUESTED_PERMISSIONS`), so a
narrowed token means the **App itself** is under-permissioned — almost
always one of:
Expand All @@ -168,15 +176,18 @@ assistant-api logs this exact case at WARN on the next mint:

```
installation token for <owner>/<repo> is missing requested permissions
[actions, contents, pull_requests] (granted: {metadata=read}). Widen the
personal-stack-agents App's repository permissions … then approve …
[actions, contents, issues, packages, pull_requests, workflows]
(granted: {metadata=read}). Widen the personal-stack-agents App's
repository permissions … then approve …
```

Fix:

1. App settings → **Permissions → Repository permissions**: set
Metadata: Read-only, **Contents: Read and write**, **Pull requests:
Read and write**, **Actions: Read and write**. Save.
Read and write**, **Actions: Read and write**, **Issues: Read and
write**, **Workflows: Read and write**, **Packages: Read-only**. Do
not grant Administration. Save.
2. For **each** account the App is installed on (ExtraToast and
ESA-Blueshell), open the installation and **approve the updated
permissions** (GitHub surfaces a "review request" banner; an org
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,18 @@ import java.util.Base64
* Mints short-lived, single-repo GitHub App installation tokens so the
* runner's `gh` can create PRs/re-run Actions and `git push` can create
* feature branches over HTTPS. Each token is scoped to the one
* repository being acted on and carries only `contents:write`,
* `pull_requests:write`, and `actions:write`; `administration` is never
* requested, so the token cannot change repo settings. `main` stays
* guarded by branch protection.
* repository being acted on and carries `contents:write`,
* `pull_requests:write`, `actions:write`, `issues:write`,
* `workflows:write`, and `packages:read`; `administration` is never
* requested, so the token cannot change repo settings or rulesets.
* `main` stays guarded by branch protection.
*
* `workflows:write` lets the runner author the per-repo CI pipeline that
* ends in the required `Pipeline Complete` check; `issues:write` lets it
* keep tracking issues/milestones current; `packages:read` lets local
* builds resolve published `dev.extratoast.*` / `@extratoast` artifacts.
* Artifact publishing stays a CI concern (the release workflow's own
* `GITHUB_TOKEN`), so `packages:write` is not requested here.
*
* Disabled — [enabled] is false and [mint] returns null — whenever the
* App id or private key is absent, so an unconfigured deployment is a
Expand Down Expand Up @@ -90,9 +98,10 @@ class GitHubAppInstallationTokenClient(
if (shortfall.isNotEmpty()) {
log.warn(
"installation token for {}/{} is missing requested permissions {} (granted: {}). " +
"Widen the personal-stack-agents App's repository permissions to contents/pull_requests/" +
"actions: read & write, then approve the updated permissions on the {} installation — " +
"until then runner git push / gh pr / gh run rerun stay read-only.",
"Widen the personal-stack-agents App's repository permissions (contents/pull_requests/" +
"actions/issues/workflows: read & write, packages: read), then approve the updated " +
"permissions on the {} installation — until then runner git push / gh pr / gh run rerun / " +
"workflow edits / issue edits stay restricted.",
slug.owner,
slug.repo,
shortfall,
Expand Down Expand Up @@ -183,14 +192,21 @@ class GitHubAppInstallationTokenClient(
private const val GH_API_VERSION_HEADER = "X-GitHub-Api-Version"
private const val GH_API_VERSION = "2022-11-28"

// The only permissions a runner token ever carries: enough for
// git push, gh pr create/comment, and gh run rerun. `administration`
// is deliberately absent so the token cannot change repo settings.
// The permissions a runner token carries: enough for git push,
// gh pr create/comment, gh run rerun, authoring `.github/workflows`
// (the Pipeline Complete pipeline), keeping tracking issues current,
// and resolving published packages for local builds. `administration`
// is deliberately absent so the token cannot change repo settings or
// rulesets; `packages` is read-only because publishing is done by the
// release workflow's own GITHUB_TOKEN, not by runner tokens.
val REQUESTED_PERMISSIONS =
mapOf(
"contents" to "write",
"pull_requests" to "write",
"actions" to "write",
"issues" to "write",
"workflows" to "write",
"packages" to "read",
)

private val PERMISSION_RANK = mapOf("read" to 1, "write" to 2, "admin" to 3)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,14 @@ class GitHubAppInstallationTokenClientTest {
.andExpect(jsonPath("$.permissions.contents").value("write"))
.andExpect(jsonPath("$.permissions.pull_requests").value("write"))
.andExpect(jsonPath("$.permissions.actions").value("write"))
.andExpect(jsonPath("$.permissions.issues").value("write"))
.andExpect(jsonPath("$.permissions.workflows").value("write"))
.andExpect(jsonPath("$.permissions.packages").value("read"))
.andRespond(
withSuccess(
"""{"token":"ghs_abc","expires_at":"2026-06-02T15:00:00Z",""" +
""""permissions":{"contents":"write","pull_requests":"write","actions":"write"}}""",
""""permissions":{"contents":"write","pull_requests":"write","actions":"write",""" +
""""issues":"write","workflows":"write","packages":"read"}}""",
MediaType.APPLICATION_JSON,
),
)
Expand Down Expand Up @@ -122,23 +126,37 @@ class GitHubAppInstallationTokenClientTest {
fun `narrowedPermissions flags absent and weaker grants, and passes a full grant`() {
val requested = GitHubAppInstallationTokenClient.REQUESTED_PERMISSIONS

// A metadata-only install grants none of contents/pull_requests/actions.
// A metadata-only install grants none of the requested permissions.
assertThat(GitHubAppInstallationTokenClient.narrowedPermissions(requested, emptyMap()))
.containsExactly("actions", "contents", "pull_requests")
.containsExactly("actions", "contents", "issues", "packages", "pull_requests", "workflows")

// contents granted read-only is weaker than the requested write.
assertThat(
GitHubAppInstallationTokenClient.narrowedPermissions(
requested,
mapOf("contents" to "read", "pull_requests" to "write", "actions" to "write"),
mapOf(
"contents" to "read",
"pull_requests" to "write",
"actions" to "write",
"issues" to "write",
"workflows" to "write",
"packages" to "read",
),
),
).containsExactly("contents")

// Exactly the requested write set — nothing narrowed.
// Exactly the requested set — nothing narrowed.
assertThat(
GitHubAppInstallationTokenClient.narrowedPermissions(
requested,
mapOf("contents" to "write", "pull_requests" to "write", "actions" to "write"),
mapOf(
"contents" to "write",
"pull_requests" to "write",
"actions" to "write",
"issues" to "write",
"workflows" to "write",
"packages" to "read",
),
),
).isEmpty()
}
Expand Down
Loading