From f901eb56f4b17e16de853834e156efe014f0773c Mon Sep 17 00:00:00 2001 From: "personal-stack-agents[bot]" Date: Mon, 8 Jun 2026 11:02:37 +0000 Subject: [PATCH] feat(agent-token): request workflows, issues, packages:read in minted tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Minted installation tokens carried only contents/pull_requests/actions:write, so runner tokens could not push .github/workflows files (blocking the required Pipeline Complete pipeline), edit/comment tracking issues, or resolve published packages for local builds. GitHubAppInstallationTokenClient.REQUESTED_PERMISSIONS now also requests issues:write, workflows:write, and packages:read. administration is still never requested (rulesets/settings stay operator-owned) and packages stays read-only — artifact publishing is done by the release workflow's own GITHUB_TOKEN, not by runner tokens. Tests and the App setup doc (manifest, troubleshooting, fix steps) are updated to the widened set. Takes effect once the App permissions are approved on each installation and a fresh token is minted (cached token dropped). Part of #605. Closes #606. --- .../flux/apps/agents/GITHUB-APP-SETUP.md | 27 +++++++++----- .../GitHubAppInstallationTokenClient.kt | 36 +++++++++++++------ .../GitHubAppInstallationTokenClientTest.kt | 30 ++++++++++++---- 3 files changed, 69 insertions(+), 24 deletions(-) diff --git a/platform/cluster/flux/apps/agents/GITHUB-APP-SETUP.md b/platform/cluster/flux/apps/agents/GITHUB-APP-SETUP.md index 1f092a5c..7f076b73 100644 --- a/platform/cluster/flux/apps/agents/GITHUB-APP-SETUP.md +++ b/platform/cluster/flux/apps/agents/GITHUB-APP-SETUP.md @@ -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; @@ -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": [] }' @@ -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: @@ -168,15 +176,18 @@ assistant-api logs this exact case at WARN on the next mint: ``` installation token for / 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 diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/GitHubAppInstallationTokenClient.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/GitHubAppInstallationTokenClient.kt index 906ed8b0..2a522ee9 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/GitHubAppInstallationTokenClient.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/GitHubAppInstallationTokenClient.kt @@ -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 @@ -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, @@ -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) diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/GitHubAppInstallationTokenClientTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/GitHubAppInstallationTokenClientTest.kt index d09dcef4..71054f52 100644 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/GitHubAppInstallationTokenClientTest.kt +++ b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/integration/GitHubAppInstallationTokenClientTest.kt @@ -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, ), ) @@ -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() }