polyforge uses two lifecycle hooks in devcontainer.json:
onCreateCommand(make setup_all) — installs shared tooling, clones repos, generates workspace file. Runs once on container creation.postAttachCommand(make setup_repos) — runs each repo'sonCreateCommand+postCreateCommandfrom their owndevcontainer.jsoninside the host container. This is needed because multi-root workspaces only execute the host container's devcontainer lifecycle, not the workspace folders'.
Terminal tasks (runOn: folderOpen) in workspace.code-workspace
auto-open a shell per repo. Controlled by the
task.allowAutomaticTasks: "on" setting.
After changing devcontainer.json, rebuild to apply:
gh codespace rebuild # rebuild current
gh codespace rebuild --full # clean rebuild (no cache)
gh codespace rebuild -c <name> # rebuild specific codespaceOr via VS Code command palette:
Ctrl+Shift+P → Codespaces: Rebuild Container
Note: Dev Containers: Rebuild Container works for
local devcontainers, not Codespaces.
Per GitHub's Codespaces docs: rebuilding a container clears everything outside /workspaces; everything inside it survives. That boundary is absolute — it includes $HOME (/home/vscode), so anything living there needs an explicit plan.
~/.claude is symlinked to /workspaces/.claude-files (this predates this repo — set up by qte77/dotfiles's Codespaces-aware install.sh, which is account-level dotfiles config, not anything in this repo's devcontainer.json). The symlink itself is a file under $HOME, so it's wiped on rebuild same as everything else there — only the target (/workspaces/.claude-files) survives.
Per qte77/dotfiles's own README, dotfiles installation "only applies to new codespaces, not existing ones" — so install.sh does not re-run on a rebuild of an existing codespace, and can't re-create the symlink there. setup_claude_home (run first in setup_all, via vendored scripts/link-claude-home.sh) is what actually re-links it on every onCreateCommand of an existing codespace's rebuild — not a defense-in-depth backstop, the load-bearing mechanism for that case. Dotfiles' install.sh remains the mechanism for a brand-new codespace's first creation.
Not covered: ~/.claude.json (a sibling file next to ~/.claude/, holding per-project trust approvals and marketplace/onboarding state) has no persistence mechanism here. Symlinking it like a directory is a known-risky pattern — Claude Code's config writes aren't verified atomic-rename-safe on every path, which silently breaks a file symlink (unlike a directory symlink, which isn't affected). Tracked at qte77/claude-code-plugins#199.
Manage any Codespace from within polyforge-orchestrator using -c:
gh codespace list
gh codespace stop -c <name>
gh codespace start -c <name> # (re-)start a stopped codespace
gh codespace ssh -c <name>
gh codespace delete -c <name>
gh codespace logs -c <name>Secrets are set at user level and scoped to repos:
gh secret set GH_PAT --user --repos qte77/polyforge-orchestrator
gh secret list --userSecrets are injected as env vars. Map them in
devcontainer.json via containerEnv:
"containerEnv": {
"GH_PAT": "${localEnv:GH_PAT}",
"GH_TOKEN": "${localEnv:GH_PAT}"
}See docs/cross-repo-setup.md for auth details.
Per GitHub's auth-token reference, each token kind has a distinct prefix:
| Prefix | Meaning |
|---|---|
ghp_ |
Personal access token (classic) |
github_pat_ |
Fine-grained personal access token |
gho_ |
OAuth access token |
ghu_ |
User access token for a GitHub App |
ghs_ |
Installation access token for a GitHub App |
ghr_ |
Refresh token for a GitHub App |
Codespaces auto-injects a ghu_* (user access token for the Codespaces GitHub App, scoped to the codespace's repo) as GITHUB_TOKEN. This is not documented explicitly by GitHub but is empirically observable. The token rotates periodically; per the security docs: "Every time a codespace is created or restarted, it's assigned a new GitHub token with an automatic expiry period."
GH_PAT (Codespaces user secret) is github_pat_* (fine-grained PAT). The two token kinds are not interchangeable — see the caveat below.
The Codespaces-injected GITHUB_TOKEN and fine-grained
PATs (GH_PAT) have different scope coverage:
| Operation | GITHUB_TOKEN |
GH_PAT (fine-grained) |
|---|---|---|
gh codespace list/rebuild/stop |
Yes | Needs codespace scope |
gh pr create |
No | Needs pull_requests:write |
git push |
Scoped to current repo | Needs contents:write |
git push (protected branch) |
No | Needs administration:write |
Set GH_PAT scopes to cover gh and git operations.
Codespace management (rebuild, stop, etc.) uses the
default GITHUB_TOKEN unless GH_PAT includes codespace.
When more than one credential source is present, both gh and git
follow this precedence:
GITHUB_TOKENenv var — wins outright if setGH_TOKENenv var — wins over hosts.yml- hosts.yml OAuth token — used only when neither env var is set
The trap: GITHUB_TOKEN or GH_TOKEN set in your environment
silently shadows the hosts.yml OAuth token. If the env-var token has
narrower scope than the OAuth one, cross-repo writes can fail with 403
even though gh auth status looks healthy.
Standardize on a single named env var, GH_PAT, as the intentional
override. containerEnv in .devcontainer/devcontainer.json already
forwards it and aliases GH_TOKEN=${localEnv:GH_PAT}:
"containerEnv": {
"GH_PAT": "${localEnv:GH_PAT}",
"GH_TOKEN": "${localEnv:GH_PAT}"
}When you want write access through a fine-grained PAT, set GH_PAT in
your local environment. Everything else flows from there. No need to set
GITHUB_TOKEN or GH_TOKEN directly.
/.codespaces/bin/gh-gpgsign signs through the Codespaces identity service using the auto-injected
ghu_* token in GITHUB_TOKEN. Three causes of failed or unsigned commits were confirmed in this
codespace; each was fixed by removing the cause, not by turning signing off.
| Cause | Symptom |
|---|---|
GITHUB_TOKEN unset for the commit (e.g. env -u GH_TOKEN -u GITHUB_TOKEN git commit, or a whole agent process started under env -u GITHUB_TOKEN) |
gpg: skipped "GitHub <[email protected]>": No secret key |
A user.name in ~/.gitconfig that doesn't match the GitHub profile name, or a commit authored as someone else (e.g. git am of a patch written by another identity) |
the signer answers 403 Author is invalid |
A repo-local commit.gpgsign false left behind by an old workaround |
the commit succeeds but is unsigned |
A/B test of the token cause (2026-10-29, same shell, the probe below reported GH_TOKEN: github_pat_
and GITHUB_TOKEN: ghu_):
| Shell | Result |
|---|---|
| both tokens set | signed |
GH_TOKEN unset, GITHUB_TOKEN set |
signed |
GITHUB_TOKEN unset, GH_TOKEN (the PAT) set |
"No secret key" |
So the 2026-05 hypothesis holds in a narrower form: the PAT in GH_TOKEN does no harm while
GITHUB_TOKEN is set, because GITHUB_TOKEN wins (the precedence above); signing fails only once
GITHUB_TOKEN is gone and the PAT is all that is left. Why the service refuses the PAT was not tested.
Rule: prefix gh calls and git network calls (fetch, push, ls-remote) with
env -u GH_TOKEN -u GITHUB_TOKEN; never strip GITHUB_TOKEN from git commit or from a whole agent
process. Don't fix signing with --no-gpg-sign, -c commit.gpgsign=false or a local
commit.gpgsign false.
Token-type probe (prints only the type, never the value):
for v in GH_PAT GH_TOKEN GITHUB_TOKEN; do
case "${!v}" in
"") echo "$v: <unset>" ;;
ghu_*) echo "$v: ghu_ (auto-injected, short-lived)" ;;
github_pat_*) echo "$v: github_pat_ (user fine-grained PAT)" ;;
*) echo "$v: other" ;;
esac
doneDropping the GH_TOKEN mapping from containerEnv is not needed for signing as long as
GITHUB_TOKEN stays set. Tracked in #64.
When env-var precedence must be dropped (e.g. third-party install scripts that fight your token), prefix the command with explicit clearing:
GITHUB_TOKEN= GH_TOKEN= some-commandOr, to genuinely unset rather than set-empty (some tools check env-var presence, not just value):
env -u GH_TOKEN -u GITHUB_TOKEN some-commandThis is already the canonical pattern in Makefile's setup_rtk
target — see lines around the curl … rtk install.sh invocation. Use
it as the example when documenting any new automation that must run
under a different token (or no token at all).
Exception — never for git commit. The signer needs GITHUB_TOKEN; a commit run under
env -u GITHUB_TOKEN fails with "No secret key" (see "Commit signing" above).
Caveat — this is not a privilege-reduction mechanism. Dropping
GH_TOKEN/GITHUB_TOKEN only changes which already-present
credential gh/git pick up next; it does not remove access.
Verified 2026-09-04: this codespace's ~/.config/gh/hosts.yml holds a
stored classic OAuth token (gho_*, scopes gist, read:org, repo, workflow) used deliberately for cross-org pushes, and — separately —
gh auth status reported the GH_TOKEN-mapped GH_PAT env var as
invalid at the time of checking, meaning this gho_ token may
already be doing all the actual pushing here, not just serving as a
fallback. Classic tokens can't be repo-scoped, so it reaches every
repo the account can touch — as broad as, or broader than, GH_PAT.
Unsetting the env vars falls through to that token, not to nothing.
It's also not a container
isolation boundary: the credential still sits on disk regardless of
what's unset for one command, so any code executing in this same
container (e.g. a postCreateCommand from untrusted repo content
pulled into this workspace) can read it directly. Use this escape
hatch only to dodge third-party tooling conflicts, never to get a
lower-privilege credential — for that, see "Contributing to
external/untrusted repos" below.
Two independent things scope a GitHub credential, and only one is narrow by default here:
- Which codespaces receive a secret —
gh secret set GH_PAT --user --repos <list>controls injection: which codespaces getGH_PATat all. - What the token can reach once injected — set when the
fine-grained PAT itself was created (github.com/settings/personal-access-tokens),
independent of the line above.
GH_PATpresumably needs write access spanning every repo this orchestrator manages (config/repos.conf) for its own job to work, but its actual repository-access list is not verifiable from inside a codespace (an API read attempt got401 Bad credentials) — check it directly at github.com/settings/personal-access-tokens. Either way, "created on a repo we own" was never a scope limiter — the--reposallow-list only ever gated injection, not reach.
For work on a repo you don't own or fully trust, don't reuse this codespace's credentials at all (see the caveat above — env-var unsetting doesn't isolate you from them). Use a separate codespace instead:
- Fork the external repo to your own account.
- Don't add the fork to
GH_PAT's Codespaces-secret--reposallow-list. - Create a fresh codespace on the fork. Don't manually run
gh auth login,source ~/.gh_pat, or any of this repo's own credential-setup patterns there. If account-level dotfiles auto-install is enabled (github.com/settings/codespaces — not checked here)qte77/dotfiles'sinstall.shwill run there too; its content is verified (2026-09-04,mainbranch) to never touchgh auth— it only symlinks editor/config files — so it isn't a source of broad credentials either way. A fresh codespace should therefore start with nothing but GitHub's own auto-injectedghu_token (env-varGITHUB_TOKEN, scoped to that one repo) and an emptyhosts.yml— confirm this with step 4 rather than assuming it. - Verify before doing anything else:
gh auth statusshould show only theGITHUB_TOKEN-sourced auth,ghu_*prefix, and nohosts.ymlentry. If anything else appears, stop and investigate before touching git. - Skim the fork's own
.devcontainer/devcontainer.jsonandpostCreateCommand/onCreateCommandbefore letting them run — it's still untrusted code with a real (if narrow) token available to it. - Push to the fork with the default token — that works. Open the PR
against upstream via the browser, not
gh pr create: the default token can't create PRs cross-repo (needspull_requests:writeon upstream, which a fork-scoped token doesn't have — see the Token scopes table above). This also means the whole flow needs zero extra tokens minted. - Only if upstream write access is genuinely required, mint a
purpose-built fine-grained PAT whose own repository-access list
(set at PAT creation, independent of any secret's
--reposallow-list) is restricted to exactly that repo pair. Never reuseGH_PATor thehosts.ymlOAuth token for this.
When something auth- or signing-related goes sideways, run the audit one-liner first to capture the real state of git config across all four scopes (system / global / local / worktree). The labelled output makes dotfiles overrides obvious:
for k in commit.gpgsign gpg.program gpg.format user.signingkey credential.helper user.name user.email; do
printf '%-22s %s\n' "$k" "$(git config --show-origin --get "$k" 2>/dev/null || echo '<unset>')"
doneHealthy Codespaces output looks like:
commit.gpgsign file:/home/vscode/.gitconfig true
gpg.program file:/etc/gitconfig /.codespaces/bin/gh-gpgsign
gpg.format file:/home/vscode/.gitconfig openpgp
user.signingkey <unset>
credential.helper file:/etc/gitconfig /.codespaces/bin/gitcredential_github.sh
user.name file:/etc/gitconfig qte77
user.email file:/etc/gitconfig [email protected]
What to look for:
gpg.programandcredential.helpermust come from/etc/gitconfig(system-level, set by Codespaces). If they come from~/.gitconfigor.git/configinstead, your dotfiles are clobbering them — that's GitHub's documented cause #2 forgpg failed to sign.user.signingkeyshould be<unset>—gh-gpgsignsigns via the Codespaces identity, not a local GPG key.commit.gpgsigncan come from any scope; only its value matters.
Codespaces also bakes some non-auth git config defaults into ~/.gitconfig that you may want to override per-repo.
commit.template=/home/vscode/.gitmessage is set globally so every repo gets a default commit-message scaffold. Repos that ship their own .gitmessage at the root don't get it used automatically until they opt in:
git config --local commit.template .gitmessage # use this repo's template
git config --local commit.template "" # disable template for this repoSetting local to empty string is required to actually disable; plain --unset only removes the local key, which causes the global value to resurface.
A .devcontainer/devcontainer.json postCreateCommand is the natural place for repos that want this automatic on rebuild:
"postCreateCommand": "[ -f .gitmessage ] && git config --local commit.template .gitmessage || true"| Goal | Operation | Effect |
|---|---|---|
Clear GH_TOKEN in current shell only |
unset GH_TOKEN |
One-shell scope; subsequent commands in this shell only |
| Pick up newly added Codespaces user secrets | Stop + start codespace | Per secrets docs — restart re-injects current secret values |
Regenerate auto-injected GITHUB_TOKEN |
Stop + start codespace | Per security docs: "a new GitHub token … each time a codespace is created or restarted" |
Apply changes to .devcontainer/devcontainer.json |
Rebuild container (gh codespace rebuild or Codespaces: Rebuild Container) |
Stop+start alone is not enough; rebuild re-runs containerEnv, onCreateCommand, postCreateCommand |
Stop+start is cheap and rotates the auto-injected token. Rebuild is heavier and required for any devcontainer change.
gh codespace ports
gh codespace ports forward 8080:8080- Codespaces docs — overview
- Security in Codespaces — auto-injected token lifecycle
- Managing GPG verification — enabling and trusted-repo list
- Troubleshooting GPG verification — three documented
gpg failed to signcauses - Org/repo Codespaces secrets — libsodium sealed-box encryption
- Token format prefixes —
ghu_/ghp_/github_pat_/ etc. gh codespaceCLI- Introduction to dev containers —
/workspacesvs. everything-else rebuild persistence boundary