Skip to content

Latest commit

 

History

History
367 lines (283 loc) · 18.9 KB

codespaces.md

File metadata and controls

367 lines (283 loc) · 18.9 KB

Codespaces

Devcontainer Lifecycle

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's onCreateCommand + postCreateCommand from their own devcontainer.json inside 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.

Rebuild

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 codespace

Or via VS Code command palette: Ctrl+Shift+P → Codespaces: Rebuild Container

Note: Dev Containers: Rebuild Container works for local devcontainers, not Codespaces.

Persistence across rebuild

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.

Management

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

Secrets are set at user level and scoped to repos:

gh secret set GH_PAT --user --repos qte77/polyforge-orchestrator
gh secret list --user

Secrets 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.

Token format prefixes

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.

Token scopes

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.

Token precedence — what wins when multiple are set

When more than one credential source is present, both gh and git follow this precedence:

  1. GITHUB_TOKEN env var — wins outright if set
  2. GH_TOKEN env var — wins over hosts.yml
  3. 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.

Convention: GH_PAT as the named override

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.

Commit signing: what breaks gh-gpgsign (verified 2026-10-25 and 2026-10-29)

/.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
done

Dropping the GH_TOKEN mapping from containerEnv is not needed for signing as long as GITHUB_TOKEN stays set. Tracked in #64.

Escape hatch: explicitly drop env precedence

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-command

Or, to genuinely unset rather than set-empty (some tools check env-var presence, not just value):

env -u GH_TOKEN -u GITHUB_TOKEN some-command

This 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.

Contributing to external/untrusted repos

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 get GH_PAT at 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_PAT presumably 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 got 401 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 --repos allow-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:

  1. Fork the external repo to your own account.
  2. Don't add the fork to GH_PAT's Codespaces-secret --repos allow-list.
  3. 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's install.sh will run there too; its content is verified (2026-09-04, main branch) to never touch gh 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-injected ghu_ token (env-var GITHUB_TOKEN, scoped to that one repo) and an empty hosts.yml — confirm this with step 4 rather than assuming it.
  4. Verify before doing anything else: gh auth status should show only the GITHUB_TOKEN-sourced auth, ghu_* prefix, and no hosts.yml entry. If anything else appears, stop and investigate before touching git.
  5. Skim the fork's own .devcontainer/devcontainer.json and postCreateCommand/onCreateCommand before letting them run — it's still untrusted code with a real (if narrow) token available to it.
  6. 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 (needs pull_requests:write on 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.
  7. 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 --repos allow-list) is restricted to exactly that repo pair. Never reuse GH_PAT or the hosts.yml OAuth token for this.

Diagnostics

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>')"
done

Healthy 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.program and credential.helper must come from /etc/gitconfig (system-level, set by Codespaces). If they come from ~/.gitconfig or .git/config instead, your dotfiles are clobbering them — that's GitHub's documented cause #2 for gpg failed to sign.
  • user.signingkey should be <unset> — gh-gpgsign signs via the Codespaces identity, not a local GPG key.
  • commit.gpgsign can come from any scope; only its value matters.

Inherited git config defaults (and per-repo overrides)

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 repo

Setting 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"

Reset / rebuild scope

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.

Ports and Forwarding

gh codespace ports
gh codespace ports forward 8080:8080

References