CLAUDE.md@plugins/gh-tools/skills/gh-fine-grained-pat · git:20261022.04eff2b · 2026-10-22 · sha256 54fe7802bf679c37
CLAUDE.md@plugins/gh-tools/skills/gh-fine-grained-pat git:20261022.04eff2bB
Immutable. This exact content never changes and is served at /api/v1/blob/54fe7802bf679c37.
# gh-fine-grained-pat — maintainer SSoT
Per-skill compass (sibling to [SKILL.md](./SKILL.md)). **Hub**: [gh-tools CLAUDE.md](../../CLAUDE.md). Read this before editing the engine — the GitHub UI is the fragile part and the load-bearing knowledge lives here.
## Why this exists
GitHub has **no API to create fine-grained PATs** (only the web UI). We created one by hand-driven browser automation, hit every gotcha, and codified the result here so it never has to be re-learned. The declarative JSON spec is the SSoT; the engine is a thin, anti-fragile UI driver.
## File map
| File | Role |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `scripts/pat.mjs` | CLI: `login \| doctor \| create \| rotate \| list \| inspect \| delete \| register \| patch-password \| agent \| accounts \| quit`. |
| `scripts/browser.mjs` | Chrome launch (persistent profile), CDP attach (`/json/version` → `connectOverCDP`, retry), specific-PID teardown. |
| `scripts/form.mjs` | The form driver — all the gotchas. `createToken / listTokens / inspectToken / deleteToken` + `ensureFormReady` (sudo). |
| `scripts/selectors.mjs` | Selector constants + generic click/wait/DOM-fallback/screenshot helpers. |
| `scripts/identity.mjs` | Resolve target account (flag → host-alias → spec owner → logged-in) + provisioned-account registry. (ADR 2026-06-26) |
| `scripts/webauthn.mjs` | CDP virtual-authenticator helpers (mount/inject/getCredentials) — autonomous passkey, no biometric at run time. |
| `scripts/autosudo.mjs` | Clear GitHub sudo via the gated `github-web-<account>` blob: passkey primary, password/TOTP fallback. |
| `scripts/webauth-agent.mjs` | Memory-only session agent (ssh-agent pattern) → one Touch-ID unlock lasts the session. |
| `schema/token-spec.schema.json` | JSON Schema 2020-12 — formal spec SSoT. |
| `specs/*.json` | Six templates (release-bot, relay-monitor-release-bot, read-only-auditor, ci-status-reporter, account-scoped, kitchen-sink) + `specs/examples/`. |
| `test/campaign.mjs` | Empirical create→verify→delete harness + forced-failure + leak sweep. |
| `test/autonomous.test.mjs` | Unit tests (identity parsing, credential serde, agent put/get/TTL). |
| `test/webauthn-smoke.mjs` | Live CDP proof: virtual-authenticator create→capture→restore on localhost (GitHub-independent). |
## Runtime invariants (do NOT change blindly)
- **node, never bun.** Bun's `connectOverCDP` times out; node attaches in <1s. All scripts are `.mjs` run with `node`.
- **`--remote-allow-origins=*`** on the Chrome launch — Chrome 111+ rejects the CDP websocket without it.
- **CDP attach** = fetch `http://127.0.0.1:9222/json/version` → `webSocketDebuggerUrl` → `chromium.connectOverCDP()` (mirrors `gemini-deep-research/scripts/client.ts`). Retry both steps.
- `playwright-core` resolves from the repo root `node_modules` (its version range lives in the root `package.json`, the SSoT); scripts just `import { chromium } from "playwright-core"`.
- **Teardown kills a specific PID** found via `lsof -iTCP:<port>`; never `pkill -f` (process-storm policy in `~/.claude/CLAUDE.md`).
- **`browser.close()` after each command** disconnects Playwright only — the externally-launched Chrome keeps running (persistent session). Use `pat quit` to actually kill it.
## Hard-won gotchas (encoded in `form.mjs`)
1. **Generate-confirmation overlay — detection, and the trap in the old fix.** After clicking the form's "Generate token", a summary overlay headed "New personal access token" appears with its own "Generate token" button. ⚠️ **Two corrections, measured 2026-09-04.** (a) It IS a `<dialog>` today: `DIALOG#fgpat-confirmation-dialog.Overlay < DIALOG-HELPER < FGPAT-CONFIRMATION-DIALOG`, and the two buttons are distinguishable by `type` — the form's is `type="button"` (`.js-integrations-install-form-submit`, it merely OPENS the overlay), the overlay's is `type="submit"`. (b) The old advice — "click the **last** visible button whose text is exactly Generate token", **unconditionally, 1.6 s after submitting** — was the bug described in GOTCHA #17: the overlay only appears for some specs, so when it does not, the last visible button IS the one already clicked and the form is submitted TWICE. Gate any such click on the overlay actually being present (`SEL.confirmModalHeading`).
2. **Repo picker intercepts pointer events.** After selecting repos in `#repository-menu-list-dialog`, the dialog stays open and swallows the next click. Fix: close it via `button[aria-label="Close"]` before continuing (`setRepoAccess()`).
3. **"No expiration"** is selectable inline (no immediate modal); its confirmation arrives at the generate-time summary overlay — handled by (1). The inline amber "GitHub strongly recommends…" warning is not a blocker.
4. **Permission access levels.** Newly added permissions default to "Read-only". Each row has an "Access:" button → menu with `menuitemradio` "No access" / "Read-only" / "Read and write". Match the row by its heading prefix, then pick the target level. **Never touch Metadata** (auto-required RO).
5. **Permissions are split across two tabs.** The Permissions section has "Repositories" and "Account" tabs, each with its **own** "Add permissions" menu ("Select repository permissions" / "Select account permissions"). Repository perms (`Contents`, …) are only in the repo menu; account perms (`Gists`, …) only in the account menu. Add + set-level each group while its tab is active (`applyGroup`). _Discovered empirically: searching "Gists" in the repo menu returns "No items available"._
6. **Token-list scrape: capture only the name link.** Each token row has a name link `/settings/personal-access-tokens/<id>` AND a `…/<id>/regenerate?index_page=1` link that shares the id. A naive id-keyed map lets the regenerate link (text "This token has no expiration date") overwrite the name. Fix: match `/\/personal-access-tokens\/(\d+)(?:[?#]|$)/` so only the bare-id href is captured. _This caused a false "cc-skills-release MISSING" sweep on the first run._
7. **Detail-page verification uses friendly nouns, not labels.** A token's detail page renders permissions as prose grouped by access level — e.g. _"Read access to actions, metadata, and repository hooks"_ / _"Read and Write access to code, commit statuses, deployments, and environments"_ — NOT per-label rows. `test/campaign.mjs` parses these clauses and maps UI label → detail noun (below).
8. **GitHub sudo mode ("Confirm access").** On a session that hasn't re-authed recently (e.g. after a Chrome restart), navigating to the new-token page shows a "Confirm access" passkey/2FA challenge instead of the form — `input[name="user_programmatic_access[name]"]` is absent. This cannot be bypassed programmatically. `createToken` → `ensureFormReady()` detects it (page title "Confirm access") and waits up to 3 min for the operator to confirm in the browser (or clears it via the gated passkey when `GH_PAT_AUTONOMOUS=1`), then proceeds.
9. **Passkey enablement invalidates the session ONCE.** Right after the `register` ceremony adds a passkey, GitHub commonly forces a fresh sign-in — the _next_ run lands on the full login page (not just sudo). Re-run `pat login --account <a>` ONE more time; the session then persists across restarts (verified for the account named `vanjobbers` at the time, `cptbc` since 2026-10-03). The ceremony prints this heads-up.
10. **Gists has no Read-only.** GitHub's `Gists` account permission only offers "Read and write" — a spec with `Gists: "read"` fails at `setAccessLevels` ("Read-only not selectable"). Pick the level GitHub actually offers.
11. **Passkey sudo assertion races the virtual authenticator → retry the click.** GitHub's `/sessions/sudo` page auto-fires `navigator.credentials.get()` on load — _before_ `autosudo` mounts its virtual authenticator — so the first attempt shows "Authentication failed" and the button flips "Use passkey" → "Retry passkey". A single post-mount click can still race the authenticator's warm-up. `tryPasskey` therefore **arms the authenticator, then re-clicks the assertion up to 6×** (regex matches both "Use passkey" and "Retry passkey"); a subsequent fresh assertion reliably succeeds. _Empirically: `terrylica` won the race on the first click and looked fine; `vanjobbers` (today's `cptbc`) lost it and failed — the retry loop fixes both. The credential itself was valid the whole time (a manual "Retry passkey" with the same stored credential cleared sudo)._
12. **A session check is not an identity check — and `--account` was ignored by half the verbs.** `isAuthedViaRequest` answers "a session exists", never "whose". The engine DERIVES the profile dir from the resolved account (`profile` for the shared account, `profile-<account>` otherwise) and then trusts that binding. Worse, only `create`/`login`/`quit` ever set `GH_PAT_ACCOUNT`; `list`/`inspect`/`delete` silently read the SHARED profile whatever account you named, then labelled the result with the account you asked for. Measured 2026-09-04: `list --account terrylica` printed **"(no fine-grained tokens)"** while reading a profile signed in as **`work`** — a TRUE statement about work's genuinely empty list, indistinguishable from a fact about terrylica's. `doctor` printed `auth authenticated ✓` over the same state. A `create` would have minted on work, and `setOwner`'s catch-all (`catch { /* owner already correct */ }`) would have swallowed the absent `example-org` resource-owner option, producing a silently wrong token that fails only later, mid-release. Fix: `session()` now resolves `--account` at the one chokepoint every verb passes through (before `launchChrome()`, since profile/port derive from it) and `assertProfileIdentity()` compares `meta[name="user-login"]` to the resolved account, refusing on mismatch **and on an unreadable login** (INCOMPLETE is not a match). `doctor` gained a `signed in as` row; `login` no longer says "already authenticated" for the wrong identity. Escape: `GH_PAT_SKIP_IDENTITY_CHECK=1`.
13. **Assert the RESULT, never the absence of an exception.** `clickOption()` RETURNS FALSE on a miss and `setOwner`'s click sat in a catch-all commented `/* owner already correct */` — a guess rendered as a fact. Every signal the function had was negative-only, so a missed resource-owner selection was completely silent and the form kept its defaults. `setOwner` now reads the control back and refuses on a **positive** mismatch (it names a different owner); an unreadable control warns instead of blocking, because a UI rename must not be able to veto a correct mint. Same treatment in `setExpiration`. **Note the sharp edge in the empty case:** the first version tested `if (!ok && shown)`, so an unreadable-and-therefore-empty read scored as a PASS — the assertion silently disarmed itself. Empty is INCOMPLETE, never OK.
14. **The owner control is labelled with the CURRENT owner, so a selector naming the DESIRED owner can never match it.** The original locator was `getByRole("button", {name: /<spec.owner>|owner/i})`; the button's accessible name is the signed-in login (`terrylica`), matching neither alternative, so the click timed out into the catch-all. Anchor on the one string that does not change — the **"Resource owner" field label** — and take the first control inside its container (`ownerControl()`, exported for probes). Read it back the same way.
15. **Menu rows are `role="none"` wrappers; click the node that carries the behaviour.** GitHub's ActionList renders every option as `<li class="ActionListItem" role="none"><button role="option|menuitemradio" data-value="…"><span class="ActionListItem-label">`. The `li`, the `button` and the `span` all carry the same `textContent`, and **the `li` comes first in document order** — so a text-matching DOM walk (the old `evalClick(page, '…,li,button', re)` fallback) clicks the wrapper. That does nothing AND throws nothing, so the fallback reported success. This one defect silently defeated **both** the resource-owner picker and the expiration menu. `clickOption()`'s fallback now descends to the interactive child and skips `role="none"`/`presentation`.
16. **"No expiration" is not always on the menu — an ORG can cap token lifetime.** With resource owner `example-org` the menu is 7/30/60/90/**366** days + "Custom between 1 and 366 days", labelled "Limit set by example-org"; the `li` even advertises it as `data-fg-limit="366"` / `data-fg-limit-exempt="false"`. The option is **removed, not disabled**, and `clickExact()` does not throw on a miss, so a spec asking for `"none"` was silently downgraded to the 30-day default. `setExpiration` now fails closed and prints what WAS offered. Corollary worth internalising: a token that "never expires" under a user owner may simply be evidence that the owner is wrong. ⚠️ `clickExact()` resolves to `undefined` and **throws** on a miss — catch it; testing its return for falsiness fires on success.
17. **Never click the confirm button unconditionally — it double-submits.** See GOTCHA #1(b). The second POST raced the first, GitHub answered with a freshly-rendered form, and the failure screenshot showed the form back at its **defaults** — which reads as "your form was filled wrong" and sends you after the wrong bug. A `GH_PAT_TRACE=1` run had already proven owner and expiration were correct at submit time; the screenshot was of a different, later form. `GH_PAT_TRACE=1` prints the owner and expiration controls after every phase; it is what moved the search to `generate()`.
18. **The overlay's confirm needs a REAL mouse event.** An in-page `HTMLElement.click()` on the overlay's submit produced an untrusted event: the overlay closed, the browser landed back on the form with NO token created and no error anywhere on the page or in the token list. Drive it through Playwright (`getByRole(...).last().click()`), which dispatches a genuine CDP input event — but only when the overlay is up (GOTCHA #17).
19. **An over-long `description` is rejected by the SERVER, silently.** The description textarea advertises **no** maxlength (`maxLength === -1`), so the browser types any length happily; the POST is then dropped, the form re-renders with defaults, no token is created, and **no error text appears anywhere** — not as a flash, not inline, not in the list. Measured 2026-09-04: **4272 chars failed repeatedly, 191 chars succeeded on the very next attempt with every other field byte-identical.** This is now blocked in `validateSpec()` (>1000 chars) before the browser opens, and specs carry long-form rationale in the new **`notes`** field, which the engine never types into the UI. _Diagnostic value: this failure is indistinguishable from a mis-filled form, and it is what made GOTCHAs #14–#18 so slow to isolate — three of them were real and fixed while this one kept the symptom alive._
20. **Verify a token's reach with `curl`, never `gh` — and the detail page's repos are no longer links.** Measured 2026-10-05 while minting `private-monorepo-release`. (a) On the operator's Mac, `gh api` answered with the operator's OWN login even with `GH_TOKEN` exported (the shell's `gh` wrapper chooses the account from the repo alias), so a correctly scoped token appeared to read another private repo; the token was revoked and re-minted before `curl -H "Authorization: Bearer $GH_TOKEN"` showed 200 for its repo and 404 for the rest. (b) `inspect` printed `repos: (none listed)` for every selected-repos token, which looked like corroboration: GitHub now renders the selected repos as plain text (`span.color-fg-muted` plus a `tool-tip`) under "Repository access", and `inspectToken()` only scanned links. It now accepts any leaf element whose whole text is `owner/repo`, excluding the editable name/description fields.
21. **A spec for an org's repo must set `owner`.** `setOwner()` returns on its first line when `spec.owner` is unset (default = the signed-in user), so naming `org/repo` in `repositoryAccess.repos` alone mints an account-scoped token that cannot see the org repo. Measured 2026-09-04 on `specs/relay-monitor-release-bot.json`: `GET /repos/<org>/<repo>` → 404, git upload-pack and receive-pack → 403.
## Live selector map (update when GitHub drifts)
| Step | Selector / strategy |
| ----------------- | ------------------------------------------------------------------------------------------ |
| Name | `input[name="user_programmatic_access[name]"]` |
| Description | `textarea[name="user_programmatic_access[description]"]` |
| Expiration open | `button` name `/days \( \| No expiration \| Custom \| Expiration/i` |
| Expiration pick | exact text "No expiration" / `^<N> days` option |
| Repo access radio | exact text "Public/All repositories" or "Only select repositories" |
| Repo picker open | `button` name `/Select repositories/i` |
| Repo search | `input[type="search"]:visible` |
| Repo option | exact text `owner/name` |
| Repo picker close | `#repository-menu-list-dialog button[aria-label="Close"]` |
| Add permissions | `button` name `/Add permissions/i`; check via `menuitemcheckbox` name = label |
| Access level | row "Access:" button → `menuitemradio` name = level |
| Generate | `button` name `/^Generate token$/i` (form), then **last** visible "Generate token" (modal) |
| Token value | `input` whose value starts `github_pat_` |
| Token list | `a[href*="/settings/personal-access-tokens/<id>"]` (text = name) |
| Detail: repos | leaf `span`/`tool-tip` text `owner/repo` under "Repository access" (not links, 2026-10) |
| Account perms tab | `role=tab` name `/Account/i` → its own "Add permissions" menu |
## Detail-page noun map (for verification)
The token detail page names permissions with friendly nouns, not the UI labels. `test/campaign.mjs` (`NOUN`) maps them. Verified empirically:
| UI label | Detail noun | | UI label | Detail noun |
| ------------- | --------------- | --- | --------------- | ---------------------- |
| Contents | `code` | | Commit statuses | `commit statuses` |
| Issues | `issues` | | Deployments | `deployments` |
| Pull requests | `pull requests` | | Environments | `environments` |
| Actions | `actions` | | **Webhooks** | **`repository hooks`** |
| Metadata | `metadata` | | Gists (account) | `gists` |
Verified for `specs/examples/dependabot-secrets.json`: `Dependabot secrets` → `dependabot secrets`, `Secrets` → `secrets`, `Secret scanning alerts` → `secret scanning alerts`.
When adding a new permission to a spec, confirm its detail noun (create one token, read the detail page) and add it to `NOUN` — otherwise verification reports a false "perm missing".
## Security invariants
- Token value never reaches stdout/chat: `--out` writes 0600; `--vault scope:dot` shells `vault set` (value in-process). `inspect`/`list` never expose the value (the value is not on any later page anyway).
- Replacing a live token: mint the new one under a **different name** and revoke the old one only after the new one is proven, rather than `--replace`, so a failure at any point leaves you no worse off (practice from the 2026-09-04 relay-monitor mint, which failed seven times).
- Persistent profile (`~/.local/share/gh-pat-automation/profile`) holds the GitHub session cookie → sensitive, outside the repo, never committed.