GitHub access with a GitHub App¶
The box has no GitHub credential by default. To let an agent in it read a pull request, answer
review comments or check CI, give it a GitHub App through a github-app [[inject]] row.
Once the switch is on, gh in the box works against GitHub's API as the App. The App's private
key stays on your computer, a token service there turns it into a short-lived token, and the
proxy adds that token to requests on their way out, so the box only ever holds a placeholder.
This page walks through it once, end to end. The row's full reference is in configuration.md; how switches work is in modes.md.
Why an App¶
- You choose exactly what it can do. An App has its own permissions, set per area (pull requests, issues, checks…), and is installed only on the repositories you pick. A personal token carries everything you can do.
- Its tokens are short-lived. foldyard makes an installation token on your computer, good for an hour, and makes a fresh one before it expires. Nothing long-lived ever reaches the box.
- The installation's permissions are the scope. foldyard asks GitHub for no narrower permissions and caps none: what the App installation grants is what the box can do (ADR-0031). The one place to decide it is the App's settings on github.com, where widening it needs an org owner's approval. foldyard reports what it sees, and tells you when it changes (below).
- It belongs to no one person. Comments and replies show as the App, and removing access is one uninstall.
One App per purpose. If two jobs need different permissions, make two Apps, each with its own row and switch: a shared App hands the box both jobs' permissions.
1. Register the App¶
On github.com, create the App under the account that owns the repositories: for an organization, its Settings → Developer settings → GitHub Apps → New GitHub App (an org owner does this, or someone they've made an App manager); for your own repositories, the same under your personal settings.
- Name and Homepage URL: anything; the URL is not used.
- Webhook: untick Active. foldyard never receives events.
- Where can this GitHub App be installed?: Only on this account.
- Repository permissions: only what the job needs. Metadata: Read-only is always added.
| job | repository permissions |
|---|---|
| read and answer PR reviews and comments | Pull requests: Read and write (it also covers a PR's conversation comments, which use the issues API; add Issues: Read and write only to work on plain issues) |
| open pull requests | Pull requests: Read and write (the branch must already be on GitHub) |
| read CI results | Checks: Read-only · Actions: Read-only · Commit statuses: Read-only |
| read code through the API | Contents: Read-only |
Leave everything else at No access, in particular Contents: Read and write,
Administration, Workflows and Secrets. Contents write would let the box change
branches through the API even though git push from the box doesn't work (see
what works).
After creating it, note the App ID on its General page (a number; not the Client ID). Then,
further down the same page, Generate a private key. Your browser downloads a .pem file, and
GitHub keeps no copy: you'll paste it into foldyard in step 4.
2. Install it on only the repositories it needs¶
On the App's page, Install App, pick the account, then Only select repositories and choose
the repository (or few) the box works on. After installing, the page's address ends in the
installation id: …/settings/installations/7654321.
3. Add the [[inject]] row, and adopt it¶
In foldyard.toml:
[[inject]]
switch = "github" # `fy mode github=on`; the key is host.env's FY_INJECT_GITHUB
kind = "github-app"
app_id = "123456"
installation_id = "7654321"
repositories = ["my-repo"] # optional: narrow the token to some of the installation's repos
box_env = { GH_TOKEN = "x" } # so `gh` in the box sends a header for the proxy to replace
app_idandinstallation_idare identifiers, not secrets, so they're committed.repositoriestakes bare names, notmy-org/my-repo. It can only narrow: GitHub refuses a repository the installation doesn't cover.- There is no
hostto set: the kind always injects onapi.github.comand refuses any other. - There is no
permissionskey, and foldyard refuses to load a row with a field the kind doesn't take.
The switch name also names the secret: FY_INJECT_<SWITCH>, uppercased, so FY_INJECT_GITHUB
here. You can't choose another variable, so a repo edit can't point the row at a different
secret.
The row tells your computer where to send a credential, so it does nothing until you adopt
it: answer the prompt at your next fy up, or read fy config diff and run fy config adopt
(the adopted config). An
edit made in the box changes nothing until you do.
box_env is baked in when the box is created. If the box was already running, recreate it once:
fy box down && fy box up. After that, turning the switch on or off needs no recreate.
Installing gh in the box is your box image's job.
4. Switch it on, and give it the key¶
On your computer:
fy mode github=on
The first time, foldyard sees that FY_INJECT_GITHUB is missing and asks for it on your terminal
before switching, with a hidden prompt. host.env holds one KEY=VALUE per line, so the key goes
in as single-line base64 (the .pem file is several lines):
base64 < my-app.private-key.pem | tr -d '\n'
Paste the output. foldyard checks it decodes to a whole private key (a half-pasted one is refused
and nothing is stored) and writes it to host.env in ~/.foldyard/<project>/, readable only by
you. You can also add the line there yourself; the next token request reads it. Then put the .pem
somewhere safe, such as your password manager, or delete it: you can always generate another.
If your team keeps the key in a vault, retarget the prompt's "get it with" hint with a [[secret]]
row naming the same variable. The hint is printed for you to run, never run by foldyard:
[[secret]]
var = "FY_INJECT_GITHUB"
how = "op read op://dev/github-app/private-key | base64 | tr -d '\\n'"
fy doctor on your computer shows a github PEM row (is the key there?) and a github PEM shape
row (does it decode to a key?). Both are offline checks. Whether GitHub accepts the key is the
switch's capability check, below.
5. Check it from the box¶
In the box:
fy doctor # "github injection — token reaching requests (rate limit 5000/h)"
fy verify # GH_TOKEN is the dummy: the real credential stays on your computer
The github injection row asks GitHub for its rate limit. An anonymous caller gets 60 requests an
hour and the App far more, so the limit alone shows whether the token is really being added. If it
says "NOT injected" while fy mode shows the switch on, run fy host restart on your computer;
fy doctor there names the credential's problem.
Then try what the App is for, with REST calls through gh api:
R=repos/my-org/my-repo
gh api $R/pulls/42 --jq '{title, sha: .head.sha, mergeable_state}'
gh api $R/pulls/42/reviews --paginate --jq '.[] | {user: .user.login, state, body}'
gh api $R/pulls/42/comments --paginate --jq '.[] | {id, path, line, body}' # inline comments
gh api $R/issues/42/comments --paginate --jq '.[] | {user: .user.login, body}'
gh api -X POST $R/pulls/42/comments/<comment-id>/replies -f body='Fixed in <sha>.'
gh api $R/commits/<sha>/check-runs --jq '.check_runs[] | {name, status, conclusion}'
Replies are public and signed by the App.
GitHub names the permission an endpoint needs in each response's X-Accepted-GitHub-Permissions
header. gh api -i <endpoint> shows it, which is a quick way to see whether a call fits the App
before widening anything. Finally, confirm what should fail does fail: git push from the box,
or a call that needs a permission you didn't grant (a 403).
What works from the box¶
- REST through
gh apiworks, within the App's permissions. gh pr view --json …may not.gh pr viewandgh pr listuse GitHub's GraphQL API, and fields such asreviews,commitsandstatusCheckRollupgo through commit objects that need Contents: Read. Without it the whole query fails with "Resource not accessible by integration". Use the REST calls above, or grant Contents: Read if the App should read code anyway.git pushnever works through this row, whatever the App holds. The token is added only onapi.github.com; git talks togithub.com, which gets nothing. Push from your computer. The same goes for a PR: push the branch from your computer, then the box can open the PR withgh api -X POST $R/pulls -f head=<branch> -f base=main -f title=… -f body=….
When a call fails:
- 403 "Resource not accessible by integration": the token got there, but the App lacks that
permission. The
X-Accepted-GitHub-Permissionsheader names it. Change the App on github.com, notfoldyard.toml. - 401 starting
foldyard: `github` is off: the switch is off. The proxy answered and sent nothing upstream. Runfy mode github=onon your computer. - 502 "foldyard could not mint a credential for api.github.com …": making the token failed on
your computer, and the message quotes why.
fy host logsthere has the details.
More on these answers: modes.md.
Seeing the scope¶
While the switch is on, its capability check runs every 5 minutes. It signs in as the App (without
making a token), checks the key works and the installation still exists, and reads the installation's
permissions. A failing check shows the switch as DEGRADED in fy mode, naming the fix: a rotated
or wrong key, an uninstalled App (installation … not found), or a suspended installation
(DEGRADED).
The permissions it read are recorded on your computer. fy config widenings shows them under the
row, with any write or admin permission flagged:
api.github.com ← [[inject]] github (github-app; App private key: $FY_INJECT_GITHUB) [ON]
Host is REPO CONFIG: adopting a change here re-points the credential.
scope (probed 2026-10-17T09:12:00+00:00, 3m ago): checks:read, issues:WRITE, metadata:read, pull_requests:WRITE — selected repositories
⚠ write or admin: issues, pull_requests — what the box can change through this credential
fy mode and fy tui show the same reading beside the switch. Both read the record and never call
GitHub. Before the switch has first been on, the row says "not yet probed". When the permissions
change, say after an org owner accepted a new one on github.com, the supervisor logs one line
naming what changed (fy host logs) and, on macOS, shows one desktop notification.
Rotating the key, and switching it off¶
To rotate: on the App's page, generate a new private key, replace the FY_INJECT_GITHUB line in
~/.foldyard/<project>/host.env with the new key's base64, and check fy mode doesn't show the
switch DEGRADED (the check runs every 5 minutes). Then delete the old key on GitHub. A token
already made lasts at most an hour.
To stop access: fy mode github=off stops the proxy adding the token at once. To take it away for
good, uninstall the App or remove the repository from the installation on github.com.
Gotchas¶
- An installation can't have zero repositories. GitHub needs at least one selected repository; to cut every repository off, uninstall.
- Changing permissions needs acceptance. After you edit the App's permissions, an org owner accepts them on the installation before the token carries them.
- Managing the installation's repositories through the API (for example from infrastructure-as-code) may need a classic personal access token; fine-grained ones have been refused there with a 403. The install page on github.com always works.
- Only one credential per host and path at a time. Two rows that inject on
api.github.com(two Apps, or an App and thegh-clirow below) can't both be on.fy moderefuses the second and names the first; swap in one command, for examplefy mode github=off github-user=on.
Other ways in¶
kind = "gh-cli"injects your ownghtoken (gh auth tokenon your computer): full user authority, writes to anything you can write included. It must beemergency = true, so eachonexpires (fy mode github-user=on ttl=30m). Keep it for the rare task the App can't do, and switch it off after.kind = "static"echoes a long-lived token fromhost.env, such as a fine-grained personal access token, withhost = "api.github.com"andvalue_prefix = "Bearer ". It works, but the token lives long and acts as a person, which is what an App avoids.
Both are in configuration.md.