WIP: feat(tools): forge.py login --token-stdin, a personal access token for a second machine #473
No reviewers
Labels
No labels
area/accessibility
area/audio
area/input
area/install
area/menu
area/renderer
area/saves
area/upscaling
good first issue
gpu/amd
gpu/igpu
gpu/intel
gpu/nvidia
help wanted
kind
bug
kind
chore
kind
docs
kind
feature
kind
performance
kind
question
needs measurements
platform/linux
platform/steam-deck
platform/windows
preview
priority
critical
priority
high
priority
low
status
blocked
status
confirmed
status
duplicate
status
invalid
status
needs-info
status
wontfix
testing wanted
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Longshot!473
Loading…
Reference in a new issue
No description provided.
Delete branch "mooki/Longshot:feat/forge-token-login"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Why
Signing in with
python -I scripts/forge.py loginon a second machine signs you out on the first within about anhour, 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_IDinscripts/magicbean_login.py). In Forgejo'srouters/web/auth/oauth.go(v17.0.1, the versiongit.magicbean.dev reports):
app.GetGrantByUserID, line 617);InvalidateRefreshTokensis on(lines 161-162);
unauthorized_client, "token was already used" (lines827-831).
git.magicbean.dev has
InvalidateRefreshTokenson. So when machine A signs in or renews, machine B's refresh token goesstale; B works until its access token runs out (about an hour), then its renewal fails:
The
_SessionLockfixed this race between two processes on one machine; it can't help across machines.What changed
python -I scripts/forge.py login --token-stdin(cmd_loginandread_tokeninscripts/forge.py,token_sign_ininscripts/magicbean_login.py).getpass, so a typed or pasted token isn't shown.An empty line, or one with spaces or control characters in it, is refused.
GET /api/v1/user. A 401 says "token refused" and a 403 names the missing scope. Thetoken is never printed.
write_private, under the session lock, asforge.json:{"kind": "token", "login": ..., "access_token": ..., "expires_at": 4102444800}. There is no refresh token, soit never touches the OAuth grant. The far
expires_at(2100-01-01) is for an older forge.py, which knows no kindand 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.forgeremote with the same code the Discord sign-in runs.write:repository,write:issueandread:user(below).forge_sessionreturns a saved token as it is, never renewing it (magicbean_login.py).forge_expiremarks a token"refused": trueinstead of zeroing its expiry. It runs on git'scredential erase,and now also when one of forge.py's API calls gets a 401 with a token (
apiinforge.py).login --token-stdin. It never says "could not be renewed".LoginErrorcarries the HTTP status (status), which those two checks read.whoamiadds "(personal access token)" for a token.logoutworks for both kinds; for a token it adds thatthe token stays valid on the site until deleted. Plain
loginkeeps a saved token and says so.--againstillsigns in with Discord, replacing it.
matrix_from_forge, when the sign-in service refuses a token, says to useteam_chat.py login --access-token-stdin, and that plainteam_chat.py login(the Discord sign-in) would replacethe token. forge.py's and team_chat.py's help say the same.
docs/forge.mdhas a new "Two machines" section (the one-grant behaviour, the token route, the scopes, theteam chat) and three new lines in "If something goes wrong". CONTRIBUTING.md's "Sign in" step points to it.
tests/test_forge_token_login.py(new). It runs forge.py against a stand-in Forgejo on 127.0.0.1 and runsgit in a sandbox (a temporary HOME, XDG_CONFIG_HOME and GIT_CONFIG_GLOBAL, no system config). It checks:
git credential fill;and git untouched;
The scopes, from each API call forge.py and
test_requests.pymake (Forgejo v17.0.1routers/api/v1/api.go;writeincludesread):read:userGET /user: the check at sign-in, andfork,pr create,pr list --mine(the/usergroup, lines 689-798)write:repositorygit pushto your fork (routers/web/repo/githttp.go:156checks 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 andcollaborators/.../permission(test_requests.py) (the/repos/{owner}/{repo}group, lines 817-1131)write:issueissue 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
forge.py login --token-stdinloginis unchangedMeasurements
main:
5e072fc8c2(2026-11-14)this PR: feat/forge-token-login @
cf6fa5e9(the suite ran ata65a4f1e;cf6fa5e9adds 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.pyagainst main'sscripts/(git archive 5e072fc8 scripts) and against thisPR, 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'stests/and run it there)5e072fc8a65a4f1etests/test_forge_token_login.py(2 runs each)--token-stdinunknown); the Discord-path test passestests/run_all.py --jobs 6)Testing
Tested:
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 passeson both.
tests/test_forge_pr.py,tests/test_forge_session_lock.py,tests/test_team_chat.pyandtests/test_check_pr_description.pypass.a65a4f1e, compared with main's by test name: 526 passed, 19 failed, 17 skipped against525/19/17. At
cf6fa5e9, which adds only the farexpires_atand two test lines,tests/test_forge_token_login.pypasses 9 of 9 again.
tests/test_forge_token_login.py, absent on main, passes here.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.read_tokenprompts "Personal access token forgit.magicbean.dev (not shown):", reads the line, and the typed text does not come back in the output.
forge.py login --helpshows--token-stdinwith the token page and the scopes.Not tested:
sign-in on the Mac (this PR's second machine), with
whoami, a fetch and a push to his fork; its result goes in acomment.
/_magicbean/matrix-login) takes a personal access token: its code isn't inthis repository (Reviewer notes). Either way the chat is reached: the error names
--access-token-stdin.Needs other testers:
python -I scripts/forge.py login --token-stdinin a console with a real token: the token isn't shown as itis typed or pasted (
getpassreads the console there),whoaminames it, andgit fetch forgeand a push to thefork sign in through it, expecting no prompt (request to open).
... --token-stdin < file), expecting the same (requestto open).
Screenshots
Nothing on screen.
Risk and rollback
loginand--again, the OAuth renewal,forge_expirefor an OAuth sign-in, and the git setup areunchanged; the test's Discord-path part passes on main and here.
forge.json, which only you can read, as the OAuth sign-in already is. It staysvalid until it is deleted on the site or expires;
logoutsays so.loginprints the ones it needs, and a missing one shows asForgejo's 403 on the command that needs it.
forge.py loginagain.Reviewer notes
token_sign_in,forge_sessionandforge_expireinmagicbean_login.py, thencmd_loginandapiinforge.py./_magicbean/matrix-logintake a personal access token? Its code isn't here.GET /api/v1/userwith the token, a token withread:userworks./login/oauth/userinfoor/login/oauth/introspect, Forgejo v17.0.1 takes only its own OAuth tokensthere, so it would refuse one. userinfo runs only the OAuth2 method (
routers/web/web.go:431-439), which parsesthe token as a Forgejo-signed JWT (
services/auth/method/oauth2.go:84); introspect parses it the same way(
routers/web/auth/oauth.go:401).team_chat.py login --access-token-stdineither way.team_chat.py loginagainst replacing a saved token. The help and the docs say notto run it on a token machine; refusing would change the chat's sign-in for everyone.
[oauth2] INVALIDATE_REFRESH_TOKENS(it weakens refresh-tokenreuse detection for everyone), or register a second OAuth application.
Issues
Fixes #435
Checklist
For everyone
type(scope): description).main. Branched from5e072fc8; main has moved since, and it is rebased in one sweep before ready.python -I -B tests/run_all.pyrun and passing (any failure that also fails onmainis named above). (Run on macOS: the 19 failures also fail on main, named under Testing.)third_party/untouched (a new folder with aPROVENANCE.mdinstead).If applicable
Assisted-by:/Co-Authored-By:trailers.mainand passing here.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]>View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.