From 277fa405c1f946e2724f0beeabeb5a9faaae822d Mon Sep 17 00:00:00 2001 From: Mariozada Date: Mon, 13 Apr 2026 14:21:01 +1000 Subject: [PATCH 01/12] oauth: add race pattern, PKCE for Gemini, TLS preflight, and manual fallback --- .../agent/utils/oauth/anthropic_oauth_llm.py | 78 +++++-- .../oauth/gemini_oauth_code_assist_llm.py | 193 +++++++++++++++-- .../agent/utils/oauth/openai_oauth_llm.py | 201 +++++++++++++++++- 3 files changed, 430 insertions(+), 42 deletions(-) diff --git a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py index ac6f5f46..6479de52 100644 --- a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py @@ -3,6 +3,7 @@ import json import os import secrets +import sys import threading import time import webbrowser @@ -63,6 +64,18 @@ def _pkce_pair() -> tuple[str, str]: return verifier, challenge +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"): + return True + if os.environ.get("WSL_DISTRO_NAME"): + return True + if sys.platform.startswith("linux"): + if not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"): + return True + return False + + def _normalize_manual_code(raw: str, expected_state: str) -> str: value = raw.strip() if not value: @@ -391,7 +404,13 @@ def login( callback_path: str = "/callback", expires_in: Optional[int] = None, ) -> str: + if os.environ.get("DROIDRUN_OAUTH_FORCE_MANUAL"): + return self.login_manual( + open_browser=open_browser, expires_in=expires_in + ) + result: Dict[str, Optional[str]] = {"code": None, "state": None, "error": None} + manual_code: Dict[str, Optional[str]] = {"code": None} done = threading.Event() code_verifier, code_challenge = _pkce_pair() @@ -431,7 +450,18 @@ def do_GET(self) -> None: # noqa: N802 def log_message(self, format: str, *args: Any) -> None: # noqa: A003 return - httpd = HTTPServer((callback_host, callback_port), _OAuthHandler) + try: + httpd = HTTPServer((callback_host, callback_port), _OAuthHandler) + except OSError as exc: + 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." + ) + return self.login_manual( + open_browser=open_browser, expires_in=expires_in + ) + actual_port = httpd.server_address[1] redirect_uri = f"http://localhost:{actual_port}{callback_path}" auth_url = self._build_auth_url( @@ -442,23 +472,47 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A003 server_thread = threading.Thread(target=httpd.serve_forever, daemon=True) server_thread.start() + try: + print(f"Open this URL to login:\n{auth_url}\n") if open_browser: webbrowser.open(auth_url) - else: - print(f"Open this URL to login:\n{auth_url}") + + def _read_manual() -> None: + try: + raw = str(input("Or paste the redirect URL / authorization code: ")) + except Exception: + return + if done.is_set() or not raw.strip(): + return + try: + code = _normalize_manual_code(raw, state) + except Exception as e: # noqa: BLE001 + print(f"Invalid paste: {e}") + return + if code: + manual_code["code"] = code + done.set() + + 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 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.") + + 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"] return self._exchange_authorization_code( - code=result["code"], + code=code_to_exchange, redirect_uri=redirect_uri, code_verifier=code_verifier, state=state, @@ -491,11 +545,11 @@ def login_manual( ) try: + print(f"Open this URL to login:\n{auth_url}") if open_browser: webbrowser.open(auth_url) - print(f"Open this URL to login:\n{auth_url}") code = _normalize_manual_code( - str(input_fn("Paste authorization code: ")), + str(input_fn("Paste the redirect URL or authorization code: ")), state, ) if not code: 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 5a2383bc..3dc38eb6 100644 --- a/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py +++ b/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py @@ -1,7 +1,10 @@ +import base64 +import hashlib import json import os import secrets import socket +import sys import threading import time import webbrowser @@ -43,6 +46,52 @@ DEFAULT_CLIENT_SECRET = "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" +def _b64_no_pad(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).decode("utf-8").rstrip("=") + + +def _pkce_pair() -> tuple[str, str]: + verifier = _b64_no_pad(secrets.token_bytes(64)) + challenge = _b64_no_pad(hashlib.sha256(verifier.encode("utf-8")).digest()) + return verifier, challenge + + +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"): + return True + if os.environ.get("WSL_DISTRO_NAME"): + return True + if sys.platform.startswith("linux"): + if not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"): + return True + 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 "code=" in first_token: + parsed = urlparse(first_token) + params = parse_qs(parsed.query) + code = params.get("code", [None])[0] + 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 + + class GeminiOAuthCodeAssistLLM(CustomLLM): """Gemini OAuth LLM that talks to Google Code Assist endpoints. @@ -359,16 +408,25 @@ def _refresh_access_token(self) -> str: return access_token - def _exchange_authorization_code(self, code: str, redirect_uri: str) -> str: + def _exchange_authorization_code( + self, + code: str, + redirect_uri: str, + code_verifier: Optional[str] = None, + ) -> str: + payload = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": self.client_id, + "client_secret": self.client_secret, + } + if code_verifier: + payload["code_verifier"] = code_verifier + response = self._session.post( self.token_url, - data={ - "grant_type": "authorization_code", - "code": code, - "redirect_uri": redirect_uri, - "client_id": self.client_id, - "client_secret": self.client_secret, - }, + data=payload, timeout=self.timeout, ) response.raise_for_status() @@ -384,6 +442,12 @@ def _exchange_authorization_code(self, code: str, redirect_uri: str) -> str: if isinstance(refresh_token, str) and refresh_token: self._cached_refresh_token = refresh_token + if not self._cached_refresh_token: + raise RuntimeError( + "No refresh token received from Google. " + "Revoke access at https://myaccount.google.com/permissions and retry." + ) + expires_in = data.get("expires_in", 3600) try: expires_in_s = int(expires_in) @@ -395,7 +459,13 @@ def _exchange_authorization_code(self, code: str, redirect_uri: str) -> str: self._persist_credentials() return access_token - def _build_auth_url(self, redirect_uri: str, state: str, prompt_consent: bool) -> str: + def _build_auth_url( + self, + redirect_uri: str, + state: str, + prompt_consent: bool, + code_challenge: Optional[str] = None, + ) -> str: scope = " ".join( [ "https://www.googleapis.com/auth/cloud-platform", @@ -413,6 +483,9 @@ def _build_auth_url(self, redirect_uri: str, state: str, prompt_consent: bool) - } if prompt_consent: query["prompt"] = "consent" + if code_challenge: + query["code_challenge"] = code_challenge + query["code_challenge_method"] = "S256" return f"{self.authorize_url}?{urlencode(query)}" def login( @@ -425,9 +498,16 @@ def login( callback_path: str = "/oauth2callback", prompt_consent: bool = True, ) -> str: + if os.environ.get("DROIDRUN_OAUTH_FORCE_MANUAL"): + return self.login_manual( + open_browser=open_browser, prompt_consent=prompt_consent + ) + result: Dict[str, Optional[str]] = {"code": None, "state": None, "error": None} + manual_code: Dict[str, Optional[str]] = {"code": None} done = threading.Event() expected_state = secrets.token_hex(32) + code_verifier, code_challenge = _pkce_pair() class _OAuthHandler(BaseHTTPRequestHandler): def do_GET(self) -> None: # noqa: N802 @@ -459,39 +539,112 @@ def do_GET(self) -> None: # noqa: N802 def log_message(self, format: str, *args: Any) -> None: # noqa: A003 return - httpd = HTTPServer((callback_host, callback_port), _OAuthHandler) + try: + httpd = HTTPServer((callback_host, callback_port), _OAuthHandler) + 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, prompt_consent=prompt_consent + ) + actual_port = httpd.server_address[1] redirect_uri = f"http://127.0.0.1:{actual_port}{callback_path}" auth_url = self._build_auth_url( redirect_uri=redirect_uri, state=expected_state, prompt_consent=prompt_consent, + code_challenge=code_challenge, ) server_thread = threading.Thread(target=httpd.serve_forever, daemon=True) server_thread.start() try: + print(f"Open this URL to login:\n{auth_url}\n") if open_browser: webbrowser.open(auth_url) - else: - print(f"Open this URL to login:\n{auth_url}") + + def _read_manual() -> None: + try: + raw = str(input("Or paste the redirect URL / authorization code: ")) + except Exception: + return + if done.is_set() or not raw.strip(): + return + try: + code = _normalize_manual_code(raw, expected_state) + except Exception as e: # noqa: BLE001 + print(f"Invalid paste: {e}") + return + if code: + manual_code["code"] = code + done.set() + + 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 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(result["code"], redirect_uri) + 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"] + + return self._exchange_authorization_code( + code_to_exchange, redirect_uri, code_verifier=code_verifier + ) finally: httpd.shutdown() httpd.server_close() + def login_manual( + self, + *, + open_browser: bool = True, + input_fn: Any = input, + prompt_consent: bool = True, + ) -> str: + """Manual OAuth flow for headless/VPS/WSL environments. + + Opens (or prints) the auth URL and prompts the user to paste the + redirected URL or bare authorization code from the browser. + """ + 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" + + auth_url = self._build_auth_url( + redirect_uri=redirect_uri, + state=expected_state, + prompt_consent=prompt_consent, + code_challenge=code_challenge, + ) + + print(f"Open this URL to login:\n{auth_url}") + if open_browser: + webbrowser.open(auth_url) + + raw = str(input_fn("Paste the redirect URL or authorization code: ")) + code = _normalize_manual_code(raw, expected_state) + if not code: + raise RuntimeError("Authorization code was empty.") + + return self._exchange_authorization_code( + code, redirect_uri, code_verifier=code_verifier + ) + 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 394724cb..74124ada 100644 --- a/mobilerun/agent/utils/oauth/openai_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/openai_oauth_llm.py @@ -19,6 +19,7 @@ import json import os import secrets +import sys import threading import time from dataclasses import dataclass @@ -60,6 +61,87 @@ def _pkce_pair() -> tuple[str, str]: return verifier, challenge +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"): + return True + if os.environ.get("WSL_DISTRO_NAME"): + return True + if sys.platform.startswith("linux"): + if not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"): + return True + 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 "code=" in first_token: + parsed = urlparse(first_token) + params = parse_qs(parsed.query) + code = params.get("code", [None])[0] + 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. + + Raises RuntimeError on TLS certificate errors (with fix suggestions). + Prints warnings for non-TLS connection errors but does not block. + """ + probe_url = f"{issuer.rstrip('/')}/oauth/authorize" + try: + httpx.head(probe_url, follow_redirects=False, timeout=timeout) + except httpx.ConnectError as exc: + err_str = str(exc).lower() + tls_indicators = ( + "certificate", + "ssl", + "tls", + "unable_to_get_issuer_cert", + "cert_has_expired", + "self_signed_cert", + "verify_leaf_signature", + "altname_invalid", + ) + if any(indicator in err_str for indicator in tls_indicators): + raise RuntimeError( + f"TLS certificate error connecting to {probe_url}: {exc}\n" + "Possible fixes:\n" + " - Update CA certificates " + "(e.g. `sudo update-ca-certificates` on Linux, " + "`brew postinstall ca-certificates` on macOS)\n" + " - Update OpenSSL (e.g. `brew postinstall openssl@3`)\n" + " - Check if a corporate proxy is intercepting HTTPS traffic" + ) from exc + print( + f"Warning: Could not connect to {probe_url}: {exc}\n" + "The login flow may fail if there is a DNS or firewall issue." + ) + except httpx.TimeoutException: + print( + f"Warning: Connection to {probe_url} timed out.\n" + "The login flow may fail if there is a network issue." + ) + except Exception as exc: + # Unexpected error — warn but don't block. + print(f"Warning: TLS preflight check encountered an error: {exc}") + + @dataclass class OpenAIOAuthCredentials: access_token: str @@ -480,7 +562,20 @@ def login( redirect_host: str = DEFAULT_OPENAI_OAUTH_CALLBACK_HOST, scope: str = DEFAULT_OPENAI_OAUTH_SCOPE, ) -> OpenAIOAuthCredentials: + _tls_preflight(self._oauth_manager.issuer) + + if os.environ.get("DROIDRUN_OAUTH_FORCE_MANUAL"): + return self.login_manual( + open_browser=open_browser, + callback_host=callback_host, + callback_port=callback_port, + callback_path=callback_path, + redirect_host=redirect_host, + scope=scope, + ) + result: Dict[str, Optional[str]] = {"code": None, "state": None, "error": None} + manual_code: Dict[str, Optional[str]] = {"code": None} done = threading.Event() code_verifier, code_challenge = _pkce_pair() state = _b64_no_pad(secrets.token_bytes(32)) @@ -515,7 +610,22 @@ def do_GET(self) -> None: # noqa: N802 def log_message(self, format: str, *args: Any) -> None: # noqa: A003 return - httpd = HTTPServer((callback_host, callback_port), _OAuthHandler) + try: + httpd = HTTPServer((callback_host, callback_port), _OAuthHandler) + 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, + callback_host=callback_host, + callback_port=callback_port, + callback_path=callback_path, + redirect_host=redirect_host, + scope=scope, + ) + actual_port = httpd.server_address[1] redirect_uri = f"http://{redirect_host}:{actual_port}{callback_path}" auth_url = self._build_auth_url( @@ -529,23 +639,47 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A003 server_thread = threading.Thread(target=httpd.serve_forever, daemon=True) server_thread.start() + try: + print(f"Open this URL to login:\n{auth_url}\n") if open_browser: webbrowser.open(auth_url) - else: - print(f"Open this URL to login:\n{auth_url}") + + def _read_manual() -> None: + try: + raw = str(input("Or paste the redirect URL / authorization code: ")) + except Exception: + return + if done.is_set() or not raw.strip(): + return + try: + code = _normalize_manual_code(raw, state) + except Exception as e: # noqa: BLE001 + print(f"Invalid paste: {e}") + return + if code: + manual_code["code"] = code + done.set() + + 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 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.") + + 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"] creds = self._oauth_manager.exchange_authorization_code( - code=result["code"], + code=code_to_exchange, redirect_uri=redirect_uri, code_verifier=code_verifier, ) @@ -556,6 +690,53 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A003 httpd.shutdown() httpd.server_close() + def login_manual( + self, + *, + open_browser: bool = True, + input_fn: Any = input, + callback_host: str = DEFAULT_OPENAI_OAUTH_CALLBACK_HOST, + 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 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. + """ + 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) + + raw = str(input_fn("Paste the redirect URL or authorization code: ")) + code = _normalize_manual_code(raw, state) + if not code: + raise RuntimeError("Authorization code was empty.") + + 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 + def _ensure_access_token(self) -> OpenAIOAuthCredentials: creds = self._oauth_manager.get_valid_credentials(skew_ms=self._oauth_refresh_skew_ms) From 7aa35bf9d360a63f97e9a693918af8f883cd381f Mon Sep 17 00:00:00 2001 From: Mariozada Date: Mon, 13 Apr 2026 14:41:26 +1000 Subject: [PATCH 02/12] fix: Code Assist 400 errors by matching gemini-cli request shape --- .../oauth/gemini_oauth_code_assist_llm.py | 103 +++++++++++++++--- 1 file changed, 85 insertions(+), 18 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 3dc38eb6..1d5547ba 100644 --- a/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py +++ b/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py @@ -45,6 +45,9 @@ ) DEFAULT_CLIENT_SECRET = "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" +# LlamaIndex-internal kwargs that must never be forwarded to Google's API. +_IGNORED_REQUEST_KWARGS = {"formatted"} + def _b64_no_pad(raw: bytes) -> str: return base64.urlsafe_b64encode(raw).decode("utf-8").rstrip("=") @@ -321,14 +324,26 @@ def _metadata_payload(self) -> Dict[str, str]: "pluginType": "GEMINI", } - def _ensure_project_id(self, token: str) -> Optional[str]: - if self.project_id: - return self.project_id + def _build_headers(self, token: str) -> Dict[str, str]: + """Build Code Assist request headers matching gemini-cli expectations. - headers = { + The private v1internal endpoint requires the X-Goog-Api-Client and + Client-Metadata headers to identify the caller as a gemini-cli-style + client; without them, requests return 400. + """ + return { "Authorization": f"Bearer {token}", "Content-Type": "application/json", + "User-Agent": "google-cloud-sdk vscode_cloudshelleditor/0.1", + "X-Goog-Api-Client": "gl-node/22.17.0", + "Client-Metadata": json.dumps(self._metadata_payload()), } + + def _ensure_project_id(self, token: str) -> Optional[str]: + if self.project_id: + return self.project_id + + headers = self._build_headers(token) metadata = self._metadata_payload() response = self._session.post( self._method_url(DEFAULT_CODE_ASSIST_LOAD_METHOD), @@ -719,11 +734,18 @@ def _to_code_assist_request( payload: Dict[str, Any] = { "model": self.model, "request": request, + "userAgent": "droidrun", + "requestId": f"droidrun-{int(time.time() * 1000)}-{secrets.token_hex(4)}", } if self.project_id: payload["project"] = self.project_id - payload.update(kwargs) + # Strip LlamaIndex-internal kwargs (e.g. ``formatted``) that Google's + # Code Assist API rejects as unknown fields. + safe_kwargs = { + k: v for k, v in kwargs.items() if k not in _IGNORED_REQUEST_KWARGS + } + payload.update(safe_kwargs) return payload def _method_url(self, method: str) -> str: @@ -751,31 +773,76 @@ def _extract_text(chunk: Dict[str, Any]) -> str: @llm_chat_callback() def chat(self, messages: Sequence[ChatMessage], **kwargs: Any) -> ChatResponse: + # Google's Code Assist private API (v1internal) does not reliably + # accept the non-streaming generateContent endpoint for every model. + # Route chat through streamGenerateContent and accumulate the stream, + # matching the pattern used by gemini-cli / OpenClaw. token = self._resolve_access_token() self._ensure_project_id(token) payload = self._to_code_assist_request(messages, **kwargs) response = self._session.post( - self._method_url("generateContent"), + self._method_url("streamGenerateContent"), + params={"alt": "sse"}, headers={ - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", + **self._build_headers(token), + "Accept": "text/event-stream", }, json=payload, timeout=self.timeout, + stream=True, ) - response.raise_for_status() + if not response.ok: + raise requests.HTTPError( + f"Code Assist {response.status_code} error: {response.text}", + response=response, + ) + + accumulated = "" + last_raw: Dict[str, Any] = {} + buffer: list[str] = [] - raw = response.json() - text = self._extract_text(raw) + def _flush(buffer: list[str]) -> Optional[Dict[str, Any]]: + if not buffer: + return None + chunk_text = "\n".join(buffer) + try: + return json.loads(chunk_text) + except json.JSONDecodeError: + return None + + for line in response.iter_lines(decode_unicode=True): + if line is None: + continue + stripped = line.strip() + if stripped.startswith("data:"): + buffer.append(stripped[5:].strip()) + continue + if stripped != "" or not buffer: + continue + raw_chunk = _flush(buffer) + buffer = [] + if raw_chunk is None: + continue + last_raw = raw_chunk + delta = self._extract_text(raw_chunk) + if delta: + accumulated += delta + + raw_chunk = _flush(buffer) + if raw_chunk is not None: + last_raw = raw_chunk + delta = self._extract_text(raw_chunk) + if delta: + accumulated += delta return ChatResponse( - message=ChatMessage(role=MessageRole.ASSISTANT, content=text), - raw=raw, + message=ChatMessage(role=MessageRole.ASSISTANT, content=accumulated), + raw=last_raw, additional_kwargs={ - "trace_id": raw.get("traceId"), - "usage": (raw.get("response") or {}).get("usageMetadata"), - "model_version": (raw.get("response") or {}).get("modelVersion"), + "trace_id": last_raw.get("traceId"), + "usage": (last_raw.get("response") or {}).get("usageMetadata"), + "model_version": (last_raw.get("response") or {}).get("modelVersion"), }, ) @@ -801,8 +868,8 @@ def stream_chat(self, messages: Sequence[ChatMessage], **kwargs: Any) -> ChatRes self._method_url("streamGenerateContent"), params={"alt": "sse"}, headers={ - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", + **self._build_headers(token), + "Accept": "text/event-stream", }, json=payload, timeout=self.timeout, From 1d8b377628ad96f7ce1bfbc1e259fd8fac997184 Mon Sep 17 00:00:00 2001 From: Mariozada Date: Mon, 13 Apr 2026 14:49:00 +1000 Subject: [PATCH 03/12] fix: honor explicit model selection and remove force-manual debug flag --- mobilerun/agent/utils/oauth/anthropic_oauth_llm.py | 5 ----- .../agent/utils/oauth/gemini_oauth_code_assist_llm.py | 10 +++++----- mobilerun/agent/utils/oauth/openai_oauth_llm.py | 10 ---------- 3 files changed, 5 insertions(+), 20 deletions(-) diff --git a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py index 6479de52..ac4ef138 100644 --- a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py @@ -404,11 +404,6 @@ def login( callback_path: str = "/callback", expires_in: Optional[int] = None, ) -> str: - if os.environ.get("DROIDRUN_OAUTH_FORCE_MANUAL"): - return self.login_manual( - open_browser=open_browser, expires_in=expires_in - ) - result: Dict[str, Optional[str]] = {"code": None, "state": None, "error": None} manual_code: Dict[str, Optional[str]] = {"code": None} done = threading.Event() 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 1d5547ba..c3af77e2 100644 --- a/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py +++ b/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py @@ -177,8 +177,13 @@ def __init__( selected_model = custom_model if not selected_model: if model in self.MODEL_PRESETS: + # Passed a preset key like "pro_preview" → resolve to actual id. selected_model = self.MODEL_PRESETS[model] + elif model and model != DEFAULT_MODEL: + # Explicit model name from config/CLI → honor it verbatim. + selected_model = model elif model_preset in self.MODEL_PRESETS: + # Fall back to preset only when no explicit model was provided. selected_model = self.MODEL_PRESETS[model_preset] else: selected_model = model @@ -513,11 +518,6 @@ def login( callback_path: str = "/oauth2callback", prompt_consent: bool = True, ) -> str: - if os.environ.get("DROIDRUN_OAUTH_FORCE_MANUAL"): - return self.login_manual( - open_browser=open_browser, prompt_consent=prompt_consent - ) - result: Dict[str, Optional[str]] = {"code": None, "state": None, "error": None} manual_code: Dict[str, Optional[str]] = {"code": None} done = threading.Event() diff --git a/mobilerun/agent/utils/oauth/openai_oauth_llm.py b/mobilerun/agent/utils/oauth/openai_oauth_llm.py index 74124ada..a4c1ab75 100644 --- a/mobilerun/agent/utils/oauth/openai_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/openai_oauth_llm.py @@ -564,16 +564,6 @@ def login( ) -> OpenAIOAuthCredentials: _tls_preflight(self._oauth_manager.issuer) - if os.environ.get("DROIDRUN_OAUTH_FORCE_MANUAL"): - return self.login_manual( - open_browser=open_browser, - callback_host=callback_host, - callback_port=callback_port, - callback_path=callback_path, - redirect_host=redirect_host, - scope=scope, - ) - result: Dict[str, Optional[str]] = {"code": None, "state": None, "error": None} manual_code: Dict[str, Optional[str]] = {"code": None} done = threading.Event() From f252b12816fcd5faa16a8aa5fcaf75c225e11bf3 Mon Sep 17 00:00:00 2001 From: Mariozada Date: Mon, 13 Apr 2026 15:28:54 +1000 Subject: [PATCH 04/12] fix: validate state parameter in pasted OAuth URLs --- mobilerun/agent/utils/oauth/anthropic_oauth_llm.py | 3 +++ mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py | 3 +++ mobilerun/agent/utils/oauth/openai_oauth_llm.py | 3 +++ 3 files changed, 9 insertions(+) diff --git a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py index ac4ef138..def91f94 100644 --- a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py @@ -87,6 +87,9 @@ def _normalize_manual_code(raw: str, expected_state: str) -> str: parsed = urlparse(first_token) params = parse_qs(parsed.query) 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 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 c3af77e2..2b119800 100644 --- a/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py +++ b/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py @@ -83,6 +83,9 @@ def _normalize_manual_code(raw: str, expected_state: str) -> str: parsed = urlparse(first_token) params = parse_qs(parsed.query) 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 diff --git a/mobilerun/agent/utils/oauth/openai_oauth_llm.py b/mobilerun/agent/utils/oauth/openai_oauth_llm.py index a4c1ab75..0852f362 100644 --- a/mobilerun/agent/utils/oauth/openai_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/openai_oauth_llm.py @@ -85,6 +85,9 @@ def _normalize_manual_code(raw: str, expected_state: str) -> str: parsed = urlparse(first_token) params = parse_qs(parsed.query) 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 From 9b0544f5d15114c816342aec094183684a1c3538 Mon Sep 17 00:00:00 2001 From: Mariozada Date: Mon, 13 Apr 2026 16:05:56 +1000 Subject: [PATCH 05/12] fix: only start OAuth manual-paste thread when headless or opted-in --- .../agent/utils/oauth/anthropic_oauth_llm.py | 46 +++++++++++-------- .../oauth/gemini_oauth_code_assist_llm.py | 46 +++++++++++-------- .../agent/utils/oauth/openai_oauth_llm.py | 46 +++++++++++-------- 3 files changed, 84 insertions(+), 54 deletions(-) diff --git a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py index def91f94..a5fa1b30 100644 --- a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py @@ -476,24 +476,34 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A003 if open_browser: webbrowser.open(auth_url) - def _read_manual() -> None: - try: - raw = str(input("Or paste the redirect URL / authorization code: ")) - except Exception: - return - if done.is_set() or not raw.strip(): - return - try: - code = _normalize_manual_code(raw, state) - except Exception as e: # noqa: BLE001 - print(f"Invalid paste: {e}") - return - if code: - manual_code["code"] = code - done.set() - - manual_thread = threading.Thread(target=_read_manual, daemon=True) - manual_thread.start() + # 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 bool( + os.environ.get("DROIDRUN_OAUTH_MANUAL") + ) + if enable_manual: + def _read_manual() -> None: + try: + raw = str(input("Or paste the redirect URL / authorization code: ")) + except Exception: + return + if done.is_set() or not raw.strip(): + return + try: + code = _normalize_manual_code(raw, state) + except Exception as e: # noqa: BLE001 + print(f"Invalid paste: {e}") + return + if code: + manual_code["code"] = code + done.set() + + 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.") 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 2b119800..44256b0a 100644 --- a/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py +++ b/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py @@ -585,24 +585,34 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A003 if open_browser: webbrowser.open(auth_url) - def _read_manual() -> None: - try: - raw = str(input("Or paste the redirect URL / authorization code: ")) - except Exception: - return - if done.is_set() or not raw.strip(): - return - try: - code = _normalize_manual_code(raw, expected_state) - except Exception as e: # noqa: BLE001 - print(f"Invalid paste: {e}") - return - if code: - manual_code["code"] = code - done.set() - - manual_thread = threading.Thread(target=_read_manual, daemon=True) - manual_thread.start() + # 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 bool( + os.environ.get("DROIDRUN_OAUTH_MANUAL") + ) + if enable_manual: + def _read_manual() -> None: + try: + raw = str(input("Or paste the redirect URL / authorization code: ")) + except Exception: + return + if done.is_set() or not raw.strip(): + return + try: + code = _normalize_manual_code(raw, expected_state) + except Exception as e: # noqa: BLE001 + print(f"Invalid paste: {e}") + return + if code: + manual_code["code"] = code + done.set() + + 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.") diff --git a/mobilerun/agent/utils/oauth/openai_oauth_llm.py b/mobilerun/agent/utils/oauth/openai_oauth_llm.py index 0852f362..dc2c66d9 100644 --- a/mobilerun/agent/utils/oauth/openai_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/openai_oauth_llm.py @@ -638,24 +638,34 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A003 if open_browser: webbrowser.open(auth_url) - def _read_manual() -> None: - try: - raw = str(input("Or paste the redirect URL / authorization code: ")) - except Exception: - return - if done.is_set() or not raw.strip(): - return - try: - code = _normalize_manual_code(raw, state) - except Exception as e: # noqa: BLE001 - print(f"Invalid paste: {e}") - return - if code: - manual_code["code"] = code - done.set() - - manual_thread = threading.Thread(target=_read_manual, daemon=True) - manual_thread.start() + # 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 bool( + os.environ.get("DROIDRUN_OAUTH_MANUAL") + ) + if enable_manual: + def _read_manual() -> None: + try: + raw = str(input("Or paste the redirect URL / authorization code: ")) + except Exception: + return + if done.is_set() or not raw.strip(): + return + try: + code = _normalize_manual_code(raw, state) + except Exception as e: # noqa: BLE001 + print(f"Invalid paste: {e}") + return + if code: + manual_code["code"] = code + done.set() + + 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.") From 0e9f9533a265b3c28cd3fe7be591f10eda3388c5 Mon Sep 17 00:00:00 2001 From: Mariozada Date: Mon, 13 Apr 2026 16:15:04 +1000 Subject: [PATCH 06/12] chore: clean up dead code --- mobilerun/agent/utils/oauth/openai_oauth_llm.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/mobilerun/agent/utils/oauth/openai_oauth_llm.py b/mobilerun/agent/utils/oauth/openai_oauth_llm.py index dc2c66d9..e2ce3621 100644 --- a/mobilerun/agent/utils/oauth/openai_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/openai_oauth_llm.py @@ -612,7 +612,6 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A003 ) return self.login_manual( open_browser=open_browser, - callback_host=callback_host, callback_port=callback_port, callback_path=callback_path, redirect_host=redirect_host, @@ -698,7 +697,6 @@ def login_manual( *, open_browser: bool = True, input_fn: Any = input, - callback_host: str = DEFAULT_OPENAI_OAUTH_CALLBACK_HOST, callback_port: int = DEFAULT_OPENAI_OAUTH_CALLBACK_PORT, callback_path: str = DEFAULT_OPENAI_OAUTH_CALLBACK_PATH, redirect_host: str = DEFAULT_OPENAI_OAUTH_CALLBACK_HOST, From 167ef29a8273027a6d0f6220146b8866d3b062c0 Mon Sep 17 00:00:00 2001 From: Mariozada Date: Thu, 30 Apr 2026 19:59:26 +1000 Subject: [PATCH 07/12] fix: retry once on invalid manual OAuth paste instead of hanging until timeout --- .../oauth/gemini_oauth_code_assist_llm.py | 48 +++++++++++++------ 1 file changed, 34 insertions(+), 14 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 44256b0a..ed8b1cf1 100644 --- a/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py +++ b/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py @@ -523,6 +523,7 @@ def login( ) -> str: 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() @@ -596,20 +597,36 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A003 ) if enable_manual: def _read_manual() -> None: - try: - raw = str(input("Or paste the redirect URL / authorization code: ")) - except Exception: - return - if done.is_set() or not raw.strip(): - return - try: - code = _normalize_manual_code(raw, expected_state) - except Exception as e: # noqa: BLE001 - print(f"Invalid paste: {e}") - return - if code: - manual_code["code"] = code - done.set() + 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 + 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.") + 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() @@ -617,6 +634,9 @@ def _read_manual() -> None: 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: From ba8df66b0fd2bcbc5fd5fd18785fecbee86ad1bb Mon Sep 17 00:00:00 2001 From: Mariozada Date: Thu, 30 Apr 2026 20:15:28 +1000 Subject: [PATCH 08/12] fix: apply same manual-paste retry to OpenAI and Anthropic OAuth flows --- .../agent/utils/oauth/anthropic_oauth_llm.py | 48 +++++++++++++------ .../agent/utils/oauth/openai_oauth_llm.py | 48 +++++++++++++------ 2 files changed, 68 insertions(+), 28 deletions(-) diff --git a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py index a5fa1b30..14a3ac87 100644 --- a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py @@ -409,6 +409,7 @@ def login( ) -> str: 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() @@ -487,20 +488,36 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A003 ) if enable_manual: def _read_manual() -> None: - try: - raw = str(input("Or paste the redirect URL / authorization code: ")) - except Exception: - return - if done.is_set() or not raw.strip(): - return - try: - code = _normalize_manual_code(raw, state) - except Exception as e: # noqa: BLE001 - print(f"Invalid paste: {e}") - return - if code: - manual_code["code"] = code - done.set() + 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 + 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.") + 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() @@ -508,6 +525,9 @@ def _read_manual() -> None: 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: diff --git a/mobilerun/agent/utils/oauth/openai_oauth_llm.py b/mobilerun/agent/utils/oauth/openai_oauth_llm.py index e2ce3621..9fd757c4 100644 --- a/mobilerun/agent/utils/oauth/openai_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/openai_oauth_llm.py @@ -569,6 +569,7 @@ def login( 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)) @@ -648,20 +649,36 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A003 ) if enable_manual: def _read_manual() -> None: - try: - raw = str(input("Or paste the redirect URL / authorization code: ")) - except Exception: - return - if done.is_set() or not raw.strip(): - return - try: - code = _normalize_manual_code(raw, state) - except Exception as e: # noqa: BLE001 - print(f"Invalid paste: {e}") - return - if code: - manual_code["code"] = code - done.set() + 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 + 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.") + 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() @@ -669,6 +686,9 @@ def _read_manual() -> None: 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: From 8545f03e14de523e4cf17b416694c48edff97b4c Mon Sep 17 00:00:00 2001 From: Mariozada Date: Thu, 30 Apr 2026 21:05:36 +1000 Subject: [PATCH 09/12] fix: strict DROIDRUN_OAUTH_MANUAL check and remove Gemini refresh-token guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DROIDRUN_OAUTH_MANUAL now only enables manual paste for "1", "true", or "yes" instead of any non-empty value. Removed the hard-fail guard when Google omits a refresh token — the access token is still usable. --- mobilerun/agent/utils/oauth/anthropic_oauth_llm.py | 6 +++--- .../utils/oauth/gemini_oauth_code_assist_llm.py | 12 +++--------- mobilerun/agent/utils/oauth/openai_oauth_llm.py | 6 +++--- 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py index 14a3ac87..3dfe0271 100644 --- a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py @@ -483,9 +483,9 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A003 # 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 bool( - os.environ.get("DROIDRUN_OAUTH_MANUAL") - ) + 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): 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 ed8b1cf1..f0a884c2 100644 --- a/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py +++ b/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py @@ -465,12 +465,6 @@ def _exchange_authorization_code( if isinstance(refresh_token, str) and refresh_token: self._cached_refresh_token = refresh_token - if not self._cached_refresh_token: - raise RuntimeError( - "No refresh token received from Google. " - "Revoke access at https://myaccount.google.com/permissions and retry." - ) - expires_in = data.get("expires_in", 3600) try: expires_in_s = int(expires_in) @@ -592,9 +586,9 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A003 # 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 bool( - os.environ.get("DROIDRUN_OAUTH_MANUAL") - ) + 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): diff --git a/mobilerun/agent/utils/oauth/openai_oauth_llm.py b/mobilerun/agent/utils/oauth/openai_oauth_llm.py index 9fd757c4..c5e13c7e 100644 --- a/mobilerun/agent/utils/oauth/openai_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/openai_oauth_llm.py @@ -644,9 +644,9 @@ def log_message(self, format: str, *args: Any) -> None: # noqa: A003 # 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 bool( - os.environ.get("DROIDRUN_OAUTH_MANUAL") - ) + 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): From 2d838735a180af820191c0b0907c2f7921b7424c Mon Sep 17 00:00:00 2001 From: Mariozada Date: Thu, 30 Apr 2026 21:16:26 +1000 Subject: [PATCH 10/12] fix: prevent callback race on manual failure and add retry to login_manual Check done before setting manual_failed so a late browser callback is not masked by a bad paste. Also add one-retry logic to the standalone login_manual() path in all three OAuth helpers. --- .../agent/utils/oauth/anthropic_oauth_llm.py | 51 ++++++++++++------- .../oauth/gemini_oauth_code_assist_llm.py | 41 ++++++++++----- .../agent/utils/oauth/openai_oauth_llm.py | 51 ++++++++++++------- 3 files changed, 96 insertions(+), 47 deletions(-) diff --git a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py index 3dfe0271..57d6cf57 100644 --- a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py @@ -501,8 +501,9 @@ def _read_manual() -> None: if attempt == 0: print("Invalid paste. Try again.") continue - manual_failed.set() - done.set() + if not done.is_set(): + manual_failed.set() + done.set() return try: code = _normalize_manual_code(raw, state) @@ -511,8 +512,9 @@ def _read_manual() -> None: print("Invalid paste. Try again.") continue print("Invalid paste.") - manual_failed.set() - done.set() + if not done.is_set(): + manual_failed.set() + done.set() return if code: manual_code["code"] = code @@ -576,20 +578,33 @@ def login_manual( print(f"Open this URL to login:\n{auth_url}") if open_browser: webbrowser.open(auth_url) - code = _normalize_manual_code( - str(input_fn("Paste the redirect URL or authorization code: ")), - state, - ) - if not code: - raise ValueError("Authorization code was empty.") - - return self._exchange_authorization_code( - code=code, - redirect_uri=redirect_uri, - code_verifier=code_verifier, - state=state, - expires_in=expires_in, - ) + for attempt in range(2): + raw = str(input_fn("Paste the redirect URL or authorization code: ")) + 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: + 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 paste. Try again.") + continue + raise RuntimeError("Login failed.") + raise RuntimeError("Login failed.") 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 f0a884c2..29ec70bd 100644 --- a/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py +++ b/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py @@ -604,8 +604,9 @@ def _read_manual() -> None: if attempt == 0: print("Invalid paste. Try again.") continue - manual_failed.set() - done.set() + if not done.is_set(): + manual_failed.set() + done.set() return try: code = _normalize_manual_code(raw, expected_state) @@ -614,8 +615,9 @@ def _read_manual() -> None: print("Invalid paste. Try again.") continue print("Invalid paste.") - manual_failed.set() - done.set() + if not done.is_set(): + manual_failed.set() + done.set() return if code: manual_code["code"] = code @@ -678,14 +680,29 @@ def login_manual( if open_browser: webbrowser.open(auth_url) - raw = str(input_fn("Paste the redirect URL or authorization code: ")) - code = _normalize_manual_code(raw, expected_state) - if not code: - raise RuntimeError("Authorization code was empty.") - - return self._exchange_authorization_code( - code, redirect_uri, code_verifier=code_verifier - ) + for attempt in range(2): + raw = str(input_fn("Paste the redirect URL or authorization code: ")) + if not raw.strip(): + if attempt == 0: + print("Invalid paste. 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.") + continue + raise RuntimeError("Login failed.") + if code: + return self._exchange_authorization_code( + code, redirect_uri, code_verifier=code_verifier + ) + if attempt == 0: + print("Invalid paste. Try again.") + continue + raise RuntimeError("Login failed.") + raise RuntimeError("Login failed.") 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 c5e13c7e..550dc9fb 100644 --- a/mobilerun/agent/utils/oauth/openai_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/openai_oauth_llm.py @@ -662,8 +662,9 @@ def _read_manual() -> None: if attempt == 0: print("Invalid paste. Try again.") continue - manual_failed.set() - done.set() + if not done.is_set(): + manual_failed.set() + done.set() return try: code = _normalize_manual_code(raw, state) @@ -672,8 +673,9 @@ def _read_manual() -> None: print("Invalid paste. Try again.") continue print("Invalid paste.") - manual_failed.set() - done.set() + if not done.is_set(): + manual_failed.set() + done.set() return if code: manual_code["code"] = code @@ -744,19 +746,34 @@ def login_manual( if open_browser: webbrowser.open(auth_url) - raw = str(input_fn("Paste the redirect URL or authorization code: ")) - code = _normalize_manual_code(raw, state) - if not code: - raise RuntimeError("Authorization code was empty.") - - 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 + for attempt in range(2): + raw = str(input_fn("Paste the redirect URL or authorization code: ")) + 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.") def _ensure_access_token(self) -> OpenAIOAuthCredentials: creds = self._oauth_manager.get_valid_credentials(skew_ms=self._oauth_refresh_skew_ms) From 6a352649ba7148aab0401d7f5e7af9325a9d631a Mon Sep 17 00:00:00 2001 From: Mariozada Date: Thu, 30 Apr 2026 21:23:05 +1000 Subject: [PATCH 11/12] chore: remove unused socket import and trailing whitespace --- mobilerun/agent/utils/oauth/anthropic_oauth_llm.py | 1 - mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py | 1 - 2 files changed, 2 deletions(-) diff --git a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py index 57d6cf57..3460b762 100644 --- a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py @@ -607,7 +607,6 @@ def login_manual( raise RuntimeError("Login failed.") finally: self.authorize_url = original_authorize_url - def _resolve_access_token(self) -> str: env_access_token = os.environ.get("ANTHROPIC_OAUTH_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 29ec70bd..874d3d03 100644 --- a/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py +++ b/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py @@ -3,7 +3,6 @@ import json import os import secrets -import socket import sys import threading import time From 5aec952f76ccd685977f6332c0dbea84806fb36f Mon Sep 17 00:00:00 2001 From: Mariozada Date: Thu, 30 Apr 2026 21:39:03 +1000 Subject: [PATCH 12/12] fix: surface OAuth error redirects instead of treating them as auth codes --- mobilerun/agent/utils/oauth/anthropic_oauth_llm.py | 6 +++++- mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py | 6 +++++- mobilerun/agent/utils/oauth/openai_oauth_llm.py | 6 +++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py index 3460b762..ce723f07 100644 --- a/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/anthropic_oauth_llm.py @@ -83,9 +83,13 @@ def _normalize_manual_code(raw: str, expected_state: str) -> str: first_token = value.split()[0] - if "code=" in first_token: + 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: 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 874d3d03..a4ec51f3 100644 --- a/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py +++ b/mobilerun/agent/utils/oauth/gemini_oauth_code_assist_llm.py @@ -78,9 +78,13 @@ def _normalize_manual_code(raw: str, expected_state: str) -> str: first_token = value.split()[0] - if "code=" in first_token: + 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: diff --git a/mobilerun/agent/utils/oauth/openai_oauth_llm.py b/mobilerun/agent/utils/oauth/openai_oauth_llm.py index 550dc9fb..f2159bdc 100644 --- a/mobilerun/agent/utils/oauth/openai_oauth_llm.py +++ b/mobilerun/agent/utils/oauth/openai_oauth_llm.py @@ -81,9 +81,13 @@ def _normalize_manual_code(raw: str, expected_state: str) -> str: first_token = value.split()[0] - if "code=" in first_token: + 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: