WIP: feat(tools): forge.py login --token-stdin, a personal access token for a second machine #473

Draft
mooki wants to merge 3 commits from mooki/Longshot:feat/forge-token-login into main
Member

Why

Signing in with python -I scripts/forge.py login on a second machine signs you out on the first within about an
hour, and signing in again there does the same to the second. Someone who works on two machines can't stay signed in
on both.

Problem

Forgejo keeps one OAuth grant per user per application, and every machine signs in through the same app
(GIT_CLIENT_ID in scripts/magicbean_login.py). In Forgejo's routers/web/auth/oauth.go (v17.0.1, the version
git.magicbean.dev reports):

  • a sign-in reuses the user's grant for the app (app.GetGrantByUserID, line 617);
  • every token issued, at a sign-in or a refresh, increments the grant's counter when InvalidateRefreshTokens is on
    (lines 161-162);
  • a refresh token carrying an older count is refused with unauthorized_client, "token was already used" (lines
    827-831).

git.magicbean.dev has InvalidateRefreshTokens on. So when machine A signs in or renews, machine B's refresh token goes
stale; B works until its access token runs out (about an hour), then its renewal fails:

the git.magicbean.dev sign-in could not be renewed (POST https://git.magicbean.dev/login/oauth/access_token: HTTP 400
{"error":"unauthorized_client","error_description":"token was already used"}): sign in again

The _SessionLock fixed this race between two processes on one machine; it can't help across machines.

What changed

  • tools: python -I scripts/forge.py login --token-stdin (cmd_login and read_token in scripts/forge.py,
    token_sign_in in scripts/magicbean_login.py).
    • It reads one line from standard input. On a terminal it uses getpass, so a typed or pasted token isn't shown.
      An empty line, or one with spaces or control characters in it, is refused.
    • It checks the token with GET /api/v1/user. A 401 says "token refused" and a 403 names the missing scope. The
      token is never printed.
    • It saves the token with write_private, under the session lock, as forge.json:
      {"kind": "token", "login": ..., "access_token": ..., "expires_at": 4102444800}. There is no refresh token, so
      it never touches the OAuth grant. The far expires_at (2100-01-01) is for an older forge.py, which knows no kind
      and reads it before renewing: such a copy uses the token as an access token not yet due (checked against main's
      forge_session). This forge.py never renews a token, whatever the field says.
    • It then sets up git's credential helper and the forge remote with the same code the Discord sign-in runs.
    • It prints the scopes forge.py's commands need: write:repository, write:issue and read:user (below).
  • tools: forge_session returns a saved token as it is, never renewing it (magicbean_login.py).
    • forge_expire marks a token "refused": true instead of zeroing its expiry. It runs on git's credential erase,
      and now also when one of forge.py's API calls gets a 401 with a token (api in forge.py).
    • The next use then says the token was refused (deleted or expired) and to make a new one and run
      login --token-stdin. It never says "could not be renewed".
    • LoginError carries the HTTP status (status), which those two checks read.
  • tools: whoami adds "(personal access token)" for a token. logout works for both kinds; for a token it adds that
    the token stays valid on the site until deleted. Plain login keeps a saved token and says so. --again still
    signs in with Discord, replacing it.
  • tools: the team chat. matrix_from_forge, when the sign-in service refuses a token, says to use
    team_chat.py login --access-token-stdin, and that plain team_chat.py login (the Discord sign-in) would replace
    the token. forge.py's and team_chat.py's help say the same.
  • docs: docs/forge.md has a new "Two machines" section (the one-grant behaviour, the token route, the scopes, the
    team chat) and three new lines in "If something goes wrong". CONTRIBUTING.md's "Sign in" step points to it.
  • tests: tests/test_forge_token_login.py (new). It runs forge.py against a stand-in Forgejo on 127.0.0.1 and runs
    git in a sandbox (a temporary HOME, XDG_CONFIG_HOME and GIT_CONFIG_GLOBAL, no system config). It checks:
    • the token is checked, saved (mode 600) and used with no renewal, years later too;
    • forge.py's API calls send it, and the credential helper answers with it, also through a real
      git credential fill;
    • git is set up the same as for the Discord sign-in;
    • a 401, a 403, an empty line and two tokens on one line fail cleanly, print no token, and leave the saved sign-in
      and git untouched;
    • a token refused later says to sign in again;
    • whoami and logout work for both kinds;
    • the Discord path is unchanged (that test passes on main too);
    • the chat error names the other way in.
    • It fails on main.

The scopes, from each API call forge.py and test_requests.py make (Forgejo v17.0.1 routers/api/v1/api.go;
write includes read):

Scope Calls
read:user GET /user: the check at sign-in, and fork, pr create, pr list --mine (the /user group, lines 689-798)
write:repository git push to your fork (routers/web/repo/githttp.go:156 checks the repository scope at the push's level); fork (POST /repos/.../forks); pr create, ready, wip, edit, update (POST/PATCH .../pulls); pr review (POST .../pulls/N/reviews); reads of pulls, .diff, branches, git/commits, your fork and collaborators/.../permission (test_requests.py) (the /repos/{owner}/{repo} group, lines 817-1131)
write:issue issue create, issue comment, pr comment, pr update (POST .../issues[/N/comments]); --attach (PATCH .../issues/comments/N, POST .../assets); pr wip --needs-measurements, pr ready (POST/DELETE .../issues/N/labels); reads of issues, comments and labels (the issues and labels group, lines 1143-1256)

Flags and settings

Flag What it does Default now Recommendation Helps on Doesn't help on Untested on
forge.py login --token-stdin Signs in with a personal access token from standard input instead of Discord Off: plain login is unchanged Use it on every machine but one; keep Discord on the main one A second machine on the same account One machine (Discord is simpler there) Windows, Linux

Measurements

main: 5e072fc8c2 (2026-11-14)
this PR: feat/forge-token-login @ cf6fa5e9 (the suite ran at a65a4f1e; cf6fa5e9 adds the far expires_at and its test lines)
Hardware: Apple M4 Max, macOS 26.7.1, git 2.54.0 (Apple Git-157), Python 3.14.10. Game-free: no GPU, no game
Scene: none (a tooling change: CONTRIBUTING's "build, tools" row takes test output)
Method: tests/test_forge_token_login.py against main's scripts/ (git archive 5e072fc8 scripts) and against this
PR, interleaved (main, this PR, main, this PR); the full game-free suite on this PR, compared with main's baseline run
on the same Mac
How to reproduce: python -I -B tests/test_forge_token_login.py -v (for main's side: copy it into a checkout of main's
tests/ and run it there)

main @ 5e072fc8 this PR @ a65a4f1e
tests/test_forge_token_login.py (2 runs each) FAIL: 8 of 9 (--token-stdin unknown); the Discord-path test passes PASS: 9 of 9
full game-free suite on macOS (tests/run_all.py --jobs 6) 525 passed, 19 failed, 17 skipped 526 passed, 19 failed, 17 skipped
main @ 5e072fc8:
test_discord_sign_in_unchanged ... ok
test_token_is_checked_saved_and_git_set_up ... FAIL
AssertionError: 2 != 0 : usage: test_forge_token_login.py [-h]
                                 {login,fork,whoami,logout,credential,pr,issue} ...
test_forge_token_login.py: error: unrecognized arguments: --token-stdin
(the other 7 fail on the same unknown option)
FAILED (failures=8)

this PR @ a65a4f1e:
Ran 9 tests
OK

Testing

Tested:

  • On an Apple M4 Max with macOS 26.7.1, game-free only, with no request to git.magicbean.dev: every request went to the
    test's stand-in on 127.0.0.1, and the tokens were made up.
  • tests/test_forge_token_login.py: 8 of its 9 tests fail on main, and all 9 pass here. The Discord-path test passes
    on both.
  • tests/test_forge_pr.py, tests/test_forge_session_lock.py, tests/test_team_chat.py and
    tests/test_check_pr_description.py pass.
  • The full game-free suite at a65a4f1e, compared with main's by test name: 526 passed, 19 failed, 17 skipped against
    525/19/17. At cf6fa5e9, which adds only the far expires_at and two test lines, tests/test_forge_token_login.py
    passes 9 of 9 again.
    • Only one status changed: tests/test_forge_token_login.py, absent on main, passes here.
    • The same 19 checks fail on both. Main lacks the macOS port's fixes, so they fail on a Mac whatever this PR does:
      test_appimage_package_retry, test_collect_wide, test_control, test_framegen_resources, test_game_layout,
      test_low_memory_notice, test_mem_gate, test_mem_gate_children, test_mirror_trim, test_module_ids,
      test_oot_state_tap, test_pad_layout, test_pcache_header_read, test_pipeline_cache_file,
      test_pipeline_cache_save, test_pipeline_play_cache, test_prof_symbols, test_program_cache_cleanup,
      test_program_cache_one_variant.
  • In a terminal (a pseudo-terminal driven by a script): read_token prompts "Personal access token for
    git.magicbean.dev (not shown):", reads the line, and the typed text does not come back in the output.
  • forge.py login --help shows --token-stdin with the token page and the scopes.

Not tested:

  • A real token against git.magicbean.dev: nothing here talked to the server. The first real use is the author's own
    sign-in on the Mac (this PR's second machine), with whoami, a fetch and a push to his fork; its result goes in a
    comment.
  • Whether the team's sign-in service (/_magicbean/matrix-login) takes a personal access token: its code isn't in
    this repository (Reviewer notes). Either way the chat is reached: the error names --access-token-stdin.

Needs other testers:

  • Windows: python -I scripts/forge.py login --token-stdin in a console with a real token: the token isn't shown as it
    is typed or pasted (getpass reads the console there), whoami names it, and git fetch forge and a push to the
    fork sign in through it, expecting no prompt (request to open).
  • Linux: the same in a terminal and with the token piped in (... --token-stdin < file), expecting the same (request
    to open).

Screenshots

Nothing on screen.

Risk and rollback

  • Low. Plain login and --again, the OAuth renewal, forge_expire for an OAuth sign-in, and the git setup are
    unchanged; the test's Discord-path part passes on main and here.
  • A token is a long-lived secret in forge.json, which only you can read, as the OAuth sign-in already is. It stays
    valid until it is deleted on the site or expires; logout says so.
  • The token's scopes can't be checked from the API. login prints the ones it needs, and a missing one shows as
    Forgejo's 403 on the command that needs it.
  • Reverting the commit is enough. A machine signed in with a token then needs forge.py login again.

Reviewer notes

  • Read token_sign_in, forge_session and forge_expire in magicbean_login.py, then cmd_login and api in
    forge.py.
  • @HumanBean, does /_magicbean/matrix-login take a personal access token? Its code isn't here.
    • If it asks Forgejo's GET /api/v1/user with the token, a token with read:user works.
    • If it uses /login/oauth/userinfo or /login/oauth/introspect, Forgejo v17.0.1 takes only its own OAuth tokens
      there, so it would refuse one. userinfo runs only the OAuth2 method (routers/web/web.go:431-439), which parses
      the token as a Forgejo-signed JWT (services/auth/method/oauth2.go:84); introspect parses it the same way
      (routers/web/auth/oauth.go:401).
    • The PR doesn't depend on the answer: the error points to team_chat.py login --access-token-stdin either way.
  • Left out on purpose: a guard in team_chat.py login against replacing a saved token. The help and the docs say not
    to run it on a token machine; refusing would change the chat's sign-in for everyone.
  • The alternatives for an admin are still open: turn off [oauth2] INVALIDATE_REFRESH_TOKENS (it weakens refresh-token
    reuse detection for everyone), or register a second OAuth application.

Issues

Fixes #435

Checklist

For everyone

  • One topic.
  • The title is a Conventional Commit (type(scope): description).
  • Rebased on current main. Branched from 5e072fc8; main has moved since, and it is rebased in one sweep before ready.
  • python -I -B tests/run_all.py run and passing (any failure that also fails on main is named above). (Run on macOS: the 19 failures also fail on main, named under Testing.)
  • Tested in the game, or what wasn't is named under Testing. (No game part: a tooling change.)
  • Testing filled in: what was tested, what wasn't, and what needs other testers (with test requests). The two test requests are still to open.
  • Measured: main and this PR at named commits, on the same PC, scene and settings, at least 2 interleaved runs each, the median reported. (For tools, test output: the new test fails on main and passes here, run twice on each side.)
  • 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.

If applicable

  • AI assistance recorded with Assisted-by: / Co-Authored-By: trailers.
  • A game-free test added for the fix, failing on main and passing here.
  • Docs updated.
## Why Signing in with `python -I scripts/forge.py login` on a second machine signs you out on the first within about an hour, and signing in again there does the same to the second. Someone who works on two machines can't stay signed in on both. ## Problem Forgejo keeps **one OAuth grant per user per application**, and every machine signs in through the same app (`GIT_CLIENT_ID` in `scripts/magicbean_login.py`). In Forgejo's `routers/web/auth/oauth.go` (v17.0.1, the version git.magicbean.dev reports): - a sign-in reuses the user's grant for the app (`app.GetGrantByUserID`, line 617); - every token issued, at a sign-in or a refresh, increments the grant's counter when `InvalidateRefreshTokens` is on (lines 161-162); - a refresh token carrying an older count is refused with `unauthorized_client`, "token was already used" (lines 827-831). git.magicbean.dev has `InvalidateRefreshTokens` on. So when machine A signs in or renews, machine B's refresh token goes stale; B works until its access token runs out (about an hour), then its renewal fails: ``` the git.magicbean.dev sign-in could not be renewed (POST https://git.magicbean.dev/login/oauth/access_token: HTTP 400 {"error":"unauthorized_client","error_description":"token was already used"}): sign in again ``` The `_SessionLock` fixed this race between two processes on one machine; it can't help across machines. ## What changed - tools: `python -I scripts/forge.py login --token-stdin` (`cmd_login` and `read_token` in `scripts/forge.py`, `token_sign_in` in `scripts/magicbean_login.py`). - It reads one line from standard input. On a terminal it uses `getpass`, so a typed or pasted token isn't shown. An empty line, or one with spaces or control characters in it, is refused. - It checks the token with `GET /api/v1/user`. A 401 says "token refused" and a 403 names the missing scope. The token is never printed. - It saves the token with `write_private`, under the session lock, as `forge.json`: `{"kind": "token", "login": ..., "access_token": ..., "expires_at": 4102444800}`. There is no refresh token, so it never touches the OAuth grant. The far `expires_at` (2100-01-01) is for an older forge.py, which knows no kind and reads it before renewing: such a copy uses the token as an access token not yet due (checked against main's `forge_session`). This forge.py never renews a token, whatever the field says. - It then sets up git's credential helper and the `forge` remote with the same code the Discord sign-in runs. - It prints the scopes forge.py's commands need: `write:repository`, `write:issue` and `read:user` (below). - tools: `forge_session` returns a saved token as it is, never renewing it (`magicbean_login.py`). - `forge_expire` marks a token `"refused": true` instead of zeroing its expiry. It runs on git's `credential erase`, and now also when one of forge.py's API calls gets a 401 with a token (`api` in `forge.py`). - The next use then says the token was refused (deleted or expired) and to make a new one and run `login --token-stdin`. It never says "could not be renewed". - `LoginError` carries the HTTP status (`status`), which those two checks read. - tools: `whoami` adds "(personal access token)" for a token. `logout` works for both kinds; for a token it adds that the token stays valid on the site until deleted. Plain `login` keeps a saved token and says so. `--again` still signs in with Discord, replacing it. - tools: the team chat. `matrix_from_forge`, when the sign-in service refuses a token, says to use `team_chat.py login --access-token-stdin`, and that plain `team_chat.py login` (the Discord sign-in) would replace the token. forge.py's and team_chat.py's help say the same. - docs: `docs/forge.md` has a new "Two machines" section (the one-grant behaviour, the token route, the scopes, the team chat) and three new lines in "If something goes wrong". CONTRIBUTING.md's "Sign in" step points to it. - tests: `tests/test_forge_token_login.py` (new). It runs forge.py against a stand-in Forgejo on 127.0.0.1 and runs git in a sandbox (a temporary HOME, XDG_CONFIG_HOME and GIT_CONFIG_GLOBAL, no system config). It checks: - the token is checked, saved (mode 600) and used with no renewal, years later too; - forge.py's API calls send it, and the credential helper answers with it, also through a real `git credential fill`; - git is set up the same as for the Discord sign-in; - a 401, a 403, an empty line and two tokens on one line fail cleanly, print no token, and leave the saved sign-in and git untouched; - a token refused later says to sign in again; - whoami and logout work for both kinds; - the Discord path is unchanged (that test passes on main too); - the chat error names the other way in. - It fails on main. The scopes, from each API call forge.py and `test_requests.py` make (Forgejo v17.0.1 `routers/api/v1/api.go`; `write` includes `read`): | Scope | Calls | |---|---| | `read:user` | `GET /user`: the check at sign-in, and `fork`, `pr create`, `pr list --mine` (the `/user` group, lines 689-798) | | `write:repository` | `git push` to your fork (`routers/web/repo/githttp.go:156` checks the repository scope at the push's level); `fork` (`POST /repos/.../forks`); `pr create`, `ready`, `wip`, `edit`, `update` (`POST`/`PATCH .../pulls`); `pr review` (`POST .../pulls/N/reviews`); reads of pulls, `.diff`, branches, `git/commits`, your fork and `collaborators/.../permission` (`test_requests.py`) (the `/repos/{owner}/{repo}` group, lines 817-1131) | | `write:issue` | `issue create`, `issue comment`, `pr comment`, `pr update` (`POST .../issues[/N/comments]`); `--attach` (`PATCH .../issues/comments/N`, `POST .../assets`); `pr wip --needs-measurements`, `pr ready` (`POST`/`DELETE .../issues/N/labels`); reads of issues, comments and labels (the issues and labels group, lines 1143-1256) | ## Flags and settings | Flag | What it does | Default now | Recommendation | Helps on | Doesn't help on | Untested on | |---|---|---|---|---|---|---| | `forge.py login --token-stdin` | Signs in with a personal access token from standard input instead of Discord | Off: plain `login` is unchanged | Use it on every machine but one; keep Discord on the main one | A second machine on the same account | One machine (Discord is simpler there) | Windows, Linux | ## Measurements main: 5e072fc8c2adadba5ea610148be1c47800ba0cca (2026-11-14) this PR: feat/forge-token-login @ cf6fa5e9 (the suite ran at a65a4f1e; cf6fa5e9 adds the far expires_at and its test lines) Hardware: Apple M4 Max, macOS 26.7.1, git 2.54.0 (Apple Git-157), Python 3.14.10. Game-free: no GPU, no game Scene: none (a tooling change: CONTRIBUTING's "build, tools" row takes test output) Method: `tests/test_forge_token_login.py` against main's `scripts/` (`git archive 5e072fc8 scripts`) and against this PR, interleaved (main, this PR, main, this PR); the full game-free suite on this PR, compared with main's baseline run on the same Mac How to reproduce: `python -I -B tests/test_forge_token_login.py -v` (for main's side: copy it into a checkout of main's `tests/` and run it there) | | main @ 5e072fc8 | this PR @ a65a4f1e | |---|---|---| | `tests/test_forge_token_login.py` (2 runs each) | FAIL: 8 of 9 (`--token-stdin` unknown); the Discord-path test passes | PASS: 9 of 9 | | full game-free suite on macOS (`tests/run_all.py --jobs 6`) | 525 passed, 19 failed, 17 skipped | 526 passed, 19 failed, 17 skipped | ``` main @ 5e072fc8: test_discord_sign_in_unchanged ... ok test_token_is_checked_saved_and_git_set_up ... FAIL AssertionError: 2 != 0 : usage: test_forge_token_login.py [-h] {login,fork,whoami,logout,credential,pr,issue} ... test_forge_token_login.py: error: unrecognized arguments: --token-stdin (the other 7 fail on the same unknown option) FAILED (failures=8) this PR @ a65a4f1e: Ran 9 tests OK ``` ## Testing **Tested:** - On an Apple M4 Max with macOS 26.7.1, game-free only, with no request to git.magicbean.dev: every request went to the test's stand-in on 127.0.0.1, and the tokens were made up. - `tests/test_forge_token_login.py`: 8 of its 9 tests fail on main, and all 9 pass here. The Discord-path test passes on both. - `tests/test_forge_pr.py`, `tests/test_forge_session_lock.py`, `tests/test_team_chat.py` and `tests/test_check_pr_description.py` pass. - The full game-free suite at a65a4f1e, compared with main's by test name: 526 passed, 19 failed, 17 skipped against 525/19/17. At cf6fa5e9, which adds only the far `expires_at` and two test lines, `tests/test_forge_token_login.py` passes 9 of 9 again. - Only one status changed: `tests/test_forge_token_login.py`, absent on main, passes here. - The same 19 checks fail on both. Main lacks the macOS port's fixes, so they fail on a Mac whatever this PR does: `test_appimage_package_retry`, `test_collect_wide`, `test_control`, `test_framegen_resources`, `test_game_layout`, `test_low_memory_notice`, `test_mem_gate`, `test_mem_gate_children`, `test_mirror_trim`, `test_module_ids`, `test_oot_state_tap`, `test_pad_layout`, `test_pcache_header_read`, `test_pipeline_cache_file`, `test_pipeline_cache_save`, `test_pipeline_play_cache`, `test_prof_symbols`, `test_program_cache_cleanup`, `test_program_cache_one_variant`. - In a terminal (a pseudo-terminal driven by a script): `read_token` prompts "Personal access token for git.magicbean.dev (not shown):", reads the line, and the typed text does not come back in the output. - `forge.py login --help` shows `--token-stdin` with the token page and the scopes. **Not tested:** - A real token against git.magicbean.dev: nothing here talked to the server. The first real use is the author's own sign-in on the Mac (this PR's second machine), with `whoami`, a fetch and a push to his fork; its result goes in a comment. - Whether the team's sign-in service (`/_magicbean/matrix-login`) takes a personal access token: its code isn't in this repository (Reviewer notes). Either way the chat is reached: the error names `--access-token-stdin`. **Needs other testers:** - Windows: `python -I scripts/forge.py login --token-stdin` in a console with a real token: the token isn't shown as it is typed or pasted (`getpass` reads the console there), `whoami` names it, and `git fetch forge` and a push to the fork sign in through it, expecting no prompt (request to open). - Linux: the same in a terminal and with the token piped in (`... --token-stdin < file`), expecting the same (request to open). ## Screenshots Nothing on screen. ## Risk and rollback - Low. Plain `login` and `--again`, the OAuth renewal, `forge_expire` for an OAuth sign-in, and the git setup are unchanged; the test's Discord-path part passes on main and here. - A token is a long-lived secret in `forge.json`, which only you can read, as the OAuth sign-in already is. It stays valid until it is deleted on the site or expires; `logout` says so. - The token's scopes can't be checked from the API. `login` prints the ones it needs, and a missing one shows as Forgejo's 403 on the command that needs it. - Reverting the commit is enough. A machine signed in with a token then needs `forge.py login` again. ## Reviewer notes - Read `token_sign_in`, `forge_session` and `forge_expire` in `magicbean_login.py`, then `cmd_login` and `api` in `forge.py`. - @HumanBean, does `/_magicbean/matrix-login` take a personal access token? Its code isn't here. - If it asks Forgejo's `GET /api/v1/user` with the token, a token with `read:user` works. - If it uses `/login/oauth/userinfo` or `/login/oauth/introspect`, Forgejo v17.0.1 takes only its own OAuth tokens there, so it would refuse one. userinfo runs only the OAuth2 method (`routers/web/web.go:431-439`), which parses the token as a Forgejo-signed JWT (`services/auth/method/oauth2.go:84`); introspect parses it the same way (`routers/web/auth/oauth.go:401`). - The PR doesn't depend on the answer: the error points to `team_chat.py login --access-token-stdin` either way. - Left out on purpose: a guard in `team_chat.py login` against replacing a saved token. The help and the docs say not to run it on a token machine; refusing would change the chat's sign-in for everyone. - The alternatives for an admin are still open: turn off `[oauth2] INVALIDATE_REFRESH_TOKENS` (it weakens refresh-token reuse detection for everyone), or register a second OAuth application. ## Issues Fixes #435 ## Checklist ### For everyone - [x] One topic. - [x] The title is a Conventional Commit (`type(scope): description`). - [ ] Rebased on current `main`. Branched from 5e072fc8; main has moved since, and it is rebased in one sweep before ready. - [x] `python -I -B tests/run_all.py` run and passing (any failure that also fails on `main` is named above). (Run on macOS: the 19 failures also fail on main, named under Testing.) - [x] Tested in the game, or what wasn't is named under Testing. (No game part: a tooling change.) - [ ] Testing filled in: what was tested, what wasn't, and what needs other testers (with test requests). The two test requests are still to open. - [x] Measured: main and this PR at named commits, on the same PC, scene and settings, at least 2 interleaved runs each, the median reported. (For tools, test output: the new test fails on main and passes here, run twice on each side.) - [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. ### If applicable - [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. - [x] Docs updated.
Forgejo keeps one OAuth grant per person and application, and every token it issues, at a sign-in or a renewal, moves
the grant's counter on; a refresh token from an older count is refused as "token was already used"
(routers/web/auth/oauth.go 161-162, 617, 827-831 in Forgejo v17.0.1). Every machine signs in through the same app
(GIT_CLIENT_ID), so two machines on one account sign each other out within about an hour.

login --token-stdin reads a Forgejo personal access token from standard input (not echoed on a terminal), checks it
with GET /api/v1/user ("token refused" on a 401, the missing scope on a 403, never printing it) and saves it with
write_private as forge.json {"kind": "token", "login", "access_token"}: no refresh token, no expiry. forge_session
returns it as it is; forge_expire (git's erase, or a 401 from forge.py's API calls) marks it refused, and the next use
says to make a new one and sign in again instead of "could not be renewed". The git credential helper and the forge
remote are set up as the Discord sign-in sets them up. whoami names the kind, logout says the token itself stays valid
on the site, plain login keeps a saved token and --again goes back to Discord. The scopes forge.py's calls need are
printed: write:repository, write:issue, read:user.

The team chat's sign-in service is not in this repository, so whether it takes a personal access token can't be read
from the code; when it refuses one, the error, forge.py's help, team_chat.py's help and docs/forge.md point to
team_chat.py login --access-token-stdin, and say that plain team_chat.py login (the Discord sign-in) would replace the
token. docs/forge.md has a "Two machines" section; CONTRIBUTING.md's sign-in step points to it.

tests/test_forge_token_login.py (new) runs forge.py against a stand-in Forgejo on 127.0.0.1, with git sandboxed in a
temporary HOME and GIT_CONFIG_GLOBAL. It fails on main; its Discord-path test passes on both.

Fixes #435

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
fix(tools): a personal access token is saved with a far expiry, so an older forge.py uses it as it is
All checks were successful
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
PR description / Description follows the template (pull_request_target) Successful in 4s
CI / Conventional Commit title (pull_request) Successful in 3s
CI / Workflow lint (actionlint) (pull_request) Successful in 7s
Third-party parts / As their notes say (pull_request) Successful in 30s
Website / Website on Cloudflare (pull_request_target) Has been skipped
cf6fa5e943
forge.py from before --token-stdin reads expires_at before it renews a sign-in and has no other field for the
kind, so a token saved without one stopped it with a KeyError. The token is now saved with 2100-01-01: older copies
use it as an access token that is not yet due, and this forge.py, which knows the kind, still never renews it.
Checked against main 5e072fc8's magicbean_login.forge_session.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
All checks were successful
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
PR description / Description follows the template (pull_request_target) Successful in 4s
CI / Conventional Commit title (pull_request) Successful in 3s
CI / Workflow lint (actionlint) (pull_request) Successful in 7s
Third-party parts / As their notes say (pull_request) Successful in 30s
Website / Website on Cloudflare (pull_request_target) Has been skipped
This pull request is marked as a work in progress.
This branch is out-of-date with the base branch
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u 'feat/forge-token-login:mooki-feat/forge-token-login'
git switch 'mooki-feat/forge-token-login'
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!473
No description provided.