@@ -1642,6 +1642,19 @@ async def _claude_login_read_loop(self, pid: int, fd: int) -> None:
16421642 )
16431643 except OSError :
16441644 LOG .info ('claude_login: pty closed (pid=%s)' , pid )
1645+ # The pty closed: claude either wrote ~/.claude.json and
1646+ # exited cleanly, or bailed without writing credentials.
1647+ # The general auth-poll loop runs on a 15s cadence once
1648+ # the box is past its first minute — too coarse to
1649+ # confirm success inside the cloud's exit watchdog, which
1650+ # is what makes a fast-successful sign-in surface a
1651+ # spurious "Try again". Check directly here at a tight
1652+ # cadence: on success emit `claude_authed` and skip the
1653+ # exited event; on timeout emit `claude_login_exited` so
1654+ # the cloud fails fast. Exactly one terminal event per
1655+ # attempt — no cloud-side race left to lose.
1656+ if await self ._claude_login_await_success ():
1657+ return
16451658 await self ._send ({'type' : 'claude_login_exited' })
16461659 return
16471660 if data is None :
@@ -1673,6 +1686,36 @@ async def _claude_login_read_loop(self, pid: int, fd: int) -> None:
16731686 except Exception :
16741687 LOG .exception ('claude_login: read loop crashed' )
16751688
1689+ async def _claude_login_await_success (self , deadline_s : float = 15.0 ) -> bool :
1690+ """After the login pty closes, watch for credentials to land.
1691+
1692+ `claude auth login` writes ~/.claude.json a beat after the user
1693+ pastes a valid code, then exits. The general `_auth_poll_loop`
1694+ runs on a 15s cadence once the box is past its first minute —
1695+ too coarse to confirm success inside the cloud's exit watchdog.
1696+ Poll `claude auth status` directly here every 0.5s for up to
1697+ `deadline_s`.
1698+
1699+ Returns True (and emits `claude_authed`) the moment creds appear;
1700+ False on timeout, leaving the caller to emit `claude_login_exited`.
1701+ Keeps `self._authed` in sync so `_auth_poll_loop` doesn't re-emit
1702+ a duplicate `claude_authed` on its next tick.
1703+ """
1704+ loop = asyncio .get_running_loop ()
1705+ deadline = loop .time () + deadline_s
1706+ while loop .time () < deadline :
1707+ if await check_claude_authed ():
1708+ self ._authed = True
1709+ try :
1710+ await self ._send ({'type' : 'claude_authed' })
1711+ LOG .info ('claude_login: success confirmed on pty-exit path' )
1712+ except Exception :
1713+ LOG .exception ('claude_login: claude_authed emit failed' )
1714+ return True
1715+ await asyncio .sleep (0.5 )
1716+ LOG .info ('claude_login: no credentials within %.0fs of pty exit' , deadline_s )
1717+ return False
1718+
16761719 async def _claude_login_code (self , * , code : str ) -> None :
16771720 """Pump the OAuth callback code into the pty's stdin."""
16781721 fd = self ._claude_login_fd
@@ -1910,10 +1953,15 @@ async def _codex_login_read_loop(self, pid: int, fd: int) -> None:
19101953 )
19111954 except OSError :
19121955 LOG .info ('codex_login: pty closed (pid=%s)' , pid )
1956+ # Mirror of the claude_login pty-exit path: confirm
1957+ # success directly here at a tight cadence rather than
1958+ # leaving the cloud to race a 15s auth poll. On success
1959+ # emit `codex_authed` and skip the exited event; on
1960+ # timeout emit `codex_login_exited` so the cloud fails
1961+ # fast. Exactly one terminal event per attempt.
1962+ if await self ._codex_login_await_success ():
1963+ return
19131964 await self ._send ({'type' : 'codex_login_exited' })
1914- # Wake the auth poll so codex_authed flips quickly
1915- # rather than waiting for the next 1s tick.
1916- self ._codex_auth_wakeup .set ()
19171965 return
19181966 if data is None :
19191967 if self ._codex_login_pid != pid :
@@ -1987,6 +2035,34 @@ async def _codex_login_read_loop(self, pid: int, fd: int) -> None:
19872035 except Exception :
19882036 pass
19892037
2038+ async def _codex_login_await_success (self , deadline_s : float = 15.0 ) -> bool :
2039+ """After the codex device-auth pty closes, watch for the token.
2040+
2041+ Mirror of `_claude_login_await_success`. `codex login --device-auth`
2042+ writes its credentials and exits 0 once OpenAI signals the device
2043+ approved. Poll `codex login status` directly here every 0.5s for
2044+ up to `deadline_s` so success is confirmed without racing the
2045+ general `_codex_auth_poll_loop` cadence.
2046+
2047+ Returns True (and emits `codex_authed`) on success; False on
2048+ timeout. Keeps `self._codex_authed` in sync so the poll loop
2049+ doesn't re-emit a duplicate.
2050+ """
2051+ loop = asyncio .get_running_loop ()
2052+ deadline = loop .time () + deadline_s
2053+ while loop .time () < deadline :
2054+ if await check_codex_authed ():
2055+ self ._codex_authed = True
2056+ try :
2057+ await self ._send ({'type' : 'codex_authed' })
2058+ LOG .info ('codex_login: success confirmed on pty-exit path' )
2059+ except Exception :
2060+ LOG .exception ('codex_login: codex_authed emit failed' )
2061+ return True
2062+ await asyncio .sleep (0.5 )
2063+ LOG .info ('codex_login: no credentials within %.0fs of pty exit' , deadline_s )
2064+ return False
2065+
19902066 async def _codex_login_cancel (self ) -> None :
19912067 await self ._codex_login_cleanup ()
19922068 await self ._send ({'type' : 'ack' , 'cmd' : 'codex_login_cancel' , 'ok' : True })
0 commit comments