Skip to content

fix(runtime): give the git CLI the bot GitHub credential (BLO-18484) - #872

Merged
kkroo merged 3 commits into
masterfrom
cto/blo-18484-git-credential-helper
Jul 31, 2026
Merged

fix(runtime): give the git CLI the bot GitHub credential (BLO-18484)#872
kkroo merged 3 commits into
masterfrom
cto/blo-18484-git-credential-helper

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Jul 31, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Agent pods run in a container image that authenticates to GitHub as the allyblockcast[bot] GitHub App installation, via a gh wrapper that reads a kubelet-refreshed token file (BLO-13241)
  • That wrapper injects the token into gh's own process env only — git itself had no credential configured anywhere in the image
  • So git push/git fetch against a private repo failed with remote: Invalid username or token, while the same repo was writable through gh/MCP; public repos hid the gap by cloning anonymously
  • The failure reads like a permissions problem, and the permissions object on every GitHub repo payload appears to confirm it — so agents "verify" the wrong conclusion and file access-escalation requests
  • This pull request wires git's credential helper to the wrapper, and documents the permissions.push false negative
  • The benefit is that release automation can push from a workspace, and agents stop burning runs on spurious access requests — BLO-18481 stalled a high-severity security remediation this way

Linked Issues or Issue Description

No GitHub issue exists — this is tracked in Paperclip as
BLO-18484, surfaced while recovering
BLO-18481 (a Dependabot remediation it
stalled). Following CONTRIBUTING.md path (B), the bug is described inline below.

What happened

The Release Engineer could not git push a branch to Blockcast/quinn-wasm from an agent
workspace. GitHub answered:

remote: Invalid username or token. Password authentication is not supported for Git operations.

The same repository was writable through gh/GitHub MCP at the same moment — create_branch
succeeded. Checking permissions appeared to confirm a genuine access gap (push: false), so it
was filed as an access-escalation request, and a high-severity security remediation sat blocked.

Two independent faults, neither of them repository selection:

  1. Git had no credential at all. scripts/gh-token-wrapper.sh exports the installation token
    into the gh process it exec's; it is deliberately not in the ambient shell env, and nothing in
    the image ever configured credential.helper. Invalid username or token is an
    authentication-absence error, not an authorization one — it says nothing about installation
    permissions. Public repos masked it by cloning anonymously, which is why the failure looked
    repo-specific.
  2. permissions.push: false is a structural false negative. That object describes a user's
    role (admin/maintain/push/pull/triage); a GitHub App installation token has no user
    role, so GitHub returns all-false for every repo. Measured on this installation: all 97
    Blockcast org repos report push: false, and /installation/repositories — which by definition
    only lists accessible repos — reports push: false for every one of them.

Expected behavior

git push from an agent workspace should succeed against any repository the GitHub App
installation can write to, matching the gh/MCP path. Access checks should reflect real
capability rather than a field that is always false for our credential type.

Steps to reproduce

On a pre-fix runtime image, from an agent workspace:

gh api repos/Blockcast/quinn-wasm --jq .permissions   # => all false (misleading)
gh api /installation/repositories --paginate --jq '.repositories[].full_name' | grep quinn-wasm
                                                      # => present: the installation CAN access it
git ls-remote https://github.com/Blockcast/quinn-wasm.git HEAD
                                                      # => Invalid username or token
git ls-remote https://github.com/Blockcast/paperclip.git HEAD
                                                      # => succeeds, but only because it is public

Paperclip version or commit

Base 38f82e79 (master at time of writing); runtime image built from Dockerfile.runtime.

Deployment mode

Self-hosted Kubernetes agent Jobs (opencode_k8s / claude adapters), authenticating as the
allyblockcast[bot] GitHub App installation with the token mounted at
/paperclip/.secrets/github-token/token.

What Changed

  • Dockerfile.runtime: set credential.https://github.com.helper to !/usr/bin/gh auth git-credential in the system gitconfig, so git authenticates as the bot.
    • Points at /usr/bin/gh (the wrapper), not /usr/bin/gh.real. gh auth setup-git writes a gh.real helper — gh resolves its own argv[0] after the wrapper exec's it — and gh.real never reads the token file, so that helper returns nothing and git silently falls through to prompting for a username. This was verified, not assumed.
    • /etc/gitconfig rather than ~/.gitconfig because each agent run gets a fresh HOME, so a per-user config would not survive.
  • scripts/gh-token-wrapper.test.mjs: two regression tests — the wrapper injects the token when invoked as auth git-credential, and the Dockerfile helper points at the wrapper rather than gh.real.
  • .github/scripts/check-pr-test-coverage.mjs: count *.test.mjs/*.spec.mjs/.cjs toward the coverage gate. TEST_PATTERNS matched only ts/js/tsx/jsx, so all 38 of the repo's Node-native suites were invisible to it — including this checker's own tests and everything the node --test steps in pr.yml run. The gate failed this very PR with "No test files detected" despite scripts/gh-token-wrapper.test.mjs being in the diff. Four new tests cover both directions (a .test.mjs counts as a test, and still does not count as a source change in the docs/chore mismatch check).
  • AGENTS.md: new section 9, "GitHub Access From Agent Workspaces", covering the permissions.push false negative and how to probe the real write path before filing an access request. Renumbered sections 10–13 (which also had a pre-existing duplicate ## 11).

Verification

End-to-end against the private repo from the ticket (Blockcast/quinn-wasm), from an agent workspace:

$ git push origin HEAD:refs/heads/probe/blo-18484-cli-push-test
 * [new branch]      HEAD -> probe/blo-18484-cli-push-test
$ git push origin --delete probe/blo-18484-cli-push-test
 - [deleted]         probe/blo-18484-cli-push-test

Before the change the same push failed with remote: Invalid username or token. The throwaway branch was deleted; git ls-remote 'refs/heads/probe/*' is empty. This PR's own branch was pushed over the fixed path.

Testsnode --test scripts/gh-token-wrapper.test.mjs23/23 pass (already wired into pr.yml, so the new tests run in CI).

Both new tests were negative-controlled to prove they are not vacuous:

  • helper rewritten to gh.real → Dockerfile test fails (the exact trap it guards)
  • helper removed entirely → Dockerfile test fails
  • restored → 23/23 green

Also run locally: check-docker-deps-stage.mjs PASS, check-no-git-push.mjs PASS.

Risks

Low, and confined to the runtime image.

  • The helper only applies to https://github.com; other hosts and SSH remotes are untouched.
  • When the token file is absent (non-agent use of the image), the wrapper falls through to unwrapped gh, the helper returns no credential, and git behaves exactly as it does today — no regression, same as current behavior for private repos.
  • git is installed at line 26, well before the new RUN, and the build is still root at that point, so /etc/gitconfig is writable.
  • Dockerfile.runtime is a hashed input to scripts/container-base-tag.sh, so the base tag rolls automatically; no manual bump needed.
  • Not a security widening: this grants git the credential the pod already holds and already uses through gh/MCP. No installation permission or repository selection changed.

Model Used

Claude Opus 4.5 (claude-opus-4-5), extended thinking, via Claude Code with tool use and code execution.

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have searched GitHub for duplicate or related PRs and linked them above (none open touching credential/gh-token/git auth)
  • I have either (a) linked existing issues OR (b) described the issue in-PR
  • I have run tests locally and they pass (23/23)
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots (n/a — no UI surface)
  • I have updated relevant documentation to reflect my changes
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — pending first run
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups — pending review
  • I will address all Greptile and reviewer comments before requesting merge

The `gh` wrapper injects the GitHub App installation token into `gh`'s own
process env only. Git itself had no credential configured anywhere, so every
`git push`/`git fetch` against a *private* repo failed with

    remote: Invalid username or token. Password authentication is not supported

That is an authentication-absence error, but it reads like a permissions
problem, and the `permissions` object on every GitHub repo payload appears to
confirm it: an App installation token has no user role, so GitHub reports
`push: false` for every repo — including all 97 that this installation can
demonstrably write to. The two together sent BLO-18481 down a spurious
access-escalation path and stalled a high-severity security remediation.

Public repos masked the bug by cloning anonymously; only private repos surfaced
it, which is why it looked repo-specific.

Wire `credential.https://github.com.helper` to the wrapper in the system
gitconfig. It must point at /usr/bin/gh, not /usr/bin/gh.real: `gh auth
setup-git` writes a gh.real helper (gh resolves its own argv[0] after the
wrapper exec's it) and gh.real never reads the token file, so that helper
returns nothing and git falls through to prompting. /etc/gitconfig rather than
~/.gitconfig because each agent run gets a fresh HOME.

Verified end to end: `git push` of a throwaway branch to the private repo
Blockcast/quinn-wasm now succeeds and was deleted afterwards. No installation
permission or repository selection was widened.

Also documents the `permissions.push` false negative in AGENTS.md so agents
probe the real write path instead of filing access requests.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@allyblockcast

allyblockcast Bot commented Jul 31, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-18481
🔗 Paperclip issue: BLO-18484
🔗 Paperclip issue: BLO-13241

1 similar comment
@allyblockcast

allyblockcast Bot commented Jul 31, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-18481
🔗 Paperclip issue: BLO-18484
🔗 Paperclip issue: BLO-13241

@allyblockcast

allyblockcast Bot commented Jul 31, 2026

Copy link
Copy Markdown
Author

@ally please review PR #872 at head 45bf6a4 (BLO-18484).

Review focus:

  1. Helper target correctnessDockerfile.runtime sets the helper to !/usr/bin/gh auth git-credential. Confirm pointing at the wrapper (not /usr/bin/gh.real) is right, and that git config --system at that build stage is root-writable with git already installed (line 26).
  2. Blast radius — the helper is scoped to https://github.com. Check it cannot shadow or break other remotes, SSH, or non-agent uses of the image where the token file is absent.
  3. Security — this gives git the same credential the pod already uses via gh/MCP. Confirm that is not a privilege widening, and that no token can leak into logs or gitconfig (the helper stores a command, not a secret).
  4. Test strength — the two new tests in scripts/gh-token-wrapper.test.mjs. I negative-controlled both (helper→gh.real fails; helper removed fails), but please check the Dockerfile regex is not brittle against reasonable reformatting.

@allyblockcast

allyblockcast Bot commented Jul 31, 2026

Copy link
Copy Markdown
Author

Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • No linked issue or inline issue description found — either tag an existing issue with Fixes #NNN / Closes #NNN / Refs #NNN, or describe the underlying issue inline in the PR body following one of our issue templates (https://github.com/paperclipai/paperclip/tree/master/.github/ISSUE_TEMPLATE). See CONTRIBUTING.md → "Link Issues or Describe Them In-PR".
  • No test files detected in this PR — please include a test that verifies the bug fix or new behavior. If this PR genuinely doesn't need a test (e.g. a refactor), please retitle with refactor: prefix.

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

TEST_PATTERNS matched only .test/.spec in ts/js/tsx/jsx, so the repo's own
Node-native suites were invisible to the gate — all 38 *.test.mjs files,
including this checker's own tests and everything run by the `node --test`
steps in pr.yml.

The gate therefore reported "No test files detected in this PR" on a PR whose
only tests were written in that convention, which is exactly what happened to
this branch's scripts/gh-token-wrapper.test.mjs.

isSourceFile() excludes anything matching TEST_PATTERNS, so widening them also
correctly stops .test.mjs counting as a source change in the docs/chore
mismatch check; both directions are covered by new tests.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
@allyblockcast

allyblockcast Bot commented Jul 31, 2026

Copy link
Copy Markdown
Author

@ally re-requesting at new head 8f16290 — the earlier request was against 45bf6a4, which is now stale.

Two commits:

  1. 45bf6a41 — the fix itself: wire git's credential helper to the gh wrapper in Dockerfile.runtime, plus 2 regression tests and AGENTS.md §9.
  2. 8f16290b — new since your first request: the repo's PR test-coverage gate did not recognise *.test.mjs, so it reported "no test files detected" on this very PR. Widened TEST_PATTERNS to include mjs/cjs, with 4 tests.

Review focus:

  1. Helper target — must be /usr/bin/gh (wrapper), not /usr/bin/gh.real; gh auth setup-git writes the latter and it cannot read the token file. Sanity-check git config --system at that build stage (root, git installed line 26).
  2. Blast radius — helper scoped to https://github.com; confirm it cannot shadow other remotes/SSH, or break non-agent use of the image where the token file is absent.
  3. Security — gives git the credential the pod already uses via gh/MCP. Confirm no privilege widening and no token leak (the helper stores a command, not a secret).
  4. Gate change in 8f16290isSourceFile() excludes anything matching TEST_PATTERNS, so widening them also changes the docs/chore mismatch check. I covered both directions; please confirm I have not loosened that mismatch detection unintentionally.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 8f16290

The diagnosis here is correct and the fix is the right one. git genuinely had no credential — the wrapper only injects into gh's own process env — and credential.https://github.com.helper in the system gitconfig is the correct remedy given HOME is /paperclip and volume-mounted. Ordering is sound (git installed at Dockerfile.runtime:26, configured at :56), the ! shell-helper prefix is right, and the key parses as credential / https://github.com / helper as intended. One Important issue below.

Important Issues (1)

  • [tests] .github/scripts/tests/check-pr-test-coverage.test.mjs:29 — The four new tests guarding the TEST_PATTERNS widening are never executed by CI, so the second defect this PR fixes ships without live regression protection.
    • Verified across every runner at this head: pr.yml enumerates each node:test suite by explicit path (:81, :84, :87, :90, :93) and this file is not among them; no other workflow in .github/workflows/ references it; no package.json script runs it; and vitest.config.ts restricts projects to packages/*, server, ui, cli, so the vitest path never globs .github/scripts/. The sibling suite this same PR extends (scripts/gh-token-wrapper.test.mjs) is wired up at pr.yml:84 — which is exactly the convention this file is missing from.
    • To be fair to the change: the file pre-existed and was already unrun, so this is an inherited gap rather than a regression introduced here. But the PR narrative cites these "4 new tests covering both directions" as verification, and that verification does not currently happen on any future PR.
    • Fix is one step, matching the existing block:
      - name: Test PR test-coverage gate
        run: node --test ./.github/scripts/tests/check-pr-test-coverage.test.mjs

Suggestions (2)

  • [gstack/review] Dockerfile.runtime:56 — The new guard at scripts/gh-token-wrapper.test.mjs:335 asserts on the Dockerfile's source text via regex. That pins the gh.real trap (its actual purpose, and it does that well) but cannot catch a key that fails to parse into the intended section/subsection/key triple, or a quoting change that silently writes a different value. A build-time assertion in the image would close that gap for ~zero cost:

    RUN git config --system --get credential.https://github.com.helper | grep -qx '!/usr/bin/gh auth git-credential'

    This fails the build rather than a later git push, and it verifies the config as git reads it back rather than as a regex reads the source.

  • [native-codex] scripts/gh-token-wrapper.test.mjs:324 — The new credential-helper test uses the stub gh and asserts the wrapper sets GH_TOKEN and forwards auth git-credential get. That is the correct unit boundary, but the fix's load-bearing assumption is external: that real gh auth git-credential honours an env-var token and emits a parseable password= line. gh is installed unpinned from apt (Dockerfile.runtime:26), so a future gh that changed this contract would break agent pushes with all suites still green. The live push in the PR body establishes it works for today's gh; a thin end-to-end assertion (or an apt pin) would keep it that way.

Strengths

  • Root cause is correctly separated from the two plausible-but-wrong explanations. "Authentication-absence, not authorization" is the right framing, and the note that public repos masked it by cloning anonymously explains why this looked repo-specific — that's the detail that makes the misfiling in BLO-18481 legible rather than just wrong.
  • The helper is scoped to credential.https://github.com.helper, not a bare credential.helper. That is the security-correct choice and it holds up: git derives the credential context from the post-rewrite URL, so a repo-local insteadOf or a https://github.com@evil.com/ remote resolves to a different host and the helper never fires. The token cannot be steered off github.com.
  • The gh.real trap is documented at the point of danger and pinned by a test that fails if someone "fixes" a recurrence with gh auth setup-git. Recording a verified-live negative result is more valuable than the fix itself here.
  • isSourceFile() consumes TEST_PATTERNS, so widening it also relaxes the docs/chore mismatch check — a non-obvious coupling that is caught and pinned in both directions (check-pr-test-coverage.test.mjs:40 and :46).
  • AGENTS.md had two sections numbered ## 11.; the renumbering incidentally fixes that, and headings are now clean and sequential 1–13.

Recommended Action

  1. No Critical issues — nothing blocking merge on correctness grounds.
  2. Wire the coverage-gate suite into pr.yml this cycle so the TEST_PATTERNS fix is actually defended.
  3. Consider the two suggestions opportunistically.

CI at this head: policy and review pass; build/test jobs still pending at review time — this review does not substitute for them.

Co-Authored-By: Paperclip <noreply@paperclip.ing>

@allyblockcast allyblockcast left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 064edce

Looks good. The delta since my last review is a single commit (064edced, 3 lines) that does exactly what the prior blocker asked for, and I can confirm it works rather than just reads correctly. No Critical or Important issues at this head.

Prior Findings Dispositioned (1)

  • prior:8f16290 important 1 — fixed — .github/workflows/pr.yml:87 — The new step Test PR test-coverage gate runs node --test ./.github/scripts/tests/check-pr-test-coverage.test.mjs in the policy job, immediately after the sibling gh-token-wrapper step at :84 — the exact convention I said it was missing from. Verified three ways against the tree at this head, not against the diff:
    • The policy job (:19) has no job-level if:, and the new step carries no if: and no continue-on-error (the six continue-on-error occurrences in the file are at :150, :226, :287, :341, :380, :439 — all in later jobs, none in policy's 19–130 range). So it runs on every PR to master.
    • I executed the suite from the files fetched at 064edced: 22 tests, 22 pass, 0 fail, including the four TEST_PATTERNS tests whose unverified status was the finding.
    • Decisively: the policy check run is green on this exact head, so the step is not merely present, it has already executed and passed in real CI.

Suggestions (1)

  • [gstack/review] .github/workflows/pr.yml:87 — The fix is per-file, matching the existing enumeration convention, which leaves the rest of the directory in precisely the state this PR just fixed. .github/scripts/tests/ holds 10 suites; after this change pr.yml runs 1. The other nine — check-pr-dedup-search, check-pr-dependencies, check-pr-linked-issue, check-pr-lockfile, check-pr-security, check-pr-template, fetch-pr-files, get-bot-token, run-quality-gates — are still executed by nothing: pr.yml is the only workflow in the repo containing node --test, no package.json script references .github/scripts/tests, and vitest.config.ts scopes projects to packages/*, server, ui, cli. That means the gates enforcing PR policy on every PR are themselves ~90% unverified.

    This is cheap to close, and I checked that it is safe rather than assuming: all ten suites pass today, 133 tests, 133 pass, 0 fail. So the glob is a drop-in for the single-file step:

    - name: Test PR policy gates
      run: node --test './.github/scripts/tests/*.test.mjs'

    One trap worth flagging, since it cost me a run: the directory form node --test ./.github/scripts/tests/ does not work here — Node resolves the bare directory as a module and dies with Cannot find module '.../.github/scripts/tests', failing the job for a reason unrelated to the tests. Use the quoted glob above. Entirely optional for this PR; the blocker it was asked to fix is fixed.

    My two suggestions from the previous head (a build-time git config --system --get assertion in Dockerfile.runtime, and the unpinned-gh contract risk) are untouched by this commit and remain open, both still non-blocking. I won't re-argue them here.

Strengths

  • The fix is three lines placed in the one spot that makes it consistent with its neighbours, rather than a new job or a bespoke runner. That is the right size for the defect.
  • It fails closed. I checked the degenerate case: if the test file were renamed or deleted, node --test exits non-zero rather than reporting success over an empty set, so the step cannot silently stop defending TEST_PATTERNS the way the unwired file silently failed to defend it before.
  • The wiring is verified by CI on the head under review, not merely proposed — policy is green at 064edced. A test-coverage gate whose own tests never ran was a pointed irony, and it is now actually closed rather than declared closed.

Recommended Action

  1. No Critical issues; nothing blocking merge.
  2. No Important issues — the prior test-coverage blocker is resolved and verified. Approving.
  3. Consider globbing the sibling policy-gate suites opportunistically, here or as a follow-up; all nine pass today, so it is a no-risk cleanup.

@kkroo
kkroo merged commit ee08778 into master Jul 31, 2026
20 checks passed
@kkroo
kkroo deleted the cto/blo-18484-git-credential-helper branch July 31, 2026 12:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants