← 输入框|github.com|刷新
Skip to content

Skip token refresh when gh's state directory is not writable - #14845

Open
babakks wants to merge 3 commits into
trunkfrom
babakks/skip-refresh-ro-lock
Open

babakks wants to merge 3 commits into
trunkfrom
babakks/skip-refresh-ro-lock

Conversation

@babakks

@babakks babakks commented Oct 27, 2026

Copy link
Copy Markdown
Member

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:

! Skipped refreshing the token of account monalisa on github.com because gh's state directory (/home/monalisa/.local/state/gh) is not writable. The token has expired or is about to expire. To fix this, grant gh write access to that directory. If gh is running in a sandbox, you may need to run `gh auth status` outside of it.

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 a 0555 temp directory, then called the lock acquisition directly from a temporary test. Every failure was a *fs.PathError and was classified as inaccessible:

Case Error Classified as inaccessible
State dir missing mkdir ...: read-only file system yes
Dir exists, no lock file open ...: read-only file system yes
Lock file already exists open ...: read-only file system yes
Dir mode 0555 open ...: permission denied yes

With a config holding a fake expired refreshable token and XDG_STATE_HOME on the read-only mount, I ran the built gh 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-exec with the profile (version 1) (allow default) (deny file-write* (subpath "<tmp>/state")):

Case Observed
gh auth token, state dir missing Printed the stored token, warning as the last stderr line, exit 0, hosts.yml unchanged
gh auth token, state dir exists without a lock file Same as above
gh auth token, lock file exists Same as above
gh api user gh: Bad credentials (HTTP 401) followed by the warning, exit 1, hosts.yml unchanged
Lock acquisition probe mkdir and open both failed with operation not permitted as *fs.PathError, classified as inaccessible
gh auth token without the sandbox (control) No warning. The fake refresh token was rejected, gh removed the credential and asked to re-authenticate, exit 1

Windows was not tested.

Key points

  • Only an *fs.PathError whose cause is a permission error (EACCES, EPERM) or EROFS triggers the skip. MkdirAll and opening the lock file return errors of this type. Errors from the locking syscall itself (for example ENOLCK on filesystems without advisory locking) and lock timeouts are not path errors, so gh still refreshes without the lock in those cases, as before.
  • When the refresh is skipped, gh keeps presenting the current token instead of failing locally, even if the token looks expired. The server decides, which also covers clock skew. Inside the 10 minute refresh window the token still works. Once it has really expired, API calls in the sandbox get 401 until gh runs somewhere that can refresh.
  • The warning is collected on AuthConfig and printed by Main because the refresh runs inside the HTTP transport (AddAuthTokenHeaderWithRefresh), and a RoundTripper can 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.Client would wrap it in a *url.Error, so errors.As checks for api.HTTPError with status 401 (the "Try authenticating with" and scopes hints in internal/ghcmd/cmd.go, the expired token explanation in gh auth status) would stop matching. Commands that match on error text would also break, and so would gh api, which prints the raw response body that scripts parse.
  • The warning prints on every exit, including successful ones, because a skipped refresh inside the refresh window still succeeds and the user should know the token is about to stop working. It is deduplicated per account and host, and stdout and exit codes are unchanged. The existing "Try authenticating with" hint on 401 is left as is.
  • A new refresh status, RefreshStatusSkipped, reports the outcome, and gh.AuthConfig gets a Warnings() method that Main reads before exiting.
  • Trade-off: a setup that blocks the state directory but can still save credentials (for example in the keyring, or in a writable config directory) used to get working refreshes and now gets the skip and warning. A failed lock write is still the best available signal that the credential cannot be saved, and a wrong guess there costs the user a forced re-login.

Notes for reviewers

Start with isRefreshLockInaccessible and the skip branch in refreshCredentialForUser in internal/config/refreshable.go. withFreshConfigForRefresh now 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 on AuthConfig in internal/config/config.go and printAuthWarnings in internal/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:

  • A human wrote it.
  • An agent wrote it under close human direction.
  • An agent wrote it independently, and no human has guided the implementation beyond the initial prompt.

Who answers review comments:

  • @babakks will read and reply directly.
  • An agent will draft replies and @username will read them before they are posted.
  • Nobody has explicitly committed to replying.

babakks and others added 2 commits October 27, 2026 16:15
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
@babakks
babakks requested a review from a team as a code owner October 27, 2026 17:35
@babakks
babakks requested review from BagToad and a balanced review from Copilot October 27, 2026 17:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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

Comment thread internal/config/refreshable.go
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

No deployments
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.

Expiring token auto-refresh can break the stored login when gh runs in a sandbox without write access

2 participants