fix(xai): accept Grok Build code during loopback wait + tiny screenshot guard
xAI's consent page renders the authorization code in-page instead of redirecting to the loopback callback, so the listener just hangs and the manual-paste flow demands a callback URL that never contains the token. - auth.py: poll stdin non-blockingly while waiting for the xAI loopback callback; accept a pasted bare Grok Build code and substitute the locally generated state (PKCE code_verifier still binds the exchange). No need to wait for timeout or re-run with --manual-paste. - computer_use: parse PNG/JPEG dimensions from base64 and fall back to the text/AX/SOM payload when the screenshot is below the provider minimum (8x8), which xAI rejects with HTTP 400. - model_setup_flows.py: xAI credential reuse prompt uses the standard radio picker via a shared _prompt_auth_credentials_choice helper. - main.py: thread a title through _prompt_provider_choice; re-home the helper import (flows live in model_setup_flows.py post-decomposition). Salvaged from #36781 onto current main (contributor's main.py edits re-homed to model_setup_flows.py, where the flows were extracted since the PR opened).
This commit is contained in:
+35
-3
@@ -2665,12 +2665,23 @@ def _xai_wait_for_callback(
|
||||
result: dict[str, Any],
|
||||
*,
|
||||
timeout_seconds: float = 180.0,
|
||||
manual_paste_redirect_uri: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
deadline = time.monotonic() + max(5.0, timeout_seconds)
|
||||
if manual_paste_redirect_uri and sys.stdin.isatty():
|
||||
print()
|
||||
print("If xAI shows a Grok Build code instead of redirecting,")
|
||||
print("paste that code here and press Enter.")
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
if result["code"] or result["error"]:
|
||||
return result
|
||||
if manual_paste_redirect_uri:
|
||||
raw_paste = _read_ready_stdin_line()
|
||||
if raw_paste and raw_paste.strip():
|
||||
pasted = _parse_pasted_callback(raw_paste)
|
||||
pasted["_manual_paste"] = True
|
||||
return pasted
|
||||
time.sleep(0.1)
|
||||
finally:
|
||||
server.shutdown()
|
||||
@@ -2694,6 +2705,21 @@ def _xai_wait_for_callback(
|
||||
)
|
||||
|
||||
|
||||
def _read_ready_stdin_line() -> Optional[str]:
|
||||
"""Return one pending stdin line without blocking, if the terminal has one."""
|
||||
try:
|
||||
if not sys.stdin.isatty():
|
||||
return None
|
||||
import select
|
||||
|
||||
ready, _, _ = select.select([sys.stdin], [], [], 0)
|
||||
if not ready:
|
||||
return None
|
||||
return sys.stdin.readline()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _spotify_token_payload_to_state(
|
||||
token_payload: Dict[str, Any],
|
||||
*,
|
||||
@@ -6669,6 +6695,7 @@ def _xai_oauth_loopback_login(
|
||||
authorization_endpoint = discovery["authorization_endpoint"]
|
||||
token_endpoint = discovery["token_endpoint"]
|
||||
|
||||
allow_missing_state = False
|
||||
if manual_paste:
|
||||
# No HTTP listener — synthesize a redirect_uri matching what
|
||||
# the server would have bound to so the authorize URL the user
|
||||
@@ -6695,6 +6722,7 @@ def _xai_oauth_loopback_login(
|
||||
print("Open this URL to authorize Hermes with xAI:")
|
||||
print(authorize_url)
|
||||
callback = _prompt_manual_callback_paste(redirect_uri)
|
||||
allow_missing_state = True
|
||||
else:
|
||||
server, thread, callback_result, redirect_uri = _xai_start_callback_server()
|
||||
try:
|
||||
@@ -6734,6 +6762,7 @@ def _xai_oauth_loopback_login(
|
||||
thread,
|
||||
callback_result,
|
||||
timeout_seconds=max(30.0, timeout_seconds * 9),
|
||||
manual_paste_redirect_uri=redirect_uri,
|
||||
)
|
||||
except AuthError as exc:
|
||||
if (
|
||||
@@ -6750,6 +6779,7 @@ def _xai_oauth_loopback_login(
|
||||
callback = _prompt_manual_callback_paste(redirect_uri)
|
||||
if callback.get("code") is None and callback.get("error") is None:
|
||||
raise exc
|
||||
allow_missing_state = True
|
||||
except Exception:
|
||||
try:
|
||||
server.shutdown()
|
||||
@@ -6770,7 +6800,7 @@ def _xai_oauth_loopback_login(
|
||||
code="xai_authorization_failed",
|
||||
)
|
||||
callback_state = callback.get("state")
|
||||
# Manual-paste bare-code path: when a user pastes only the opaque
|
||||
# Manual bare-code paths: when a user pastes only the opaque
|
||||
# authorization code (no ``code=``/``state=`` query parameters),
|
||||
# ``_parse_pasted_callback`` returns ``state=None``. xAI's consent
|
||||
# page renders the code in-page rather than redirecting through the
|
||||
@@ -6778,10 +6808,12 @@ def _xai_oauth_loopback_login(
|
||||
# VPS, container consoles) the bare code is the only thing the user
|
||||
# can obtain. PKCE (code_verifier) still binds the exchange to this
|
||||
# client, so the local state-equality check is redundant on the
|
||||
# bare-code path — we substitute the locally generated state to keep
|
||||
# bare-code paths — we substitute the locally generated state to keep
|
||||
# the rest of the validation chain (and the token exchange) unchanged.
|
||||
# See #26923 (AccursedGalaxy comment, 2026-05-20).
|
||||
if callback_state is None and manual_paste:
|
||||
if callback.get("_manual_paste"):
|
||||
allow_missing_state = True
|
||||
if callback_state is None and (manual_paste or allow_missing_state):
|
||||
callback_state = state
|
||||
if callback_state != state:
|
||||
raise AuthError(
|
||||
|
||||
+10
-4
@@ -499,6 +499,7 @@ from hermes_cli import __version__, __release_date__
|
||||
# (god-file decomposition Phase 2). Re-imported here so select_provider_and_model and
|
||||
# existing test monkeypatches (hermes_cli.main._model_flow_*) keep resolving unchanged.
|
||||
from hermes_cli.model_setup_flows import (
|
||||
_prompt_auth_credentials_choice,
|
||||
_model_flow_openrouter,
|
||||
_model_flow_nous,
|
||||
_model_flow_openai_codex,
|
||||
@@ -2830,7 +2831,12 @@ def select_provider_and_model(args=None):
|
||||
member_labels = [
|
||||
provider_labels.get(m, m) for m in selected_members
|
||||
]
|
||||
member_idx = _prompt_provider_choice(member_labels, default=member_default)
|
||||
group_label = ordered[provider_idx][1].split(" ▸", 1)[0]
|
||||
member_idx = _prompt_provider_choice(
|
||||
member_labels,
|
||||
default=member_default,
|
||||
title=f"Select {group_label} provider:",
|
||||
)
|
||||
if member_idx is None:
|
||||
print("No change.")
|
||||
return
|
||||
@@ -3331,7 +3337,7 @@ def _aux_flow_custom_endpoint(task: str, task_cfg: dict) -> None:
|
||||
print(f"{display_name}: custom ({short_url})" + (f" · {model}" if model else ""))
|
||||
|
||||
|
||||
def _prompt_provider_choice(choices, *, default=0):
|
||||
def _prompt_provider_choice(choices, *, default=0, title="Select provider:"):
|
||||
"""Show provider selection menu with curses arrow-key navigation.
|
||||
|
||||
Falls back to a numbered list when curses is unavailable (e.g. piped
|
||||
@@ -3341,7 +3347,7 @@ def _prompt_provider_choice(choices, *, default=0):
|
||||
try:
|
||||
from hermes_cli.setup import _curses_prompt_choice
|
||||
|
||||
idx = _curses_prompt_choice("Select provider:", choices, default)
|
||||
idx = _curses_prompt_choice(title, choices, default)
|
||||
if idx >= 0:
|
||||
print()
|
||||
return idx
|
||||
@@ -3349,7 +3355,7 @@ def _prompt_provider_choice(choices, *, default=0):
|
||||
pass
|
||||
|
||||
# Fallback: numbered list
|
||||
print("Select provider:")
|
||||
print(title)
|
||||
for i, c in enumerate(choices, 1):
|
||||
marker = "→" if i - 1 == default else " "
|
||||
print(f" {marker} {i}. {c}")
|
||||
|
||||
@@ -25,6 +25,44 @@ import os
|
||||
import subprocess
|
||||
|
||||
|
||||
def _prompt_auth_credentials_choice(title: str) -> str:
|
||||
"""Prompt for reuse / reauthenticate / cancel with the standard radio UI.
|
||||
|
||||
Returns one of ``"use"``, ``"reauth"``, ``"cancel"``. Falls back to a
|
||||
numbered prompt when curses is unavailable (piped stdin, non-TTY).
|
||||
"""
|
||||
choices = [
|
||||
"Use existing credentials",
|
||||
"Reauthenticate (new OAuth login)",
|
||||
"Cancel",
|
||||
]
|
||||
try:
|
||||
from hermes_cli.setup import _curses_prompt_choice
|
||||
|
||||
idx = _curses_prompt_choice(title, choices, 0)
|
||||
if idx >= 0:
|
||||
print()
|
||||
return ("use", "reauth", "cancel")[idx]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print(title)
|
||||
for i, label in enumerate(choices, 1):
|
||||
marker = "→" if i == 1 else " "
|
||||
print(f" {marker} {i}. {label}")
|
||||
print()
|
||||
try:
|
||||
choice = input(" Choice [1/2/3]: ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
choice = "1"
|
||||
|
||||
if choice == "2":
|
||||
return "reauth"
|
||||
if choice == "3":
|
||||
return "cancel"
|
||||
return "use"
|
||||
|
||||
|
||||
def _model_flow_openrouter(config, current_model=""):
|
||||
"""OpenRouter provider: ensure API key, then pick model."""
|
||||
from hermes_cli.main import _prompt_api_key
|
||||
@@ -321,16 +359,9 @@ def _model_flow_openai_codex(config, current_model=""):
|
||||
if status.get("logged_in"):
|
||||
print(" OpenAI Codex credentials: ✓")
|
||||
print()
|
||||
print(" 1. Use existing credentials")
|
||||
print(" 2. Reauthenticate (new OAuth login)")
|
||||
print(" 3. Cancel")
|
||||
print()
|
||||
try:
|
||||
choice = input(" Choice [1/2/3]: ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
choice = "1"
|
||||
choice = _prompt_auth_credentials_choice("OpenAI Codex credentials:")
|
||||
|
||||
if choice == "2":
|
||||
if choice == "reauth":
|
||||
print("Starting a fresh OpenAI Codex login...")
|
||||
print()
|
||||
try:
|
||||
@@ -350,7 +381,7 @@ def _model_flow_openai_codex(config, current_model=""):
|
||||
if not status.get("logged_in"):
|
||||
print("Login failed.")
|
||||
return
|
||||
elif choice == "3":
|
||||
elif choice == "cancel":
|
||||
return
|
||||
else:
|
||||
print("Not logged into OpenAI Codex. Starting login...")
|
||||
@@ -411,16 +442,11 @@ def _model_flow_xai_oauth(_config, current_model="", *, args=None):
|
||||
if status.get("logged_in"):
|
||||
print(" xAI Grok OAuth (SuperGrok / Premium+) credentials: ✓")
|
||||
print()
|
||||
print(" 1. Use existing credentials")
|
||||
print(" 2. Reauthenticate (new OAuth login)")
|
||||
print(" 3. Cancel")
|
||||
print()
|
||||
try:
|
||||
choice = input(" Choice [1/2/3]: ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
choice = "1"
|
||||
choice = _prompt_auth_credentials_choice(
|
||||
"xAI Grok OAuth (SuperGrok / Premium+) credentials:"
|
||||
)
|
||||
|
||||
if choice == "2":
|
||||
if choice == "reauth":
|
||||
print("Starting a fresh xAI OAuth login...")
|
||||
print()
|
||||
try:
|
||||
@@ -444,7 +470,7 @@ def _model_flow_xai_oauth(_config, current_model="", *, args=None):
|
||||
except Exception as exc:
|
||||
print(f"Login failed: {exc}")
|
||||
return
|
||||
elif choice == "3":
|
||||
elif choice == "cancel":
|
||||
return
|
||||
else:
|
||||
print("Not logged into xAI Grok OAuth (SuperGrok / Premium+). Starting login...")
|
||||
@@ -2560,20 +2586,13 @@ def _model_flow_anthropic(config, current_model=""):
|
||||
elif cc_available:
|
||||
print(" Claude Code credentials: ✓ (auto-detected)")
|
||||
print()
|
||||
print(" 1. Use existing credentials")
|
||||
print(" 2. Reauthenticate (new OAuth login)")
|
||||
print(" 3. Cancel")
|
||||
print()
|
||||
try:
|
||||
choice = input(" Choice [1/2/3]: ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
choice = "1"
|
||||
choice = _prompt_auth_credentials_choice("Anthropic credentials:")
|
||||
|
||||
if choice == "2":
|
||||
if choice == "reauth":
|
||||
needs_auth = True
|
||||
elif choice == "3":
|
||||
elif choice == "cancel":
|
||||
return
|
||||
# choice == "1" or default: use existing, proceed to model selection
|
||||
# choice == "use" or default: use existing, proceed to model selection
|
||||
|
||||
if needs_auth:
|
||||
# Show auth method choice
|
||||
|
||||
Reference in New Issue
Block a user