fix(forge): an expired sign-in isn't renewed where the new token can't be saved, so a sandboxed run no longer loses it #855

Merged
HumanBean merged 1 commit from HumanBean/Longshot:fix/forge-renew-where-saved into main 2026-11-15 18:09:28 +00:00
Owner

Type: fix · Risk: low · Tested on: Linux (CachyOS), no GPU involved · Needs: none

Why

The git.magicbean.dev sign-in (forge.py, git and jj pushes, the team chat) kept being lost, and people had to run
forge.py login again several times a day. One way to lose it: an agent's command ran in a sandbox that makes
~/.config/longshot read-only. When the token had run out, forge.py renewed it with Forgejo, then couldn't save the
new one. With Forgejo's default, the old refresh token was then used up, and every later run failed with "token was
already used". Even where it didn't fail, it told you to sign in again when there was nothing wrong with the saved
sign-in.

The main cause, #435 (one OAuth grant per user, so a
sign-in elsewhere, two renewals at once or a lost answer signs you out), is fixed on the server, not here:
git.magicbean.dev now has INVALIDATE_REFRESH_TOKENS = false (2026-11-15, MagicBeanDev/Infra f1cdc2ea), so a refresh
token can be used again until it expires. This PR is the client side: don't renew where the result can't be kept, and
say so plainly.

See #435.

What changed

Before renewing an expired token, forge_session() checks that the config folder can be written. Where it can't, it
says why and returns without asking Forgejo, so the saved sign-in is kept for the next run that can save it. forge.py
says "the saved sign-in could not be used (why is above)" instead of "not signed in, run login" when a sign-in is saved.

  • scripts/magicbean_login.py: cannot_save() (makes and removes a probe file, as write_private would);
    forge_session() calls it before the lock and the renewal; _SessionLock's note says the server now takes refresh
    tokens again.
  • scripts/forge.py: not_signed_in() for token() and the credential helper's get; login refuses to start where
    it couldn't save the result.
  • tests/test_forge_session_lock.py: test_no_renewal_where_it_cannot_be_saved: a read-only config folder with an
    expired sign-in. Nothing is renewed (the stand-in for Forgejo is never asked), the message is printed and
    forge.json is unchanged. Skipped on Windows and as root, where a 0o500 folder is still writable.

Measurements

Nothing to measure: a script and its test, nothing the game runs.

Flags and settings

None

Testing

Tested: On Linux, after the server change: two copies of one expired sign-in each renewed from the same refresh
token. Both came back as HumanBean, so a reused refresh token now works. py_compile of the three files. I didn't run
tests/run_all.py on this PC (this PC runs no test suites). CI runs it, with the new test.

Not tested: Windows (the probe is a plain os.open and os.remove, the same calls write_private makes), and a
real sandboxed run with an expired token (the new test covers that case).

Must be tested:

  • None

Risks

What could break: a config folder that can be written but where the probe fails for another reason would stop
renewals there. The message names the folder and the error.

Turning it off: revert the commit.

Compatibility: Nothing changes: forge.json is the same, and an existing sign-in keeps working.

Issues

See #435

Checklist

Everyone

  • One topic.
  • The title is a Conventional Commit (type(scope): description) of the status line's type.
  • Why gives the reason plainly and links the issues that fit (the tracker searched).
  • Measurements has a verdict, or one line saying why there is nothing to measure.
  • Testing names what was tested, what wasn't, and what must still be tested, where and how.
  • Rebased on current main.
  • python -I -B tests/run_all.py passes (any failure that also fails on main is named). (CI runs it: not run on this PC)
  • No game files, dumps, build output or generated files.
  • third_party/ untouched (a new folder with a PROVENANCE.md instead).
  • The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled.

When it applies

  • AI assistance recorded with Assisted-by: / Co-Authored-By: trailers.
  • A game-free test added for the fix, failing on main and passing here.

🤖 Generated with Claude Code

**Type:** fix · **Risk:** low · **Tested on:** Linux (CachyOS), no GPU involved · **Needs:** none ## Why The git.magicbean.dev sign-in (forge.py, git and jj pushes, the team chat) kept being lost, and people had to run `forge.py login` again several times a day. One way to lose it: an agent's command ran in a sandbox that makes `~/.config/longshot` read-only. When the token had run out, forge.py renewed it with Forgejo, then couldn't save the new one. With Forgejo's default, the old refresh token was then used up, and every later run failed with "token was already used". Even where it didn't fail, it told you to sign in again when there was nothing wrong with the saved sign-in. The main cause, [#435](https://git.magicbean.dev/MagicBeanDev/Longshot/issues/435) (one OAuth grant per user, so a sign-in elsewhere, two renewals at once or a lost answer signs you out), is fixed on the server, not here: git.magicbean.dev now has `INVALIDATE_REFRESH_TOKENS = false` (2026-11-15, MagicBeanDev/Infra `f1cdc2ea`), so a refresh token can be used again until it expires. This PR is the client side: don't renew where the result can't be kept, and say so plainly. See [#435](https://git.magicbean.dev/MagicBeanDev/Longshot/issues/435). ## What changed Before renewing an expired token, `forge_session()` checks that the config folder can be written. Where it can't, it says why and returns without asking Forgejo, so the saved sign-in is kept for the next run that can save it. forge.py says "the saved sign-in could not be used (why is above)" instead of "not signed in, run login" when a sign-in is saved. - `scripts/magicbean_login.py`: `cannot_save()` (makes and removes a probe file, as `write_private` would); `forge_session()` calls it before the lock and the renewal; `_SessionLock`'s note says the server now takes refresh tokens again. - `scripts/forge.py`: `not_signed_in()` for `token()` and the credential helper's `get`; `login` refuses to start where it couldn't save the result. - `tests/test_forge_session_lock.py`: `test_no_renewal_where_it_cannot_be_saved`: a read-only config folder with an expired sign-in. Nothing is renewed (the stand-in for Forgejo is never asked), the message is printed and `forge.json` is unchanged. Skipped on Windows and as root, where a 0o500 folder is still writable. ## Measurements Nothing to measure: a script and its test, nothing the game runs. ### Flags and settings None ## Testing **Tested:** On Linux, after the server change: two copies of one expired sign-in each renewed from the same refresh token. Both came back as HumanBean, so a reused refresh token now works. `py_compile` of the three files. I didn't run `tests/run_all.py` on this PC (this PC runs no test suites). CI runs it, with the new test. **Not tested:** Windows (the probe is a plain `os.open` and `os.remove`, the same calls `write_private` makes), and a real sandboxed run with an expired token (the new test covers that case). **Must be tested:** - None ## Risks **What could break:** a config folder that can be written but where the probe fails for another reason would stop renewals there. The message names the folder and the error. **Turning it off:** revert the commit. **Compatibility:** Nothing changes: `forge.json` is the same, and an existing sign-in keeps working. ## Issues See #435 ## Checklist ### Everyone - [x] One topic. - [x] The title is a Conventional Commit (`type(scope): description`) of the status line's type. - [x] Why gives the reason plainly and links the issues that fit (the tracker searched). - [x] Measurements has a verdict, or one line saying why there is nothing to measure. - [x] Testing names what was tested, what wasn't, and what must still be tested, where and how. - [x] Rebased on current `main`. - [ ] `python -I -B tests/run_all.py` passes (any failure that also fails on `main` is named). (CI runs it: not run on this PC) - [x] No game files, dumps, build output or generated files. - [x] `third_party/` untouched (a new folder with a `PROVENANCE.md` instead). - [x] The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled. ### When it applies - [x] AI assistance recorded with `Assisted-by:` / `Co-Authored-By:` trailers. - [x] A game-free test added for the fix, failing on `main` and passing here. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(forge): an expired sign-in isn't renewed where the new token can't be saved, so a sandboxed run no longer loses it
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request_target) Has been skipped
PR description / Description follows the template (pull_request_target) Has been cancelled
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Workflow lint (actionlint) (pull_request) Has been cancelled
Guide screenshots / Guide images and local UI source review (pull_request) Has been cancelled
Third-party parts / As their notes say (pull_request) Has been cancelled
c32d78688c
forge_session() tries a file in the config folder before renewing; where it can't
(a sandbox making ~/.config/longshot read-only), it says so and keeps the saved
sign-in instead of asking Forgejo for a token it would then fail to save. forge.py
no longer tells you to sign in again when a saved sign-in exists, and login refuses
to start where it couldn't save the result.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
HumanBean force-pushed fix/forge-renew-where-saved from c32d78688c
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
Website / Website on Cloudflare (pull_request_target) Has been skipped
PR description / Description follows the template (pull_request_target) Has been cancelled
CI / Conventional Commit title (pull_request) Has been cancelled
CI / Workflow lint (actionlint) (pull_request) Has been cancelled
Guide screenshots / Guide images and local UI source review (pull_request) Has been cancelled
Third-party parts / As their notes say (pull_request) Has been cancelled
to 42a50203a5
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 5s
CI / Workflow lint (actionlint) (pull_request) Successful in 9s
Guide screenshots / Guide images and local UI source review (pull_request) Failing after 8s
Third-party parts / As their notes say (pull_request) Successful in 15s
PR description / Description follows the template (pull_request_target) Failing after 4s
2026-11-15 17:51:39 +00:00
Compare
Author
Owner

Pushed 42a50203: the first push (c32d7868) took in 7 host/ and nx2/ files from another session's change (game title and icon), which shared my workspace. They're out, so the diff is only scripts/forge.py, scripts/magicbean_login.py and tests/test_forge_session_lock.py.

Pushed 42a50203: the first push (c32d7868) took in 7 host/ and nx2/ files from another session's change (game title and icon), which shared my workspace. They're out, so the diff is only scripts/forge.py, scripts/magicbean_login.py and tests/test_forge_session_lock.py.
HumanBean changed title from WIP: fix(forge): an expired sign-in isn't renewed where the new token can't be saved, so a sandboxed run no longer loses it to fix(forge): an expired sign-in isn't renewed where the new token can't be saved, so a sandboxed run no longer loses it 2026-11-15 18:09:22 +00:00
HumanBean deleted branch fix/forge-renew-where-saved 2026-11-15 18:09:29 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
MagicBeanDev/Longshot!855
No description provided.