Repository navigation
Conversation
When the refresh lock file cannot be created or opened because the filesystem denies the write (permission denied or read-only), as in an agent sandbox, gh can most likely not persist a rotated credential either. Skip the token exchange in that case, keep presenting the current access token, and report the new RefreshStatusSkipped status. Other lock failures keep the existing best-effort refresh. Refs #14844 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ac178b5a-5b62-49bd-9e83-4796c7ddec46
AuthConfig now collects deduplicated warnings, exposed through Warnings(), and records one when a token refresh is skipped because gh's state directory is not writable. Main prints them to stderr before exiting, even when the command succeeds, so the user or a calling agent can fix the environment before the token stops working. Refs #14844 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ac178b5a-5b62-49bd-9e83-4796c7ddec46
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
Skipped-refresh errors bypass scope discovery in gh auth refresh, potentially dropping existing extra scopes despite a still-valid access token.
1 open issue
What changed in this PR
This PR addresses #14844 by skipping automatic token refresh when filesystem permissions prevent opening the refresh lock, preserving the stored refresh token for use outside a sandbox.
Changes:
- Distinguishes permission and read-only filesystem errors from other lock failures.
- Adds a skipped-refresh status and deduplicated authentication warnings printed to stderr before exit.
- Adds tests for lock-error handling and warning formatting.
| File | Description |
|---|---|
| pkg/auth/credential.go | Defines the skipped-refresh status. |
| internal/ghcmd/cmd.go | Prints collected authentication warnings before exit. |
| internal/ghcmd/cmd_test.go | Tests warning formatting. |
| internal/gh/gh.go | Exposes the status alias and warnings interface. |
| internal/config/refreshable.go | Skips refresh when lock writes are denied. |
| internal/config/refreshable_test.go | Tests skipped refreshes and other lock failures. |
| internal/config/config.go | Stores deduplicated warnings and updates lock callbacks. |
Review effort: Balanced
When the token refresh is skipped because gh's state directory is not writable, ActiveTokenWithRefresh returns the current credential along with an error. The scope carry-forward only read scopes on success, so it dropped the existing extra scopes even though the current token may still be valid within the refresh window. Also read the scopes when the refresh status is RefreshStatusSkipped. Refs #14844 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ac178b5a-5b62-49bd-9e83-4796c7ddec46
This branch has not been deployed
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.

Fixes #14844
Description
When a user signs in with an expiring token, gh stores a short-lived access token and a refresh token. Shortly before the access token expires, gh exchanges the refresh token for a new pair and saves it. The server rotates the refresh token on every exchange, so the old one stops working right away. To keep concurrent gh processes from spending the same refresh token, the exchange runs under a file lock,
refresh.lock, in gh's state directory ($XDG_STATE_HOME/gh, usually~/.local/state/gh).Agent sandboxes often make everything outside the workspace read-only, including gh's state and config directories. In that case gh fails to create the lock, carries on without it, and refreshes anyway. The server rotates the refresh token, but gh cannot save the new credential. That one command works, but the stored refresh token is now dead. The next run outside the sandbox gets rejected, gh removes the credential, and the user has to sign in again. Several sandboxed processes starting at once can also each spend the token.
This PR makes gh skip the refresh when it cannot create or open the lock file because the filesystem denies the write. A failure like that means the refreshed credential most likely could not be saved either. gh keeps presenting the current access token and leaves the stored refresh token untouched, so a later run outside the sandbox refreshes it normally. gh also records a warning, which is printed to stderr just before gh exits:
How did you test this change?
On Ubuntu 24.04, I pointed gh's state directory at a read-only squashfs snap mount (
/snap/core22/2411) and at a0555temp directory, then called the lock acquisition directly from a temporary test. Every failure was a*fs.PathErrorand was classified as inaccessible:mkdir ...: read-only file systemopen ...: read-only file systemopen ...: read-only file system0555open ...: permission deniedWith a config holding a fake expired refreshable token and
XDG_STATE_HOMEon the read-only mount, I ran the builtgh auth token. It printed the stored access token, then the warning above on stderr, and exited 0. The config file was unchanged.On macOS 27.2 (arm64), the built binary was run against the same kind of fake config under
sandbox-execwith the profile(version 1) (allow default) (deny file-write* (subpath "<tmp>/state")):gh auth token, state dir missinghosts.ymlunchangedgh auth token, state dir exists without a lock filegh auth token, lock file existsgh api usergh: Bad credentials (HTTP 401)followed by the warning, exit 1,hosts.ymlunchangedmkdirandopenboth failed withoperation not permittedas*fs.PathError, classified as inaccessiblegh auth tokenwithout the sandbox (control)Windows was not tested.
Key points
*fs.PathErrorwhose cause is a permission error (EACCES,EPERM) orEROFStriggers the skip.MkdirAlland opening the lock file return errors of this type. Errors from the locking syscall itself (for exampleENOLCKon filesystems without advisory locking) and lock timeouts are not path errors, so gh still refreshes without the lock in those cases, as before.AuthConfigand printed byMainbecause the refresh runs inside the HTTP transport (AddAuthTokenHeaderWithRefresh), and aRoundTrippercan only return a response or an error, with no way to pass a warning back to the command. Replacing the 401 response with a custom error was rejected as a risky, likely incompatible change.http.Clientwould wrap it in a*url.Error, soerrors.Aschecks forapi.HTTPErrorwith status 401 (the "Try authenticating with" and scopes hints ininternal/ghcmd/cmd.go, the expired token explanation ingh auth status) would stop matching. Commands that match on error text would also break, and so wouldgh api, which prints the raw response body that scripts parse.RefreshStatusSkipped, reports the outcome, andgh.AuthConfiggets aWarnings()method thatMainreads before exiting.Notes for reviewers
Start with
isRefreshLockInaccessibleand the skip branch inrefreshCredentialForUserininternal/config/refreshable.go.withFreshConfigForRefreshnow passes the lock error to its callback instead of a boolean, so the callback can tell why the lock is missing. Then read the warning collection onAuthConfigininternal/config/config.goandprintAuthWarningsininternal/ghcmd/cmd.go.The skip happens after gh re-reads the credential under the lock and confirms it still needs a refresh, but before the token exchange, so a skipped refresh never contacts the server.
Authorship and follow-up
Who wrote this:
Who answers review comments: