From 4122ea5e28d246e738e3c4a19b090a26bfd07394 Mon Sep 17 00:00:00 2001 From: Mariozada Date: Tue, 5 May 2026 22:44:54 +1000 Subject: [PATCH 01/14] fix: use device code flow for OpenAI headless OAuth --- .../agent/utils/oauth/openai_oauth_llm.py | 207 ++++++++++++------ 1 file changed, 138 insertions(+), 69 deletions(-) diff --git a/mobilerun/agent/utils/oauth/openai_oauth_llm.py b/mobilerun/agent/utils/oauth/openai_oauth_llm.py index f2159bdc..5556b33d 100644 --- a/mobilerun/agent/utils/oauth/openai_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/openai_oauth_llm.py @@ -149,6 +149,67 @@ def _tls_preflight(issuer: str, timeout: float = 5.0) -> None: print(f"Warning: TLS preflight check encountered an error: {exc}") +class _DeviceCodeNotSupported(Exception): + pass + + +def _request_device_code( + issuer: str, + client_id: str, + http_client: Optional[httpx.Client] = None, + request_timeout: float = 15.0, +) -> dict: + url = f"{issuer.rstrip('/')}/api/accounts/deviceauth/usercode" + post = http_client.post if http_client is not None else httpx.post + response = post( + url, + headers={"Content-Type": "application/json"}, + json={"client_id": client_id}, + timeout=request_timeout, + ) + if response.status_code == 404: + raise _DeviceCodeNotSupported( + "Device code login is not enabled for this server." + ) + response.raise_for_status() + return response.json() + + +_DEVICE_CODE_TIMEOUT = 15 * 60 + + +def _poll_device_code( + issuer: str, + device_auth_id: str, + user_code: str, + interval: int, + http_client: Optional[httpx.Client] = None, + request_timeout: float = 15.0, +) -> dict: + url = f"{issuer.rstrip('/')}/api/accounts/deviceauth/token" + deadline = time.time() + _DEVICE_CODE_TIMEOUT + post = http_client.post if http_client is not None else httpx.post + + while time.time() < deadline: + response = post( + url, + headers={"Content-Type": "application/json"}, + json={"device_auth_id": device_auth_id, "user_code": user_code}, + timeout=request_timeout, + ) + if response.status_code == 200: + return response.json() + if response.status_code in (403, 404): + remaining = deadline - time.time() + if remaining <= 0: + break + time.sleep(min(interval, remaining)) + continue + response.raise_for_status() + + raise TimeoutError("Device code login timed out (15 minutes).") + + @dataclass class OpenAIOAuthCredentials: access_token: str @@ -571,9 +632,24 @@ def login( ) -> OpenAIOAuthCredentials: _tls_preflight(self._oauth_manager.issuer) + # Headless environments: use device code flow (no local server needed) + use_device_code = _is_headless_environment() or os.environ.get( + "DROIDRUN_OAUTH_MANUAL", "" + ).lower() in ("1", "true", "yes") + if use_device_code: + try: + return self.login_device_code() + except _DeviceCodeNotSupported: + return self.login_manual( + open_browser=False, + callback_port=callback_port, + callback_path=callback_path, + redirect_host=redirect_host, + scope=scope, + ) + + # Desktop: browser callback server result: Dict[str, Optional[str]] = {"code": None, "state": None, "error": None} - manual_code: Dict[str, Optional[str]] = {"code": None} - manual_failed = threading.Event() done = threading.Event() code_verifier, code_challenge = _pkce_pair() state = _b64_no_pad(secrets.token_bytes(32)) @@ -642,72 +718,18 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A003 if open_browser: webbrowser.open(auth_url) - # Only run the manual-paste race when we can't rely on the local - # browser callback: headless envs (SSH/WSL/no-display), or when the - # user explicitly opts in with DROIDRUN_OAUTH_MANUAL=1. On a normal - # desktop the server always wins anyway, and a blocked input() - # thread would intercept InquirerPy's terminal queries and lag the - # configure wizard. - enable_manual = _is_headless_environment() or os.environ.get( - "DROIDRUN_OAUTH_MANUAL", "" - ).lower() in ("1", "true", "yes") - if enable_manual: - def _read_manual() -> None: - for attempt in range(2): - if done.is_set(): - return - try: - raw = str(input("Or paste the redirect URL / authorization code: ")) - except Exception: - return - if done.is_set(): - return - if not raw.strip(): - if attempt == 0: - print("Invalid paste. Try again.") - continue - if not done.is_set(): - manual_failed.set() - done.set() - return - try: - code = _normalize_manual_code(raw, state) - except Exception: # noqa: BLE001 - if attempt == 0: - print("Invalid paste. Try again.") - continue - print("Invalid paste.") - if not done.is_set(): - manual_failed.set() - done.set() - return - if code: - manual_code["code"] = code - done.set() - return - - manual_thread = threading.Thread(target=_read_manual, daemon=True) - manual_thread.start() - if not done.wait(timeout=timeout_seconds): raise TimeoutError("OAuth login timed out before callback was received.") - if manual_failed.is_set(): - raise RuntimeError("Login failed.") - - if manual_code["code"]: - code_to_exchange = manual_code["code"] - else: - if result["error"]: - raise RuntimeError(f"OAuth callback returned error: {result['error']}") - if result["state"] != state: - raise RuntimeError("OAuth callback state mismatch.") - if not result["code"]: - raise RuntimeError("OAuth callback did not include an authorization code.") - code_to_exchange = result["code"] + if result["error"]: + raise RuntimeError(f"OAuth callback returned error: {result['error']}") + if result["state"] != state: + raise RuntimeError("OAuth callback state mismatch.") + if not result["code"]: + raise RuntimeError("OAuth callback did not include an authorization code.") creds = self._oauth_manager.exchange_authorization_code( - code=code_to_exchange, + code=result["code"], redirect_uri=redirect_uri, code_verifier=code_verifier, ) @@ -718,6 +740,58 @@ def _read_manual() -> None: httpd.shutdown() httpd.server_close() + def login_device_code(self) -> OpenAIOAuthCredentials: + """Device Code login for headless/SSH environments. + + Uses the OAuth 2.0 Device Authorization Grant (RFC 8628). The user + opens a URL on any browser (phone, laptop, etc.) and enters a short + one-time code. The CLI polls until the auth completes — no redirect + back to localhost needed. + + Raises _DeviceCodeNotSupported if the server returns 404. + """ + mgr = self._oauth_manager + http_client = mgr.http_client + + device_resp = _request_device_code( + mgr.issuer, mgr.client_id, + http_client=http_client, + request_timeout=mgr.request_timeout, + ) + device_auth_id = device_resp["device_auth_id"] + user_code = device_resp.get("user_code") or device_resp.get("usercode", "") + try: + interval = int(str(device_resp.get("interval", "5")).strip()) + except (TypeError, ValueError): + interval = 5 + verification_url = f"{mgr.issuer}/codex/device" + + print( + f"\nSign in with your ChatGPT account:\n" + f"\n1. Open this link in your browser:\n {verification_url}\n" + f"\n2. Enter this code (expires in 15 minutes):\n {user_code}\n" + f"\nDevice codes are a common phishing target. Never share this code.\n" + ) + + token_resp = _poll_device_code( + mgr.issuer, + device_auth_id, + user_code, + interval, + http_client=http_client, + request_timeout=mgr.request_timeout, + ) + + redirect_uri = f"{mgr.issuer}/deviceauth/callback" + creds = self._oauth_manager.exchange_authorization_code( + code=token_resp["authorization_code"], + redirect_uri=redirect_uri, + code_verifier=token_resp["code_verifier"], + ) + if creds.account_id: + object.__setattr__(self, "_oauth_account_id", creds.account_id) + return creds + def login_manual( self, *, @@ -728,12 +802,7 @@ def login_manual( redirect_host: str = DEFAULT_OPENAI_OAUTH_CALLBACK_HOST, scope: str = DEFAULT_OPENAI_OAUTH_SCOPE, ) -> OpenAIOAuthCredentials: - """Manual OAuth flow for headless/VPS/WSL environments. - - Uses the same redirect_uri as the browser flow (OpenAI requires - port 1455). The browser will fail to load the redirect page, but - the URL bar will contain the authorization code. - """ + """Manual OAuth flow — last resort fallback when device code is unavailable.""" code_verifier, code_challenge = _pkce_pair() state = _b64_no_pad(secrets.token_bytes(32)) redirect_uri = f"http://{redirect_host}:{callback_port}{callback_path}" From bd482e70cbc768038586f1aa306dfe9abc444bba Mon Sep 17 00:00:00 2001 From: Mariozada Date: Tue, 5 May 2026 22:50:33 +1000 Subject: [PATCH 02/14] fix: use Google authcode page for Gemini headless OAuth --- .../oauth/gemini_oauth_code_assist_llm.py | 122 +++++++----------- 1 file changed, 44 insertions(+), 78 deletions(-) diff --git a/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py b/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py index a4ec51f3..99071f9a 100644 --- a/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py +++ b/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py @@ -518,9 +518,19 @@ def login( callback_path: str = "/oauth2callback", prompt_consent: bool = True, ) -> str: + # Headless environments: use authcode redirect flow (no local server) + use_authcode = _is_headless_environment() or os.environ.get( + "DROIDRUN_OAUTH_MANUAL", "" + ).lower() in ("1", "true", "yes") + if use_authcode: + return self.login_headless( + open_browser=open_browser, + timeout_seconds=timeout_seconds, + prompt_consent=prompt_consent, + ) + + # Desktop: browser callback server result: Dict[str, Optional[str]] = {"code": None, "state": None, "error": None} - manual_code: Dict[str, Optional[str]] = {"code": None} - manual_failed = threading.Event() done = threading.Event() expected_state = secrets.token_hex(32) code_verifier, code_challenge = _pkce_pair() @@ -562,8 +572,10 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A003 f"Could not bind callback server on {callback_host}:{callback_port} ({exc}). " "Falling back to manual code entry." ) - return self.login_manual( - open_browser=open_browser, prompt_consent=prompt_consent + return self.login_headless( + open_browser=open_browser, + timeout_seconds=timeout_seconds, + prompt_consent=prompt_consent, ) actual_port = httpd.server_address[1] @@ -583,94 +595,39 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A003 if open_browser: webbrowser.open(auth_url) - # Only run the manual-paste race when we can't rely on the local - # browser callback: headless envs (SSH/WSL/no-display), or when the - # user explicitly opts in with DROIDRUN_OAUTH_MANUAL=1. On a normal - # desktop the server always wins anyway, and a blocked input() - # thread would intercept InquirerPy's terminal queries and lag the - # configure wizard. - enable_manual = _is_headless_environment() or os.environ.get( - "DROIDRUN_OAUTH_MANUAL", "" - ).lower() in ("1", "true", "yes") - if enable_manual: - def _read_manual() -> None: - for attempt in range(2): - if done.is_set(): - return - try: - raw = str(input("Or paste the redirect URL / authorization code: ")) - except Exception: - return - if done.is_set(): - return - if not raw.strip(): - if attempt == 0: - print("Invalid paste. Try again.") - continue - if not done.is_set(): - manual_failed.set() - done.set() - return - try: - code = _normalize_manual_code(raw, expected_state) - except Exception: # noqa: BLE001 - if attempt == 0: - print("Invalid paste. Try again.") - continue - print("Invalid paste.") - if not done.is_set(): - manual_failed.set() - done.set() - return - if code: - manual_code["code"] = code - done.set() - return - - manual_thread = threading.Thread(target=_read_manual, daemon=True) - manual_thread.start() - if not done.wait(timeout=timeout_seconds): raise TimeoutError("OAuth login timed out before callback was received.") - if manual_failed.is_set(): - raise RuntimeError("Login failed.") - - if manual_code["code"]: - code_to_exchange = manual_code["code"] - else: - if result["error"]: - raise RuntimeError(f"OAuth callback returned error: {result['error']}") - if result["state"] != expected_state: - raise RuntimeError("OAuth callback state mismatch.") - if not result["code"]: - raise RuntimeError("OAuth callback did not include an authorization code.") - code_to_exchange = result["code"] + if result["error"]: + raise RuntimeError(f"OAuth callback returned error: {result['error']}") + if result["state"] != expected_state: + raise RuntimeError("OAuth callback state mismatch.") + if not result["code"]: + raise RuntimeError("OAuth callback did not include an authorization code.") return self._exchange_authorization_code( - code_to_exchange, redirect_uri, code_verifier=code_verifier + result["code"], redirect_uri, code_verifier=code_verifier ) finally: httpd.shutdown() httpd.server_close() - def login_manual( + def login_headless( self, *, - open_browser: bool = True, + open_browser: bool = False, + timeout_seconds: float = 300.0, input_fn: Any = input, prompt_consent: bool = True, ) -> str: - """Manual OAuth flow for headless/VPS/WSL environments. + """Headless OAuth flow for SSH/WSL environments. - Opens (or prints) the auth URL and prompts the user to paste the - redirected URL or bare authorization code from the browser. + Redirects to Google's authcode page which displays the authorization + code on screen. The user copies that short code back to the terminal. """ code_verifier, code_challenge = _pkce_pair() expected_state = secrets.token_hex(32) - # Google allows any loopback redirect for installed apps. The browser - # will fail to load the page, but the URL bar will contain the code. - redirect_uri = "http://localhost/oauth2callback" + redirect_uri = "https://codeassist.google.com/authcode" auth_url = self._build_auth_url( redirect_uri=redirect_uri, @@ -679,22 +636,31 @@ def login_manual( code_challenge=code_challenge, ) - print(f"Open this URL to login:\n{auth_url}") + print( + f"\nSign in with your Google account:\n" + f"\n1. Open this link in your browser:\n {auth_url}\n" + f"\n2. Complete sign-in, then paste the authorization code shown on the page.\n" + ) if open_browser: webbrowser.open(auth_url) + deadline = time.time() + timeout_seconds + for attempt in range(2): - raw = str(input_fn("Paste the redirect URL or authorization code: ")) + remaining = deadline - time.time() + if remaining <= 0: + raise TimeoutError("OAuth login timed out.") + raw = str(input_fn("Enter the authorization code: ")) if not raw.strip(): if attempt == 0: - print("Invalid paste. Try again.") + print("No code entered. Try again.") continue raise RuntimeError("Login failed.") try: code = _normalize_manual_code(raw, expected_state) except Exception: # noqa: BLE001 if attempt == 0: - print("Invalid paste. Try again.") + print("Invalid code. Try again.") continue raise RuntimeError("Login failed.") if code: @@ -702,7 +668,7 @@ def login_manual( code, redirect_uri, code_verifier=code_verifier ) if attempt == 0: - print("Invalid paste. Try again.") + print("Invalid code. Try again.") continue raise RuntimeError("Login failed.") raise RuntimeError("Login failed.") From c2b6686957db672b563b719794cd7992496f2255 Mon Sep 17 00:00:00 2001 From: Mariozada Date: Tue, 5 May 2026 23:13:40 +1000 Subject: [PATCH 03/14] fix: use hosted callback pages for Anthropic and Gemini headless OAuth --- .../agent/utils/oauth/anthropic_oauth_llm.py | 134 ++++++++---------- .../oauth/gemini_oauth_code_assist_llm.py | 18 ++- 2 files changed, 79 insertions(+), 73 deletions(-) diff --git a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py index ce723f07..fa08a109 100644 --- a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py @@ -411,9 +411,19 @@ def login( callback_path: str = "/callback", expires_in: Optional[int] = None, ) -> str: + # Headless environments: skip local server, use hosted callback page + use_headless = _is_headless_environment() or os.environ.get( + "DROIDRUN_OAUTH_MANUAL", "" + ).lower() in ("1", "true", "yes") + if use_headless: + return self.login_headless( + open_browser=open_browser, + timeout_seconds=timeout_seconds, + expires_in=expires_in, + ) + + # Desktop: browser callback server result: Dict[str, Optional[str]] = {"code": None, "state": None, "error": None} - manual_code: Dict[str, Optional[str]] = {"code": None} - manual_failed = threading.Event() done = threading.Event() code_verifier, code_challenge = _pkce_pair() @@ -461,8 +471,10 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A003 f"Could not bind callback server on {callback_host}:{callback_port} ({exc}). " "Falling back to manual code entry." ) - return self.login_manual( - open_browser=open_browser, expires_in=expires_in + return self.login_headless( + open_browser=open_browser, + timeout_seconds=timeout_seconds, + expires_in=expires_in, ) actual_port = httpd.server_address[1] @@ -481,72 +493,18 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A003 if open_browser: webbrowser.open(auth_url) - # Only run the manual-paste race when we can't rely on the local - # browser callback: headless envs (SSH/WSL/no-display), or when the - # user explicitly opts in with DROIDRUN_OAUTH_MANUAL=1. On a normal - # desktop the server always wins anyway, and a blocked input() - # thread would intercept InquirerPy's terminal queries and lag the - # configure wizard. - enable_manual = _is_headless_environment() or os.environ.get( - "DROIDRUN_OAUTH_MANUAL", "" - ).lower() in ("1", "true", "yes") - if enable_manual: - def _read_manual() -> None: - for attempt in range(2): - if done.is_set(): - return - try: - raw = str(input("Or paste the redirect URL / authorization code: ")) - except Exception: - return - if done.is_set(): - return - if not raw.strip(): - if attempt == 0: - print("Invalid paste. Try again.") - continue - if not done.is_set(): - manual_failed.set() - done.set() - return - try: - code = _normalize_manual_code(raw, state) - except Exception: # noqa: BLE001 - if attempt == 0: - print("Invalid paste. Try again.") - continue - print("Invalid paste.") - if not done.is_set(): - manual_failed.set() - done.set() - return - if code: - manual_code["code"] = code - done.set() - return - - manual_thread = threading.Thread(target=_read_manual, daemon=True) - manual_thread.start() - if not done.wait(timeout=timeout_seconds): raise TimeoutError("OAuth login timed out before callback was received.") - if manual_failed.is_set(): - raise RuntimeError("Login failed.") - - if manual_code["code"]: - code_to_exchange = manual_code["code"] - else: - if result["error"]: - raise RuntimeError(f"OAuth callback returned error: {result['error']}") - if result["state"] != state: - raise RuntimeError("OAuth callback state mismatch.") - if not result["code"]: - raise RuntimeError("OAuth callback did not include an authorization code.") - code_to_exchange = result["code"] + if result["error"]: + raise RuntimeError(f"OAuth callback returned error: {result['error']}") + if result["state"] != state: + raise RuntimeError("OAuth callback state mismatch.") + if not result["code"]: + raise RuntimeError("OAuth callback did not include an authorization code.") return self._exchange_authorization_code( - code=code_to_exchange, + code=result["code"], redirect_uri=redirect_uri, code_verifier=code_verifier, state=state, @@ -557,13 +515,19 @@ def _read_manual() -> None: httpd.shutdown() httpd.server_close() - def login_manual( + def login_headless( self, *, - open_browser: bool = True, + open_browser: bool = False, + timeout_seconds: float = 300.0, input_fn: Any = input, expires_in: Optional[int] = None, ) -> str: + """Headless OAuth flow for SSH/WSL environments. + + Redirects to Anthropic's hosted callback page which displays the + authorization code on screen. + """ code_verifier, code_challenge = _pkce_pair() state = _b64_no_pad(secrets.token_bytes(32)) redirect_uri = "https://platform.claude.com/oauth/code/callback" @@ -579,21 +543,47 @@ def login_manual( ) try: - print(f"Open this URL to login:\n{auth_url}") + print( + f"\nSign in with your Anthropic account:\n" + f"\n1. Open this link in your browser:\n {auth_url}\n" + f"\n2. Complete sign-in, then paste the authorization code shown on the page.\n" + ) if open_browser: webbrowser.open(auth_url) + + deadline = time.time() + timeout_seconds + for attempt in range(2): - raw = str(input_fn("Paste the redirect URL or authorization code: ")) + remaining = deadline - time.time() + if remaining <= 0: + raise TimeoutError("OAuth login timed out.") + + read_result: Dict[str, Optional[str]] = {"value": None} + read_done = threading.Event() + + def _reader() -> None: + try: + read_result["value"] = str(input_fn("Enter the authorization code: ")) + except (EOFError, OSError): + pass + read_done.set() + + threading.Thread(target=_reader, daemon=True).start() + + if not read_done.wait(timeout=remaining): + raise TimeoutError("OAuth login timed out.") + + raw = read_result["value"] or "" if not raw.strip(): if attempt == 0: - print("Invalid paste. Try again.") + print("No code entered. Try again.") continue raise RuntimeError("Login failed.") try: code = _normalize_manual_code(raw, state) except Exception: # noqa: BLE001 if attempt == 0: - print("Invalid paste. Try again.") + print("Invalid code. Try again.") continue raise RuntimeError("Login failed.") if code: @@ -605,7 +595,7 @@ def login_manual( expires_in=expires_in, ) if attempt == 0: - print("Invalid paste. Try again.") + print("Invalid code. Try again.") continue raise RuntimeError("Login failed.") raise RuntimeError("Login failed.") diff --git a/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py b/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py index 99071f9a..342825f9 100644 --- a/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py +++ b/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py @@ -650,7 +650,23 @@ def login_headless( remaining = deadline - time.time() if remaining <= 0: raise TimeoutError("OAuth login timed out.") - raw = str(input_fn("Enter the authorization code: ")) + + read_result: Dict[str, Optional[str]] = {"value": None} + read_done = threading.Event() + + def _reader() -> None: + try: + read_result["value"] = str(input_fn("Enter the authorization code: ")) + except (EOFError, OSError): + pass + read_done.set() + + threading.Thread(target=_reader, daemon=True).start() + + if not read_done.wait(timeout=remaining): + raise TimeoutError("OAuth login timed out.") + + raw = read_result["value"] or "" if not raw.strip(): if attempt == 0: print("No code entered. Try again.") From 6af4a41a515890215eae93306d4800dd50c27056 Mon Sep 17 00:00:00 2001 From: Mariozada Date: Tue, 5 May 2026 23:23:54 +1000 Subject: [PATCH 04/14] fix: honor caller timeout in OpenAI device code flow --- mobilerun/agent/utils/oauth/openai_oauth_llm.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/mobilerun/agent/utils/oauth/openai_oauth_llm.py b/mobilerun/agent/utils/oauth/openai_oauth_llm.py index 5556b33d..140ae147 100644 --- a/mobilerun/agent/utils/oauth/openai_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/openai_oauth_llm.py @@ -185,9 +185,10 @@ def _poll_device_code( interval: int, http_client: Optional[httpx.Client] = None, request_timeout: float = 15.0, + timeout_seconds: float = _DEVICE_CODE_TIMEOUT, ) -> dict: url = f"{issuer.rstrip('/')}/api/accounts/deviceauth/token" - deadline = time.time() + _DEVICE_CODE_TIMEOUT + deadline = time.time() + min(timeout_seconds, _DEVICE_CODE_TIMEOUT) post = http_client.post if http_client is not None else httpx.post while time.time() < deadline: @@ -638,7 +639,7 @@ def login( ).lower() in ("1", "true", "yes") if use_device_code: try: - return self.login_device_code() + return self.login_device_code(timeout_seconds=timeout_seconds) except _DeviceCodeNotSupported: return self.login_manual( open_browser=False, @@ -740,7 +741,11 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A003 httpd.shutdown() httpd.server_close() - def login_device_code(self) -> OpenAIOAuthCredentials: + def login_device_code( + self, + *, + timeout_seconds: float = _DEVICE_CODE_TIMEOUT, + ) -> OpenAIOAuthCredentials: """Device Code login for headless/SSH environments. Uses the OAuth 2.0 Device Authorization Grant (RFC 8628). The user @@ -780,6 +785,7 @@ def login_device_code(self) -> OpenAIOAuthCredentials: interval, http_client=http_client, request_timeout=mgr.request_timeout, + timeout_seconds=timeout_seconds, ) redirect_uri = f"{mgr.issuer}/deviceauth/callback" From 4e27cfb95e3d18ee7d3c9fd3f5d8af9a22a2a3c7 Mon Sep 17 00:00:00 2001 From: Mariozada Date: Tue, 5 May 2026 23:37:56 +1000 Subject: [PATCH 05/14] fix: enforce timeout and fix stdin handling in headless OAuth --- .../agent/utils/oauth/anthropic_oauth_llm.py | 32 +++++++------ .../oauth/gemini_oauth_code_assist_llm.py | 32 +++++++------ .../agent/utils/oauth/openai_oauth_llm.py | 48 +++++++++++++++---- 3 files changed, 76 insertions(+), 36 deletions(-) diff --git a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py index fa08a109..bd3175dc 100644 --- a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py @@ -551,29 +551,33 @@ def login_headless( if open_browser: webbrowser.open(auth_url) + import queue as _queue + deadline = time.time() + timeout_seconds + input_queue: _queue.Queue[Optional[str]] = _queue.Queue() + + def _reader() -> None: + while True: + try: + input_queue.put(str(input_fn("Enter the authorization code: "))) + except (EOFError, OSError): + input_queue.put(None) + return + + threading.Thread(target=_reader, daemon=True).start() for attempt in range(2): remaining = deadline - time.time() if remaining <= 0: raise TimeoutError("OAuth login timed out.") - read_result: Dict[str, Optional[str]] = {"value": None} - read_done = threading.Event() - - def _reader() -> None: - try: - read_result["value"] = str(input_fn("Enter the authorization code: ")) - except (EOFError, OSError): - pass - read_done.set() - - threading.Thread(target=_reader, daemon=True).start() - - if not read_done.wait(timeout=remaining): + try: + raw = input_queue.get(timeout=remaining) + except _queue.Empty: raise TimeoutError("OAuth login timed out.") - raw = read_result["value"] or "" + if raw is None: + raise RuntimeError("Login failed — stdin closed.") if not raw.strip(): if attempt == 0: print("No code entered. Try again.") diff --git a/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py b/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py index 342825f9..a3a3a03d 100644 --- a/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py +++ b/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py @@ -644,29 +644,33 @@ def login_headless( if open_browser: webbrowser.open(auth_url) + import queue as _queue + deadline = time.time() + timeout_seconds + input_queue: _queue.Queue[Optional[str]] = _queue.Queue() + + def _reader() -> None: + while True: + try: + input_queue.put(str(input_fn("Enter the authorization code: "))) + except (EOFError, OSError): + input_queue.put(None) + return + + threading.Thread(target=_reader, daemon=True).start() for attempt in range(2): remaining = deadline - time.time() if remaining <= 0: raise TimeoutError("OAuth login timed out.") - read_result: Dict[str, Optional[str]] = {"value": None} - read_done = threading.Event() - - def _reader() -> None: - try: - read_result["value"] = str(input_fn("Enter the authorization code: ")) - except (EOFError, OSError): - pass - read_done.set() - - threading.Thread(target=_reader, daemon=True).start() - - if not read_done.wait(timeout=remaining): + try: + raw = input_queue.get(timeout=remaining) + except _queue.Empty: raise TimeoutError("OAuth login timed out.") - raw = read_result["value"] or "" + if raw is None: + raise RuntimeError("Login failed — stdin closed.") if not raw.strip(): if attempt == 0: print("No code entered. Try again.") diff --git a/mobilerun/agent/utils/oauth/openai_oauth_llm.py b/mobilerun/agent/utils/oauth/openai_oauth_llm.py index 140ae147..73d24158 100644 --- a/mobilerun/agent/utils/oauth/openai_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/openai_oauth_llm.py @@ -153,6 +153,9 @@ class _DeviceCodeNotSupported(Exception): pass +_DEVICE_CODE_TIMEOUT = 15 * 60 + + def _request_device_code( issuer: str, client_id: str, @@ -175,9 +178,6 @@ def _request_device_code( return response.json() -_DEVICE_CODE_TIMEOUT = 15 * 60 - - def _poll_device_code( issuer: str, device_auth_id: str, @@ -188,7 +188,8 @@ def _poll_device_code( timeout_seconds: float = _DEVICE_CODE_TIMEOUT, ) -> dict: url = f"{issuer.rstrip('/')}/api/accounts/deviceauth/token" - deadline = time.time() + min(timeout_seconds, _DEVICE_CODE_TIMEOUT) + effective_timeout = min(timeout_seconds, _DEVICE_CODE_TIMEOUT) + deadline = time.time() + effective_timeout post = http_client.post if http_client is not None else httpx.post while time.time() < deadline: @@ -200,6 +201,7 @@ def _poll_device_code( ) if response.status_code == 200: return response.json() + if response.status_code in (403, 404): remaining = deadline - time.time() if remaining <= 0: @@ -208,7 +210,8 @@ def _poll_device_code( continue response.raise_for_status() - raise TimeoutError("Device code login timed out (15 minutes).") + minutes = int(effective_timeout // 60) + raise TimeoutError(f"Device code login timed out ({minutes} minutes).") @dataclass @@ -642,7 +645,8 @@ def login( return self.login_device_code(timeout_seconds=timeout_seconds) except _DeviceCodeNotSupported: return self.login_manual( - open_browser=False, + open_browser=open_browser, + timeout_seconds=timeout_seconds, callback_port=callback_port, callback_path=callback_path, redirect_host=redirect_host, @@ -764,12 +768,18 @@ def login_device_code( request_timeout=mgr.request_timeout, ) device_auth_id = device_resp["device_auth_id"] + user_code = device_resp.get("user_code") or device_resp.get("usercode", "") try: interval = int(str(device_resp.get("interval", "5")).strip()) except (TypeError, ValueError): interval = 5 - verification_url = f"{mgr.issuer}/codex/device" + # Prefer server-provided URL if present, fall back to issuer default. + verification_url = ( + device_resp.get("verification_uri") + or device_resp.get("verification_url") + or f"{mgr.issuer}/codex/device" + ) print( f"\nSign in with your ChatGPT account:\n" @@ -802,6 +812,7 @@ def login_manual( self, *, open_browser: bool = True, + timeout_seconds: float = 300.0, input_fn: Any = input, callback_port: int = DEFAULT_OPENAI_OAUTH_CALLBACK_PORT, callback_path: str = DEFAULT_OPENAI_OAUTH_CALLBACK_PATH, @@ -825,8 +836,29 @@ def login_manual( if open_browser: webbrowser.open(auth_url) + deadline = time.time() + timeout_seconds + for attempt in range(2): - raw = str(input_fn("Paste the redirect URL or authorization code: ")) + remaining = deadline - time.time() + if remaining <= 0: + raise TimeoutError("OAuth login timed out.") + + read_result: Dict[str, Optional[str]] = {"value": None} + read_done = threading.Event() + + def _reader() -> None: + try: + read_result["value"] = str(input_fn("Paste the redirect URL or authorization code: ")) + except (EOFError, OSError): + pass + read_done.set() + + threading.Thread(target=_reader, daemon=True).start() + + if not read_done.wait(timeout=remaining): + raise TimeoutError("OAuth login timed out.") + + raw = read_result["value"] or "" if not raw.strip(): if attempt == 0: print("Invalid paste. Try again.") From 5faa31babf6e54beea439e71a4a396fb6232696a Mon Sep 17 00:00:00 2001 From: Mariozada Date: Tue, 5 May 2026 23:47:32 +1000 Subject: [PATCH 06/14] fix: stop stdin reader after login and pass timeout through all fallbacks --- .../agent/utils/oauth/anthropic_oauth_llm.py | 70 ++++++++------- .../oauth/gemini_oauth_code_assist_llm.py | 62 ++++++++------ .../agent/utils/oauth/openai_oauth_llm.py | 85 +++++++++++-------- 3 files changed, 120 insertions(+), 97 deletions(-) diff --git a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py index bd3175dc..2f1173c5 100644 --- a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py @@ -555,9 +555,12 @@ def login_headless( deadline = time.time() + timeout_seconds input_queue: _queue.Queue[Optional[str]] = _queue.Queue() + stop = threading.Event() def _reader() -> None: - while True: + for _ in range(2): + if stop.is_set(): + return try: input_queue.put(str(input_fn("Enter the authorization code: "))) except (EOFError, OSError): @@ -566,43 +569,46 @@ def _reader() -> None: threading.Thread(target=_reader, daemon=True).start() - for attempt in range(2): - remaining = deadline - time.time() - if remaining <= 0: - raise TimeoutError("OAuth login timed out.") - - try: - raw = input_queue.get(timeout=remaining) - except _queue.Empty: - raise TimeoutError("OAuth login timed out.") + try: + for attempt in range(2): + remaining = deadline - time.time() + if remaining <= 0: + raise TimeoutError("OAuth login timed out.") - if raw is None: - raise RuntimeError("Login failed — stdin closed.") - if not raw.strip(): - if attempt == 0: - print("No code entered. Try again.") - continue - raise RuntimeError("Login failed.") - try: - code = _normalize_manual_code(raw, state) - except Exception: # noqa: BLE001 + try: + raw = input_queue.get(timeout=remaining) + except _queue.Empty: + raise TimeoutError("OAuth login timed out.") + + if raw is None: + raise RuntimeError("Login failed — stdin closed.") + if not raw.strip(): + if attempt == 0: + print("No code entered. Try again.") + continue + raise RuntimeError("Login failed.") + try: + code = _normalize_manual_code(raw, state) + except Exception: # noqa: BLE001 + if attempt == 0: + print("Invalid code. Try again.") + continue + raise RuntimeError("Login failed.") + if code: + return self._exchange_authorization_code( + code=code, + redirect_uri=redirect_uri, + code_verifier=code_verifier, + state=state, + expires_in=expires_in, + ) if attempt == 0: print("Invalid code. Try again.") continue raise RuntimeError("Login failed.") - if code: - return self._exchange_authorization_code( - code=code, - redirect_uri=redirect_uri, - code_verifier=code_verifier, - state=state, - expires_in=expires_in, - ) - if attempt == 0: - print("Invalid code. Try again.") - continue raise RuntimeError("Login failed.") - raise RuntimeError("Login failed.") + finally: + stop.set() finally: self.authorize_url = original_authorize_url diff --git a/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py b/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py index a3a3a03d..e9827cbc 100644 --- a/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py +++ b/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py @@ -648,9 +648,12 @@ def login_headless( deadline = time.time() + timeout_seconds input_queue: _queue.Queue[Optional[str]] = _queue.Queue() + stop = threading.Event() def _reader() -> None: - while True: + for _ in range(2): + if stop.is_set(): + return try: input_queue.put(str(input_fn("Enter the authorization code: "))) except (EOFError, OSError): @@ -659,39 +662,42 @@ def _reader() -> None: threading.Thread(target=_reader, daemon=True).start() - for attempt in range(2): - remaining = deadline - time.time() - if remaining <= 0: - raise TimeoutError("OAuth login timed out.") - - try: - raw = input_queue.get(timeout=remaining) - except _queue.Empty: - raise TimeoutError("OAuth login timed out.") + try: + for attempt in range(2): + remaining = deadline - time.time() + if remaining <= 0: + raise TimeoutError("OAuth login timed out.") - if raw is None: - raise RuntimeError("Login failed — stdin closed.") - if not raw.strip(): - if attempt == 0: - print("No code entered. Try again.") - continue - raise RuntimeError("Login failed.") - try: - code = _normalize_manual_code(raw, expected_state) - except Exception: # noqa: BLE001 + try: + raw = input_queue.get(timeout=remaining) + except _queue.Empty: + raise TimeoutError("OAuth login timed out.") + + if raw is None: + raise RuntimeError("Login failed — stdin closed.") + if not raw.strip(): + if attempt == 0: + print("No code entered. Try again.") + continue + raise RuntimeError("Login failed.") + try: + code = _normalize_manual_code(raw, expected_state) + except Exception: # noqa: BLE001 + if attempt == 0: + print("Invalid code. Try again.") + continue + raise RuntimeError("Login failed.") + if code: + return self._exchange_authorization_code( + code, redirect_uri, code_verifier=code_verifier + ) if attempt == 0: print("Invalid code. Try again.") continue raise RuntimeError("Login failed.") - if code: - return self._exchange_authorization_code( - code, redirect_uri, code_verifier=code_verifier - ) - if attempt == 0: - print("Invalid code. Try again.") - continue raise RuntimeError("Login failed.") - raise RuntimeError("Login failed.") + finally: + stop.set() def _resolve_access_token(self) -> str: env_access_token = os.environ.get("GEMINI_OAUTH_ACCESS_TOKEN") diff --git a/mobilerun/agent/utils/oauth/openai_oauth_llm.py b/mobilerun/agent/utils/oauth/openai_oauth_llm.py index 73d24158..f61e87fd 100644 --- a/mobilerun/agent/utils/oauth/openai_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/openai_oauth_llm.py @@ -698,6 +698,7 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A003 ) return self.login_manual( open_browser=open_browser, + timeout_seconds=timeout_seconds, callback_port=callback_port, callback_path=callback_path, redirect_host=redirect_host, @@ -836,55 +837,65 @@ def login_manual( if open_browser: webbrowser.open(auth_url) - deadline = time.time() + timeout_seconds + import queue as _queue - for attempt in range(2): - remaining = deadline - time.time() - if remaining <= 0: - raise TimeoutError("OAuth login timed out.") - - read_result: Dict[str, Optional[str]] = {"value": None} - read_done = threading.Event() + deadline = time.time() + timeout_seconds + input_queue: _queue.Queue[Optional[str]] = _queue.Queue() + stop = threading.Event() - def _reader() -> None: + def _reader() -> None: + for _ in range(2): + if stop.is_set(): + return try: - read_result["value"] = str(input_fn("Paste the redirect URL or authorization code: ")) + input_queue.put(str(input_fn("Paste the redirect URL or authorization code: "))) except (EOFError, OSError): - pass - read_done.set() + input_queue.put(None) + return - threading.Thread(target=_reader, daemon=True).start() + threading.Thread(target=_reader, daemon=True).start() - if not read_done.wait(timeout=remaining): - raise TimeoutError("OAuth login timed out.") + try: + for attempt in range(2): + remaining = deadline - time.time() + if remaining <= 0: + raise TimeoutError("OAuth login timed out.") - raw = read_result["value"] or "" - if not raw.strip(): - if attempt == 0: - print("Invalid paste. Try again.") - continue - raise RuntimeError("Login failed.") - try: - code = _normalize_manual_code(raw, state) - except Exception: # noqa: BLE001 + try: + raw = input_queue.get(timeout=remaining) + except _queue.Empty: + raise TimeoutError("OAuth login timed out.") + + if raw is None: + raise RuntimeError("Login failed — stdin closed.") + if not raw.strip(): + if attempt == 0: + print("Invalid paste. Try again.") + continue + raise RuntimeError("Login failed.") + try: + code = _normalize_manual_code(raw, state) + except Exception: # noqa: BLE001 + if attempt == 0: + print("Invalid paste. Try again.") + continue + raise RuntimeError("Login failed.") + if code: + creds = self._oauth_manager.exchange_authorization_code( + code=code, + redirect_uri=redirect_uri, + code_verifier=code_verifier, + ) + if creds.account_id: + object.__setattr__(self, "_oauth_account_id", creds.account_id) + return creds if attempt == 0: print("Invalid paste. Try again.") continue raise RuntimeError("Login failed.") - if code: - creds = self._oauth_manager.exchange_authorization_code( - code=code, - redirect_uri=redirect_uri, - code_verifier=code_verifier, - ) - if creds.account_id: - object.__setattr__(self, "_oauth_account_id", creds.account_id) - return creds - if attempt == 0: - print("Invalid paste. Try again.") - continue raise RuntimeError("Login failed.") - raise RuntimeError("Login failed.") + finally: + stop.set() def _ensure_access_token(self) -> OpenAIOAuthCredentials: creds = self._oauth_manager.get_valid_credentials(skew_ms=self._oauth_refresh_skew_ms) From fcf97353636755da712efb15a44df47783c0ec70 Mon Sep 17 00:00:00 2001 From: Mariozada Date: Wed, 6 May 2026 00:00:38 +1000 Subject: [PATCH 07/14] fix: prevent stdin reader from racing ahead of main thread --- .../agent/utils/oauth/anthropic_oauth_llm.py | 11 +++++++++++ .../utils/oauth/gemini_oauth_code_assist_llm.py | 11 +++++++++++ mobilerun/agent/utils/oauth/openai_oauth_llm.py | 17 ++++++++++++++++- 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py index 2f1173c5..495b6f09 100644 --- a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py @@ -556,9 +556,12 @@ def login_headless( deadline = time.time() + timeout_seconds input_queue: _queue.Queue[Optional[str]] = _queue.Queue() stop = threading.Event() + need_more = threading.Event() + need_more.set() def _reader() -> None: for _ in range(2): + need_more.wait() if stop.is_set(): return try: @@ -580,11 +583,14 @@ def _reader() -> None: except _queue.Empty: raise TimeoutError("OAuth login timed out.") + need_more.clear() + if raw is None: raise RuntimeError("Login failed — stdin closed.") if not raw.strip(): if attempt == 0: print("No code entered. Try again.") + need_more.set() continue raise RuntimeError("Login failed.") try: @@ -592,6 +598,7 @@ def _reader() -> None: except Exception: # noqa: BLE001 if attempt == 0: print("Invalid code. Try again.") + need_more.set() continue raise RuntimeError("Login failed.") if code: @@ -604,14 +611,18 @@ def _reader() -> None: ) if attempt == 0: print("Invalid code. Try again.") + need_more.set() continue raise RuntimeError("Login failed.") raise RuntimeError("Login failed.") finally: stop.set() + need_more.set() finally: self.authorize_url = original_authorize_url + login_manual = login_headless + def _resolve_access_token(self) -> str: env_access_token = os.environ.get("ANTHROPIC_OAUTH_TOKEN") if env_access_token: diff --git a/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py b/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py index e9827cbc..31d95726 100644 --- a/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py +++ b/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py @@ -649,9 +649,12 @@ def login_headless( deadline = time.time() + timeout_seconds input_queue: _queue.Queue[Optional[str]] = _queue.Queue() stop = threading.Event() + need_more = threading.Event() + need_more.set() def _reader() -> None: for _ in range(2): + need_more.wait() if stop.is_set(): return try: @@ -673,11 +676,14 @@ def _reader() -> None: except _queue.Empty: raise TimeoutError("OAuth login timed out.") + need_more.clear() + if raw is None: raise RuntimeError("Login failed — stdin closed.") if not raw.strip(): if attempt == 0: print("No code entered. Try again.") + need_more.set() continue raise RuntimeError("Login failed.") try: @@ -685,6 +691,7 @@ def _reader() -> None: except Exception: # noqa: BLE001 if attempt == 0: print("Invalid code. Try again.") + need_more.set() continue raise RuntimeError("Login failed.") if code: @@ -693,11 +700,15 @@ def _reader() -> None: ) if attempt == 0: print("Invalid code. Try again.") + need_more.set() continue raise RuntimeError("Login failed.") raise RuntimeError("Login failed.") finally: stop.set() + need_more.set() + + login_manual = login_headless def _resolve_access_token(self) -> str: env_access_token = os.environ.get("GEMINI_OAUTH_ACCESS_TOKEN") diff --git a/mobilerun/agent/utils/oauth/openai_oauth_llm.py b/mobilerun/agent/utils/oauth/openai_oauth_llm.py index f61e87fd..fe4a5bf5 100644 --- a/mobilerun/agent/utils/oauth/openai_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/openai_oauth_llm.py @@ -201,7 +201,10 @@ def _poll_device_code( ) if response.status_code == 200: return response.json() - + # OpenAI returns 403/404 while the user hasn't completed browser auth. + # This differs from RFC 8628's 400 + authorization_pending body, but + # matches the observed behaviour of auth.openai.com/api/accounts/deviceauth/token. + # TODO: handle slow_down (RFC 8628 §3.5) by increasing interval. if response.status_code in (403, 404): remaining = deadline - time.time() if remaining <= 0: @@ -758,6 +761,9 @@ def login_device_code( one-time code. The CLI polls until the auth completes — no redirect back to localhost needed. + timeout_seconds is capped at _DEVICE_CODE_TIMEOUT (15 min) because + the server-issued code expires after that window. + Raises _DeviceCodeNotSupported if the server returns 404. """ mgr = self._oauth_manager @@ -842,9 +848,12 @@ def login_manual( deadline = time.time() + timeout_seconds input_queue: _queue.Queue[Optional[str]] = _queue.Queue() stop = threading.Event() + need_more = threading.Event() + need_more.set() def _reader() -> None: for _ in range(2): + need_more.wait() if stop.is_set(): return try: @@ -866,11 +875,14 @@ def _reader() -> None: except _queue.Empty: raise TimeoutError("OAuth login timed out.") + need_more.clear() + if raw is None: raise RuntimeError("Login failed — stdin closed.") if not raw.strip(): if attempt == 0: print("Invalid paste. Try again.") + need_more.set() continue raise RuntimeError("Login failed.") try: @@ -878,6 +890,7 @@ def _reader() -> None: except Exception: # noqa: BLE001 if attempt == 0: print("Invalid paste. Try again.") + need_more.set() continue raise RuntimeError("Login failed.") if code: @@ -891,11 +904,13 @@ def _reader() -> None: return creds if attempt == 0: print("Invalid paste. Try again.") + need_more.set() continue raise RuntimeError("Login failed.") raise RuntimeError("Login failed.") finally: stop.set() + need_more.set() def _ensure_access_token(self) -> OpenAIOAuthCredentials: creds = self._oauth_manager.get_valid_credentials(skew_ms=self._oauth_refresh_skew_ms) From 9a2265c6304986a2d4b13b997db194b1c4ec60d4 Mon Sep 17 00:00:00 2001 From: Mariozada Date: Wed, 6 May 2026 00:15:40 +1000 Subject: [PATCH 08/14] fix: close reader TOCTOU race, raise on missing verification URL, add aliases --- .../agent/utils/oauth/anthropic_oauth_llm.py | 19 ++++++------- .../oauth/gemini_oauth_code_assist_llm.py | 19 ++++++------- .../agent/utils/oauth/openai_oauth_llm.py | 28 +++++++++++-------- 3 files changed, 34 insertions(+), 32 deletions(-) diff --git a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py index 495b6f09..bad047c5 100644 --- a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py @@ -2,6 +2,7 @@ import hashlib import json import os +import queue import secrets import sys import threading @@ -551,17 +552,15 @@ def login_headless( if open_browser: webbrowser.open(auth_url) - import queue as _queue - deadline = time.time() + timeout_seconds - input_queue: _queue.Queue[Optional[str]] = _queue.Queue() + input_queue: queue.Queue[Optional[str]] = queue.Queue() stop = threading.Event() need_more = threading.Event() - need_more.set() def _reader() -> None: for _ in range(2): need_more.wait() + need_more.clear() if stop.is_set(): return try: @@ -578,19 +577,18 @@ def _reader() -> None: if remaining <= 0: raise TimeoutError("OAuth login timed out.") + need_more.set() + try: raw = input_queue.get(timeout=remaining) - except _queue.Empty: + except queue.Empty: raise TimeoutError("OAuth login timed out.") - need_more.clear() - if raw is None: raise RuntimeError("Login failed — stdin closed.") if not raw.strip(): if attempt == 0: print("No code entered. Try again.") - need_more.set() continue raise RuntimeError("Login failed.") try: @@ -598,7 +596,6 @@ def _reader() -> None: except Exception: # noqa: BLE001 if attempt == 0: print("Invalid code. Try again.") - need_more.set() continue raise RuntimeError("Login failed.") if code: @@ -611,11 +608,13 @@ def _reader() -> None: ) if attempt == 0: print("Invalid code. Try again.") - need_more.set() continue raise RuntimeError("Login failed.") raise RuntimeError("Login failed.") finally: + # stop.set() prevents the reader from starting a new input() call. + # It cannot interrupt an input() already in progress (Python limitation); + # the daemon thread dies with the process. stop.set() need_more.set() finally: diff --git a/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py b/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py index 31d95726..25ef7c02 100644 --- a/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py +++ b/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py @@ -2,6 +2,7 @@ import hashlib import json import os +import queue import secrets import sys import threading @@ -644,17 +645,15 @@ def login_headless( if open_browser: webbrowser.open(auth_url) - import queue as _queue - deadline = time.time() + timeout_seconds - input_queue: _queue.Queue[Optional[str]] = _queue.Queue() + input_queue: queue.Queue[Optional[str]] = queue.Queue() stop = threading.Event() need_more = threading.Event() - need_more.set() def _reader() -> None: for _ in range(2): need_more.wait() + need_more.clear() if stop.is_set(): return try: @@ -671,19 +670,18 @@ def _reader() -> None: if remaining <= 0: raise TimeoutError("OAuth login timed out.") + need_more.set() + try: raw = input_queue.get(timeout=remaining) - except _queue.Empty: + except queue.Empty: raise TimeoutError("OAuth login timed out.") - need_more.clear() - if raw is None: raise RuntimeError("Login failed — stdin closed.") if not raw.strip(): if attempt == 0: print("No code entered. Try again.") - need_more.set() continue raise RuntimeError("Login failed.") try: @@ -691,7 +689,6 @@ def _reader() -> None: except Exception: # noqa: BLE001 if attempt == 0: print("Invalid code. Try again.") - need_more.set() continue raise RuntimeError("Login failed.") if code: @@ -700,11 +697,13 @@ def _reader() -> None: ) if attempt == 0: print("Invalid code. Try again.") - need_more.set() continue raise RuntimeError("Login failed.") raise RuntimeError("Login failed.") finally: + # stop.set() prevents the reader from starting a new input() call. + # It cannot interrupt an input() already in progress (Python limitation); + # the daemon thread dies with the process. stop.set() need_more.set() diff --git a/mobilerun/agent/utils/oauth/openai_oauth_llm.py b/mobilerun/agent/utils/oauth/openai_oauth_llm.py index fe4a5bf5..b175e222 100644 --- a/mobilerun/agent/utils/oauth/openai_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/openai_oauth_llm.py @@ -18,6 +18,7 @@ import hashlib import json import os +import queue import secrets import sys import threading @@ -781,12 +782,15 @@ def login_device_code( interval = int(str(device_resp.get("interval", "5")).strip()) except (TypeError, ValueError): interval = 5 - # Prefer server-provided URL if present, fall back to issuer default. verification_url = ( device_resp.get("verification_uri") or device_resp.get("verification_url") - or f"{mgr.issuer}/codex/device" ) + if not verification_url: + raise RuntimeError( + "Device code response did not include a verification URL. " + "The server may not support this login flow." + ) print( f"\nSign in with your ChatGPT account:\n" @@ -815,6 +819,8 @@ def login_device_code( object.__setattr__(self, "_oauth_account_id", creds.account_id) return creds + login_headless = login_device_code + def login_manual( self, *, @@ -843,17 +849,15 @@ def login_manual( if open_browser: webbrowser.open(auth_url) - import queue as _queue - deadline = time.time() + timeout_seconds - input_queue: _queue.Queue[Optional[str]] = _queue.Queue() + input_queue: queue.Queue[Optional[str]] = queue.Queue() stop = threading.Event() need_more = threading.Event() - need_more.set() def _reader() -> None: for _ in range(2): need_more.wait() + need_more.clear() if stop.is_set(): return try: @@ -870,19 +874,18 @@ def _reader() -> None: if remaining <= 0: raise TimeoutError("OAuth login timed out.") + need_more.set() + try: raw = input_queue.get(timeout=remaining) - except _queue.Empty: + except queue.Empty: raise TimeoutError("OAuth login timed out.") - need_more.clear() - if raw is None: raise RuntimeError("Login failed — stdin closed.") if not raw.strip(): if attempt == 0: print("Invalid paste. Try again.") - need_more.set() continue raise RuntimeError("Login failed.") try: @@ -890,7 +893,6 @@ def _reader() -> None: except Exception: # noqa: BLE001 if attempt == 0: print("Invalid paste. Try again.") - need_more.set() continue raise RuntimeError("Login failed.") if code: @@ -904,11 +906,13 @@ def _reader() -> None: return creds if attempt == 0: print("Invalid paste. Try again.") - need_more.set() continue raise RuntimeError("Login failed.") raise RuntimeError("Login failed.") finally: + # stop.set() prevents the reader from starting a new input() call. + # It cannot interrupt an input() already in progress (Python limitation); + # the daemon thread dies with the process. stop.set() need_more.set() From 4746730def2a10f38a9a1f1e6454604bcb859c8b Mon Sep 17 00:00:00 2001 From: Mariozada Date: Wed, 6 May 2026 00:17:18 +1000 Subject: [PATCH 09/14] fix: restore verification URL fallback for custom issuers --- mobilerun/agent/utils/oauth/openai_oauth_llm.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/mobilerun/agent/utils/oauth/openai_oauth_llm.py b/mobilerun/agent/utils/oauth/openai_oauth_llm.py index b175e222..d1768719 100644 --- a/mobilerun/agent/utils/oauth/openai_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/openai_oauth_llm.py @@ -785,12 +785,8 @@ def login_device_code( verification_url = ( device_resp.get("verification_uri") or device_resp.get("verification_url") + or f"{mgr.issuer}/codex/device" ) - if not verification_url: - raise RuntimeError( - "Device code response did not include a verification URL. " - "The server may not support this login flow." - ) print( f"\nSign in with your ChatGPT account:\n" From 09d310942a333fe1a1535b98384be579a712d499 Mon Sep 17 00:00:00 2001 From: Mariozada Date: Wed, 6 May 2026 00:23:23 +1000 Subject: [PATCH 10/14] fix: fall back to manual login on any device-code failure --- mobilerun/agent/utils/oauth/openai_oauth_llm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobilerun/agent/utils/oauth/openai_oauth_llm.py b/mobilerun/agent/utils/oauth/openai_oauth_llm.py index d1768719..e736c2bd 100644 --- a/mobilerun/agent/utils/oauth/openai_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/openai_oauth_llm.py @@ -647,7 +647,7 @@ def login( if use_device_code: try: return self.login_device_code(timeout_seconds=timeout_seconds) - except _DeviceCodeNotSupported: + except (_DeviceCodeNotSupported, httpx.HTTPStatusError, httpx.ConnectError, RuntimeError): return self.login_manual( open_browser=open_browser, timeout_seconds=timeout_seconds, From 4352d85586c7b24332a9033cec723cf8bbe2d725 Mon Sep 17 00:00:00 2001 From: Mariozada Date: Wed, 6 May 2026 14:05:07 +1000 Subject: [PATCH 11/14] fix: run OAuth flow directly from mobilerun anthropic login --- mobilerun/cli/main.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/mobilerun/cli/main.py b/mobilerun/cli/main.py index 00bb2d32..992a51db 100644 --- a/mobilerun/cli/main.py +++ b/mobilerun/cli/main.py @@ -1000,13 +1000,15 @@ def anthropic(): @click.option( "--token", default=None, - help="Anthropic setup-token value. If omitted, you will be prompted.", + help="Anthropic setup-token value. If provided, skips the OAuth flow.", ) def anthropic_login(credential_path: str, token: str | None): - """Save an Anthropic setup-token. This is the only supported Anthropic auth flow.""" - token_value = _prompt_anthropic_setup_token(token) - save_anthropic_setup_token(credential_path, token_value) - _print_oauth_login_success("Anthropic setup-token", credential_path) + """Login with Anthropic OAuth and save credentials locally.""" + if token: + save_anthropic_setup_token(credential_path, token) + else: + _run_anthropic_oauth_login(credential_path=credential_path) + _print_oauth_login_success("Anthropic", credential_path) @anthropic.command("setup-token") From 94bb7066f01234f4565f9b5807f92a93d1216bef Mon Sep 17 00:00:00 2001 From: Mariozada Date: Wed, 6 May 2026 14:10:52 +1000 Subject: [PATCH 12/14] fix: remove duplicate success message from anthropic login --- mobilerun/cli/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobilerun/cli/main.py b/mobilerun/cli/main.py index 992a51db..4ee28ddf 100644 --- a/mobilerun/cli/main.py +++ b/mobilerun/cli/main.py @@ -1006,9 +1006,9 @@ def anthropic_login(credential_path: str, token: str | None): """Login with Anthropic OAuth and save credentials locally.""" if token: save_anthropic_setup_token(credential_path, token) + _print_oauth_login_success("Anthropic", credential_path) else: _run_anthropic_oauth_login(credential_path=credential_path) - _print_oauth_login_success("Anthropic", credential_path) @anthropic.command("setup-token") From 167f6680dddee1e8df8b0b67fb49b0cdf56ea2a1 Mon Sep 17 00:00:00 2001 From: Mariozada Date: Wed, 6 May 2026 14:19:34 +1000 Subject: [PATCH 13/14] chore: trim docstrings and clean up --- mobilerun/agent/utils/oauth/anthropic_oauth_llm.py | 6 +----- .../agent/utils/oauth/gemini_oauth_code_assist_llm.py | 6 +----- mobilerun/agent/utils/oauth/openai_oauth_llm.py | 9 +-------- 3 files changed, 3 insertions(+), 18 deletions(-) diff --git a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py index bad047c5..af8734b5 100644 --- a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py @@ -524,11 +524,7 @@ def login_headless( input_fn: Any = input, expires_in: Optional[int] = None, ) -> str: - """Headless OAuth flow for SSH/WSL environments. - - Redirects to Anthropic's hosted callback page which displays the - authorization code on screen. - """ + """Headless OAuth flow for SSH/WSL environments.""" code_verifier, code_challenge = _pkce_pair() state = _b64_no_pad(secrets.token_bytes(32)) redirect_uri = "https://platform.claude.com/oauth/code/callback" diff --git a/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py b/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py index 25ef7c02..b26e55eb 100644 --- a/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py +++ b/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py @@ -621,11 +621,7 @@ def login_headless( input_fn: Any = input, prompt_consent: bool = True, ) -> str: - """Headless OAuth flow for SSH/WSL environments. - - Redirects to Google's authcode page which displays the authorization - code on screen. The user copies that short code back to the terminal. - """ + """Headless OAuth flow for SSH/WSL environments.""" code_verifier, code_challenge = _pkce_pair() expected_state = secrets.token_hex(32) redirect_uri = "https://codeassist.google.com/authcode" diff --git a/mobilerun/agent/utils/oauth/openai_oauth_llm.py b/mobilerun/agent/utils/oauth/openai_oauth_llm.py index e736c2bd..c7a39115 100644 --- a/mobilerun/agent/utils/oauth/openai_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/openai_oauth_llm.py @@ -757,14 +757,7 @@ def login_device_code( ) -> OpenAIOAuthCredentials: """Device Code login for headless/SSH environments. - Uses the OAuth 2.0 Device Authorization Grant (RFC 8628). The user - opens a URL on any browser (phone, laptop, etc.) and enters a short - one-time code. The CLI polls until the auth completes — no redirect - back to localhost needed. - - timeout_seconds is capped at _DEVICE_CODE_TIMEOUT (15 min) because - the server-issued code expires after that window. - + timeout_seconds is capped at 15 min (device code expiry). Raises _DeviceCodeNotSupported if the server returns 404. """ mgr = self._oauth_manager From b85f2ba482cfa807fc353d22bbcbcebc4bf40edb Mon Sep 17 00:00:00 2001 From: Mariozada Date: Wed, 6 May 2026 21:05:27 +1000 Subject: [PATCH 14/14] =?UTF-8?q?fix:=20clean=20up=20headless=20OAuth=20?= =?UTF-8?q?=E2=80=94=20remove=20login=5Fmanual,=20fix=20race=20and=20timeo?= =?UTF-8?q?ut=20bugs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent/utils/oauth/anthropic_oauth_llm.py | 14 +- .../oauth/gemini_oauth_code_assist_llm.py | 14 +- .../agent/utils/oauth/openai_oauth_llm.py | 264 ++++++------------ mobilerun/cli/main.py | 2 +- 4 files changed, 100 insertions(+), 194 deletions(-) diff --git a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py index af8734b5..555c4223 100644 --- a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py @@ -65,6 +65,10 @@ def _pkce_pair() -> tuple[str, str]: return verifier, challenge +# Keep in sync with _MAX_CODE_ATTEMPTS in gemini_oauth_code_assist_llm.py +_MAX_CODE_ATTEMPTS = 2 + + def _is_headless_environment() -> bool: """Detect SSH, WSL, or missing display where browser popups won't work.""" if os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_TTY"): @@ -470,7 +474,7 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A003 self.authorize_url = original_authorize_url print( f"Could not bind callback server on {callback_host}:{callback_port} ({exc}). " - "Falling back to manual code entry." + "Falling back to headless code entry." ) return self.login_headless( open_browser=open_browser, @@ -554,7 +558,7 @@ def login_headless( need_more = threading.Event() def _reader() -> None: - for _ in range(2): + for _ in range(_MAX_CODE_ATTEMPTS): need_more.wait() need_more.clear() if stop.is_set(): @@ -568,7 +572,7 @@ def _reader() -> None: threading.Thread(target=_reader, daemon=True).start() try: - for attempt in range(2): + for attempt in range(_MAX_CODE_ATTEMPTS): remaining = deadline - time.time() if remaining <= 0: raise TimeoutError("OAuth login timed out.") @@ -578,7 +582,7 @@ def _reader() -> None: try: raw = input_queue.get(timeout=remaining) except queue.Empty: - raise TimeoutError("OAuth login timed out.") + raise TimeoutError("OAuth login timed out.") from None if raw is None: raise RuntimeError("Login failed — stdin closed.") @@ -616,8 +620,6 @@ def _reader() -> None: finally: self.authorize_url = original_authorize_url - login_manual = login_headless - def _resolve_access_token(self) -> str: env_access_token = os.environ.get("ANTHROPIC_OAUTH_TOKEN") if env_access_token: diff --git a/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py b/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py index b26e55eb..8bfd7bbb 100644 --- a/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py +++ b/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py @@ -59,6 +59,10 @@ def _pkce_pair() -> tuple[str, str]: return verifier, challenge +# Keep in sync with _MAX_CODE_ATTEMPTS in anthropic_oauth_llm.py +_MAX_CODE_ATTEMPTS = 2 + + def _is_headless_environment() -> bool: """Detect SSH, WSL, or missing display where browser popups won't work.""" if os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_TTY"): @@ -571,7 +575,7 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A003 except OSError as exc: print( f"Could not bind callback server on {callback_host}:{callback_port} ({exc}). " - "Falling back to manual code entry." + "Falling back to headless code entry." ) return self.login_headless( open_browser=open_browser, @@ -647,7 +651,7 @@ def login_headless( need_more = threading.Event() def _reader() -> None: - for _ in range(2): + for _ in range(_MAX_CODE_ATTEMPTS): need_more.wait() need_more.clear() if stop.is_set(): @@ -661,7 +665,7 @@ def _reader() -> None: threading.Thread(target=_reader, daemon=True).start() try: - for attempt in range(2): + for attempt in range(_MAX_CODE_ATTEMPTS): remaining = deadline - time.time() if remaining <= 0: raise TimeoutError("OAuth login timed out.") @@ -671,7 +675,7 @@ def _reader() -> None: try: raw = input_queue.get(timeout=remaining) except queue.Empty: - raise TimeoutError("OAuth login timed out.") + raise TimeoutError("OAuth login timed out.") from None if raw is None: raise RuntimeError("Login failed — stdin closed.") @@ -703,8 +707,6 @@ def _reader() -> None: stop.set() need_more.set() - login_manual = login_headless - def _resolve_access_token(self) -> str: env_access_token = os.environ.get("GEMINI_OAUTH_ACCESS_TOKEN") if env_access_token: diff --git a/mobilerun/agent/utils/oauth/openai_oauth_llm.py b/mobilerun/agent/utils/oauth/openai_oauth_llm.py index c7a39115..d7aa6fb6 100644 --- a/mobilerun/agent/utils/oauth/openai_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/openai_oauth_llm.py @@ -18,7 +18,6 @@ import hashlib import json import os -import queue import secrets import sys import threading @@ -74,37 +73,6 @@ def _is_headless_environment() -> bool: return False -def _normalize_manual_code(raw: str, expected_state: str) -> str: - """Parse pasted input: full URL with code= param, code#state, or bare code.""" - value = raw.strip() - if not value: - return value - - first_token = value.split()[0] - - if "error=" in first_token or "code=" in first_token: - parsed = urlparse(first_token) - params = parse_qs(parsed.query) - error = params.get("error", [None])[0] - if error: - desc = params.get("error_description", [error])[0] - raise RuntimeError(f"OAuth error: {desc}") - code = params.get("code", [None])[0] - state_from_url = params.get("state", [None])[0] - if state_from_url and state_from_url != expected_state: - raise RuntimeError("OAuth manual code state mismatch.") - if isinstance(code, str) and code: - return code - - if "#" in first_token: - code_part, fragment = first_token.split("#", 1) - if fragment and fragment != expected_state: - raise RuntimeError("OAuth manual code state mismatch.") - return code_part - - return first_token - - def _tls_preflight(issuer: str, timeout: float = 5.0) -> None: """Probe the OAuth issuer to detect TLS/certificate issues before login. @@ -150,10 +118,6 @@ def _tls_preflight(issuer: str, timeout: float = 5.0) -> None: print(f"Warning: TLS preflight check encountered an error: {exc}") -class _DeviceCodeNotSupported(Exception): - pass - - _DEVICE_CODE_TIMEOUT = 15 * 60 @@ -162,21 +126,31 @@ def _request_device_code( client_id: str, http_client: Optional[httpx.Client] = None, request_timeout: float = 15.0, + retries: int = 2, ) -> dict: url = f"{issuer.rstrip('/')}/api/accounts/deviceauth/usercode" post = http_client.post if http_client is not None else httpx.post - response = post( - url, - headers={"Content-Type": "application/json"}, - json={"client_id": client_id}, - timeout=request_timeout, - ) - if response.status_code == 404: - raise _DeviceCodeNotSupported( - "Device code login is not enabled for this server." - ) - response.raise_for_status() - return response.json() + for attempt in range(1 + retries): + try: + response = post( + url, + headers={"Content-Type": "application/json"}, + json={"client_id": client_id}, + timeout=request_timeout, + ) + except (httpx.ConnectError, httpx.TimeoutException): + if attempt < retries: + time.sleep(2) + continue + raise + if response.status_code == 404: + raise RuntimeError("Device code login is not enabled for this server.") + if response.status_code >= 500 and attempt < retries: + time.sleep(2) + continue + response.raise_for_status() + return response.json() + raise RuntimeError("Device code request failed after retries.") def _poll_device_code( @@ -193,13 +167,23 @@ def _poll_device_code( deadline = time.time() + effective_timeout post = http_client.post if http_client is not None else httpx.post + last_error: Optional[str] = None + while time.time() < deadline: - response = post( - url, - headers={"Content-Type": "application/json"}, - json={"device_auth_id": device_auth_id, "user_code": user_code}, - timeout=request_timeout, - ) + try: + response = post( + url, + headers={"Content-Type": "application/json"}, + json={"device_auth_id": device_auth_id, "user_code": user_code}, + timeout=request_timeout, + ) + except (httpx.ConnectError, httpx.TimeoutException) as exc: + last_error = str(exc) + remaining = deadline - time.time() + if remaining <= 0: + break + time.sleep(min(interval, remaining)) + continue if response.status_code == 200: return response.json() # OpenAI returns 403/404 while the user hasn't completed browser auth. @@ -212,10 +196,20 @@ def _poll_device_code( break time.sleep(min(interval, remaining)) continue + if response.status_code >= 500: + last_error = f"HTTP {response.status_code}" + remaining = deadline - time.time() + if remaining <= 0: + break + time.sleep(min(interval, remaining)) + continue response.raise_for_status() minutes = int(effective_timeout // 60) - raise TimeoutError(f"Device code login timed out ({minutes} minutes).") + msg = f"Device code login timed out ({minutes} minutes)." + if last_error: + msg += f" Last response: {last_error}." + raise TimeoutError(msg) @dataclass @@ -645,17 +639,7 @@ def login( "DROIDRUN_OAUTH_MANUAL", "" ).lower() in ("1", "true", "yes") if use_device_code: - try: - return self.login_device_code(timeout_seconds=timeout_seconds) - except (_DeviceCodeNotSupported, httpx.HTTPStatusError, httpx.ConnectError, RuntimeError): - return self.login_manual( - open_browser=open_browser, - timeout_seconds=timeout_seconds, - callback_port=callback_port, - callback_path=callback_path, - redirect_host=redirect_host, - scope=scope, - ) + return self._login_device_code(timeout_seconds=timeout_seconds) # Desktop: browser callback server result: Dict[str, Optional[str]] = {"code": None, "state": None, "error": None} @@ -698,16 +682,9 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A003 except OSError as exc: print( f"Could not bind callback server on {callback_host}:{callback_port} ({exc}). " - "Falling back to manual code entry." - ) - return self.login_manual( - open_browser=open_browser, - timeout_seconds=timeout_seconds, - callback_port=callback_port, - callback_path=callback_path, - redirect_host=redirect_host, - scope=scope, + "Falling back to device code login." ) + return self._login_device_code(timeout_seconds=timeout_seconds) actual_port = httpd.server_address[1] redirect_uri = f"http://{redirect_host}:{actual_port}{callback_path}" @@ -750,7 +727,7 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A003 httpd.shutdown() httpd.server_close() - def login_device_code( + def _login_device_code( self, *, timeout_seconds: float = _DEVICE_CODE_TIMEOUT, @@ -758,7 +735,6 @@ def login_device_code( """Device Code login for headless/SSH environments. timeout_seconds is capped at 15 min (device code expiry). - Raises _DeviceCodeNotSupported if the server returns 404. """ mgr = self._oauth_manager http_client = mgr.http_client @@ -768,23 +744,38 @@ def login_device_code( http_client=http_client, request_timeout=mgr.request_timeout, ) - device_auth_id = device_resp["device_auth_id"] - - user_code = device_resp.get("user_code") or device_resp.get("usercode", "") + device_auth_id = device_resp.get("device_auth_id") + if not device_auth_id: + raise RuntimeError("Device code response missing 'device_auth_id'.") + + user_code = device_resp.get("user_code") or device_resp.get("usercode") + if not user_code: + raise RuntimeError("Device code response missing 'user_code'.") try: interval = int(str(device_resp.get("interval", "5")).strip()) except (TypeError, ValueError): interval = 5 + try: + server_expires = int(device_resp["expires_in"]) + except (KeyError, TypeError, ValueError): + server_expires = _DEVICE_CODE_TIMEOUT + effective_timeout = min(timeout_seconds, _DEVICE_CODE_TIMEOUT, server_expires) verification_url = ( device_resp.get("verification_uri") or device_resp.get("verification_url") or f"{mgr.issuer}/codex/device" ) + if effective_timeout >= 60: + mins = int(effective_timeout // 60) + expires_str = f"{mins} minute{'s' if mins != 1 else ''}" + else: + secs = int(effective_timeout) + expires_str = f"{secs} second{'s' if secs != 1 else ''}" print( f"\nSign in with your ChatGPT account:\n" f"\n1. Open this link in your browser:\n {verification_url}\n" - f"\n2. Enter this code (expires in 15 minutes):\n {user_code}\n" + f"\n2. Enter this code (expires in {expires_str}):\n {user_code}\n" f"\nDevice codes are a common phishing target. Never share this code.\n" ) @@ -795,116 +786,27 @@ def login_device_code( interval, http_client=http_client, request_timeout=mgr.request_timeout, - timeout_seconds=timeout_seconds, + timeout_seconds=effective_timeout, ) + auth_code = token_resp.get("authorization_code") + code_verifier = token_resp.get("code_verifier") + if not auth_code or not code_verifier: + raise RuntimeError( + "Device code token response missing required fields " + "('authorization_code' / 'code_verifier')." + ) + redirect_uri = f"{mgr.issuer}/deviceauth/callback" creds = self._oauth_manager.exchange_authorization_code( - code=token_resp["authorization_code"], + code=auth_code, redirect_uri=redirect_uri, - code_verifier=token_resp["code_verifier"], + code_verifier=code_verifier, ) if creds.account_id: object.__setattr__(self, "_oauth_account_id", creds.account_id) return creds - login_headless = login_device_code - - def login_manual( - self, - *, - open_browser: bool = True, - timeout_seconds: float = 300.0, - input_fn: Any = input, - callback_port: int = DEFAULT_OPENAI_OAUTH_CALLBACK_PORT, - callback_path: str = DEFAULT_OPENAI_OAUTH_CALLBACK_PATH, - redirect_host: str = DEFAULT_OPENAI_OAUTH_CALLBACK_HOST, - scope: str = DEFAULT_OPENAI_OAUTH_SCOPE, - ) -> OpenAIOAuthCredentials: - """Manual OAuth flow — last resort fallback when device code is unavailable.""" - code_verifier, code_challenge = _pkce_pair() - state = _b64_no_pad(secrets.token_bytes(32)) - redirect_uri = f"http://{redirect_host}:{callback_port}{callback_path}" - auth_url = self._build_auth_url( - issuer=self._oauth_manager.issuer, - client_id=self._oauth_manager.client_id, - redirect_uri=redirect_uri, - code_challenge=code_challenge, - state=state, - scope=scope, - ) - - print(f"Open this URL to login:\n{auth_url}") - if open_browser: - webbrowser.open(auth_url) - - deadline = time.time() + timeout_seconds - input_queue: queue.Queue[Optional[str]] = queue.Queue() - stop = threading.Event() - need_more = threading.Event() - - def _reader() -> None: - for _ in range(2): - need_more.wait() - need_more.clear() - if stop.is_set(): - return - try: - input_queue.put(str(input_fn("Paste the redirect URL or authorization code: "))) - except (EOFError, OSError): - input_queue.put(None) - return - - threading.Thread(target=_reader, daemon=True).start() - - try: - for attempt in range(2): - remaining = deadline - time.time() - if remaining <= 0: - raise TimeoutError("OAuth login timed out.") - - need_more.set() - - try: - raw = input_queue.get(timeout=remaining) - except queue.Empty: - raise TimeoutError("OAuth login timed out.") - - if raw is None: - raise RuntimeError("Login failed — stdin closed.") - if not raw.strip(): - if attempt == 0: - print("Invalid paste. Try again.") - continue - raise RuntimeError("Login failed.") - try: - code = _normalize_manual_code(raw, state) - except Exception: # noqa: BLE001 - if attempt == 0: - print("Invalid paste. Try again.") - continue - raise RuntimeError("Login failed.") - if code: - creds = self._oauth_manager.exchange_authorization_code( - code=code, - redirect_uri=redirect_uri, - code_verifier=code_verifier, - ) - if creds.account_id: - object.__setattr__(self, "_oauth_account_id", creds.account_id) - return creds - if attempt == 0: - print("Invalid paste. Try again.") - continue - raise RuntimeError("Login failed.") - raise RuntimeError("Login failed.") - finally: - # stop.set() prevents the reader from starting a new input() call. - # It cannot interrupt an input() already in progress (Python limitation); - # the daemon thread dies with the process. - stop.set() - need_more.set() - def _ensure_access_token(self) -> OpenAIOAuthCredentials: creds = self._oauth_manager.get_valid_credentials(skew_ms=self._oauth_refresh_skew_ms) diff --git a/mobilerun/cli/main.py b/mobilerun/cli/main.py index 4ee28ddf..1ca6b0fa 100644 --- a/mobilerun/cli/main.py +++ b/mobilerun/cli/main.py @@ -1003,7 +1003,7 @@ def anthropic(): help="Anthropic setup-token value. If provided, skips the OAuth flow.", ) def anthropic_login(credential_path: str, token: str | None): - """Login with Anthropic OAuth and save credentials locally.""" + """Login with Anthropic OAuth. Pass --token to save a setup-token without OAuth.""" if token: save_anthropic_setup_token(credential_path, token) _print_oauth_login_success("Anthropic", credential_path)