fix(gh): distinguish an unresolvable GH_TOKEN from a mismatched one - #305
Merged
twistedmelonman merged 1 commit intoSep 3, 2026
Merged
Conversation
The guard added in #302 resolves the token's identity with `gh api user` and treated an empty result as the failure signal. But `gh api` writes its error body to STDOUT, so `2>/dev/null` does not suppress it: a rejected token makes the command substitution capture `{"message": "Bad credentials", ...}`. That value is non-empty, so the "could not be resolved" branch was unreachable, and an expired token was reported as an identity MISMATCH with the JSON error body printed where a login name belongs: [gh] ERROR: GH_TOKEN authenticates as '{ "message": "Bad credentials", ... }' but repo owner 'smartwatermelon' requires 'smartwatermelon' It still failed closed, so this was never a security hole — but it named the wrong cause and sent the reader to "unset GH_TOKEN" when the actual fix is to rotate the token. Decide on the exit status rather than on output emptiness, and drop any value that is not shaped like a GitHub login before it reaches the comparison. The resolution-failure message now names token expiry as the likely cause and gives the command to check it. Found when a real token expired mid-session. Adds a regression test with a stub gh whose output shape was verified against the real binary; all three of its new assertions were observed failing against the previous code. Claude-Session: https://claude.ai/code/session_01RBvPRMFfep4ktSDfGe9uHq
This comment has been minimized.
This comment has been minimized.
|
The PR fixes a real bug: `gh api` writes its error body to STDOUT, so a failed command substitution still produced a non-empty `token_login`, causing an expired token to be reported as an identity mismatch rather than a resolution failure. The fix correctly gates assignment on exit status and adds a format-guard regex. The test case exercises the exact failure path with a hermetic stub. No correctness, reliability, security, or data-loss issues in scope.
VERDICT: PASS |
twistedmelonman
deleted the
claude/fix-gh-token-resolution-error-1788449085
branch
September 3, 2026 15:51
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #302, found the hard way: a real token expired mid-session and the guard from #302 blamed the wrong thing.
The bug
#302 resolves the token's identity with
gh api user --jq .loginand treats an empty result as the failure signal. Butgh apiwrites its error body to stdout, not stderr — so2>/dev/nulldoesn't suppress it, and the command substitution captures the JSON:Non-empty. So the
could not be resolvedbranch was unreachable, and an expired token fell through to the mismatch comparison:Note the tell: it claims a mismatch while printing the same name on both sides.
Not a security hole — it still failed closed, which is why this is a follow-up and not a revert. But it names the wrong cause, and the suggested fix ("unset GH_TOKEN") is wrong for the situation that actually triggers it. The right fix is to rotate the token. This matters on a fixed schedule: the current PAT expires 2026-12-02, and this is exactly what will greet whoever hits it first.
The fix
Decide on exit status, not output emptiness.
if api_out="$(...)"keeps the value only when the call actually succeeded.Reject anything not shaped like a login. A GitHub username is
^[A-Za-z0-9-]+$. Anything multi-line or punctuated that reaches the comparison is an error body arriving by some other path, and is discarded before it can be compared againstdesired. Belt and braces — the exit-status check is the real fix, this is the backstop for the class.Say what is probably wrong. The resolution-failure message now names token expiry as the likely cause and gives the command to check it:
Testing
Case 4 uses a stub
ghonPATHwhose output shape was verified against the real binary (gh 2.99.0) rather than assumed — JSON body on stdout, human-readable line on stderr, exit 1. That keeps the case hermetic; no network, no real credential.All three new assertions were observed failing against the pre-fix code:
The third assertion is worth keeping on its own: it pins that the raw API error body never reaches the user-facing message, independent of which branch produces it.
Full suite
24 passed, 0 failed.shellcheck -S infoclean, no disable directives. Live check: the valid-token path is unaffected —gh repo viewstill succeeds on-org.Note on the test fixture
The first version of case 4 used
ghp_expired000...as the fake token and the pre-commit Semgrep secrets scan blocked it — aghp_-prefixed string reads as a hard-coded PAT no matter what follows. Changed toexpired-token-fixture; the guard only cares thatGH_TOKENis non-empty, so the shape was never load-bearing. Flagging it because the next person writing a token fixture here will hit the same wall.How this got through #302
Its Step 9 exercised the valid-token path only, and no reviewer ran the code against a bad credential — the failure mode lives in
gh's stdout/stderr split, which is invisible when you reason about the source instead of executing it. Same lesson as the two false-OK findings recorded in that PR's plan: a check that never sees a known-bad case proves nothing.Follow-up to #302. Does not touch #303 (identity-lookup caching), which remains open.
https://claude.ai/code/session_01RBvPRMFfep4ktSDfGe9uHq