diff --git a/hermes_cli/auth_codex.py b/hermes_cli/auth_codex.py index c673cc8924..7bf6e7b913 100644 --- a/hermes_cli/auth_codex.py +++ b/hermes_cli/auth_codex.py @@ -192,30 +192,25 @@ def _refresh_payload_access_token( *invalid_json*'s message is formatted with the JSON error. *strict_str* rejects non-string access tokens; otherwise they are ``str()``-coerced. """ + def _err(message: str, code: str, relogin: bool = relogin_required) -> AuthError: + return AuthError(message, provider=provider, code=code, relogin_required=relogin) + try: payload = response.json() except Exception as exc: - raise AuthError( - invalid_json[0].format(exc=exc), provider=provider, code=invalid_json[1], - relogin_required=( - relogin_required if invalid_json_relogin is None else invalid_json_relogin), - ) from exc + relogin = relogin_required if invalid_json_relogin is None else invalid_json_relogin + raise _err(invalid_json[0].format(exc=exc), invalid_json[1], relogin) from exc if not isinstance(payload, dict): - if invalid_response is None: - payload = {} - else: - raise AuthError( - invalid_response[0], provider=provider, code=invalid_response[1], - relogin_required=relogin_required) + if invalid_response is not None: + raise _err(*invalid_response) + payload = {} access = payload.get("access_token") if strict_str: access = access.strip() if isinstance(access, str) else "" else: access = _stripped(access) if not access: - raise AuthError( - missing_access[0], provider=provider, code=missing_access[1], - relogin_required=relogin_required) + raise _err(*missing_access) return payload, access @@ -301,7 +296,6 @@ def refresh_codex_oauth_pure( if not _nonempty_str(refresh_token): raise _codex_err( _MISSING_REFRESH_TOKEN_MSG, "codex_auth_missing_refresh_token", relogin=True) - timeout = httpx.Timeout(max(5.0, float(timeout_seconds))) with _codex_http_client( timeout=timeout, @@ -313,7 +307,6 @@ def refresh_codex_oauth_pure( data={ "grant_type": "refresh_token", "refresh_token": refresh_token, "client_id": CODEX_OAUTH_CLIENT_ID}) - if response.status_code == 429: # Quota exhaustion on the token endpoint: the refresh token is still valid and re-auth # cannot lift a quota cap, so classify distinctly from auth failures ("retry later"). @@ -321,7 +314,6 @@ def refresh_codex_oauth_pure( _parse_retry_after_seconds(getattr(response, "headers", None))) if response.status_code != 200: raise _codex_refresh_failure_error(response) - refresh_payload, refreshed_access = _refresh_payload_access_token( response, provider="openai-codex", invalid_json=("Codex token refresh returned invalid JSON.", "codex_refresh_invalid_json"), @@ -415,7 +407,6 @@ def resolve_codex_runtime_credentials( str(getattr(exc, "code", None) or "auth_error")) if imported: data = {"tokens": imported, "last_refresh": imported.get("last_refresh")} - if data is None: pool_token = _pool_codex_access_token() if pool_token: @@ -442,7 +433,6 @@ def resolve_codex_runtime_credentials( if read_error is not None: raise read_error raise _codex_err(_NO_CREDENTIALS_MSG, "codex_auth_missing", relogin=True) - tokens = dict(data["tokens"]) access_token = _stripped(tokens.get("access_token")) refresh_timeout_seconds = env_float("HERMES_CODEX_REFRESH_TIMEOUT_SECONDS", 20) @@ -526,7 +516,6 @@ def _probe_codex_quota_restored( return cached[1] # Reserve the slot immediately so concurrent selectors don't stampede the endpoint. _codex_quota_probe_cache[cache_key] = (now, None) - result: Optional[bool] = None try: headers = { @@ -554,7 +543,6 @@ def _probe_codex_quota_restored( except Exception: logger.debug("Codex quota probe failed", exc_info=True) result = None - with _codex_quota_probe_lock: _codex_quota_probe_cache[cache_key] = (now, result) return result @@ -660,7 +648,6 @@ def _login_openai_codex(args, pconfig: ProviderConfig, *, force_new_login: bool _offer_existing_oauth_credentials, _print_login_success, _prompt_yes_no, _save_codex_tokens, _update_config_for_provider, resolve_codex_runtime_credentials) del args, pconfig # kept for parity with other provider login helpers - if not force_new_login: if _offer_existing_oauth_credentials( "openai-codex", resolve=resolve_codex_runtime_credentials, @@ -728,14 +715,12 @@ def _codex_request_device_code(issuer: str, client_id: str) -> Dict[str, Any]: delay = max(1, min(int(retry_after if retry_after is not None else 2 ** attempt), 60)) print(f"OpenAI is rate-limiting login requests (429); retrying in {delay}s...") time.sleep(delay) - if resp is not None and resp.status_code == 429: raise _codex_login_rate_limited_error(resp) if resp is None or resp.status_code != 200: status = resp.status_code if resp is not None else "unknown" raise _codex_err( f"Device code request returned status {status}.", "device_code_request_error") - device_data = resp.json() device_data["interval"] = max(3, int(device_data.get("interval", "5"))) if not device_data.get("user_code", "") or not device_data.get("device_auth_id", ""): @@ -767,7 +752,6 @@ def _codex_poll_authorization_code( except KeyboardInterrupt: print("\nLogin cancelled.") raise SystemExit(130) - if code_resp is None: raise _codex_err("Login timed out after 15 minutes.", "device_code_timeout") return code_resp @@ -817,7 +801,6 @@ def _codex_device_code_login() -> Dict[str, Any]: print(" 2. Enter this code:") print(f" \033[94m{user_code}\033[0m\n") print("Waiting for sign-in... (press Ctrl+C to cancel)") - code_resp = _codex_poll_authorization_code( issuer, device_auth_id=device_data["device_auth_id"], user_code=user_code, poll_interval=device_data["interval"]) diff --git a/hermes_cli/auth_device_flow.py b/hermes_cli/auth_device_flow.py index 99eb134069..f41b23448e 100644 --- a/hermes_cli/auth_device_flow.py +++ b/hermes_cli/auth_device_flow.py @@ -106,22 +106,16 @@ def _print_loopback_ssh_hint(redirect_uri: str, *, docs_url: str | None = None) if host not in {"127.0.0.1", "::1", "localhost"} or not port: return divider = "-" * 60 - print() - print(divider) - print("Remote session detected — SSH tunnel required") - print(divider) - print(f"Hermes is waiting for the OAuth callback on {redirect_uri}") - print("but your browser is on a different machine. Run this command") - print("in a NEW terminal on your local machine BEFORE opening the URL:") - print() - print(f" ssh -N -L {port}:127.0.0.1:{port} {_ssh_user_at_host()}") - print() - print("Then open the authorize URL above in your local browser.") + print( + f"\n{divider}\nRemote session detected — SSH tunnel required\n{divider}\n" + f"Hermes is waiting for the OAuth callback on {redirect_uri}\n" + "but your browser is on a different machine. Run this command\n" + "in a NEW terminal on your local machine BEFORE opening the URL:\n\n" + f" ssh -N -L {port}:127.0.0.1:{port} {_ssh_user_at_host()}\n\n" + "Then open the authorize URL above in your local browser.") if docs_url: print(f"Provider docs: {docs_url}") - print(f"SSH/jump-box guide: {OAUTH_OVER_SSH_DOCS_URL}") - print(divider) - print() + print(f"SSH/jump-box guide: {OAUTH_OVER_SSH_DOCS_URL}\n{divider}\n") def _default_verify() -> bool | ssl.SSLContext: diff --git a/hermes_cli/auth_nous.py b/hermes_cli/auth_nous.py index 40d7f484c6..c3d0da7685 100644 --- a/hermes_cli/auth_nous.py +++ b/hermes_cli/auth_nous.py @@ -467,9 +467,8 @@ def _quarantine_forensics(state: Dict[str, Any], error: AuthError, reason: str) forensic["auth_json_path"] = str(auth_path) try: st = os.stat(auth_path) - forensic["auth_json_size"] = st.st_size - forensic["auth_json_mtime"] = st.st_mtime - forensic["auth_json_exists"] = True + forensic.update( + auth_json_size=st.st_size, auth_json_mtime=st.st_mtime, auth_json_exists=True) except FileNotFoundError: forensic["auth_json_exists"] = False except Exception as exc: # pragma: no cover - never let logging break quarantine @@ -504,7 +503,6 @@ def _quarantine_nous_oauth_state(state: Dict[str, Any], error: AuthError, *, rea logger.warning( "Nous OAuth state quarantined (terminal auth death): %s", json.dumps(_quarantine_forensics(state, error, reason), sort_keys=True, ensure_ascii=False)) - for key in (*_FLAT_OAUTH_TOKEN_KEYS, *_NOUS_EMPTY_AGENT_KEY_FIELDS): state.pop(key, None) state["last_auth_error"] = _last_auth_error_marker("nous", error, reason=reason) @@ -577,12 +575,10 @@ def _refresh_access_token( if "access_token" not in payload: raise _nous_err("Refresh response missing access_token", "invalid_token", relogin=True) return payload - try: error_payload = response.json() except Exception as exc: raise _nous_err("Refresh token exchange failed", relogin=True) from exc - code = str(error_payload.get("error", "invalid_grant")) description = str(error_payload.get("error_description") or "Refresh token exchange failed") relogin = code in {"invalid_grant", "invalid_token", "refresh_token_reused"} @@ -601,7 +597,6 @@ def _refresh_access_token( "instead.\n" "Re-authenticate with: hermes auth add nous") relogin = True - raise _nous_err(description, code, relogin=relogin) @@ -694,7 +689,6 @@ def fetch_nous_models( except Exception as e: logger.debug("Could not parse error response JSON: %s", e) raise _nous_err(description, "models_fetch_failed") - data = response.json().get("data") if not isinstance(data, list): return [] @@ -847,7 +841,6 @@ def _nous_effective_routing(state: Dict[str, Any]) -> tuple[str, str, str, str]: "(host %r or scheme not allowed), using default", portal_url, portal_host) portal_url = DEFAULT_NOUS_PORTAL_URL - stored_inference_url = ( _validate_nous_inference_url_from_network( _optional_base_url(state.get("inference_base_url"))) @@ -983,7 +976,6 @@ class _NousRuntimeResolve: self.persist("runtime_shared_merge_missing_access_token") if not self.has_access_token(): raise _nous_err("No access token found for Nous Portal login.", relogin=True) - invoke_jwt_status = self.invoke_jwt_status() self.skip_refresh_if_peer_rotated() if not (self.force_refresh or invoke_jwt_status is not None): @@ -1036,10 +1028,8 @@ def resolve_nous_runtime_credentials( state["client_id"] = run.client_id state["tls"] = _tls_state_from_verify(verify) run.persist("resolve_nous_runtime_credentials_final") - if run.persisted_any: _sync_nous_pool_from_auth_store() - api_key = state.get("agent_key") if not isinstance(api_key, str) or not api_key: raise _nous_err("Failed to resolve a Nous inference API key", "server_error") @@ -1293,14 +1283,12 @@ def _nous_device_code_login( verify: bool | str = False if insecure else (ca_bundle if ca_bundle else True) if _is_remote_session(): open_browser = False - print(f"Starting Hermes login via {pconfig.name}...") print(f"Portal: {portal_base_url}") if insecure: print("TLS verification: disabled (--insecure)") elif ca_bundle: print(f"TLS verification: custom CA bundle ({ca_bundle})") - with _nous_http_client(timeout_seconds, verify) as client: device_data = _request_device_code( client=client, portal_base_url=portal_base_url, client_id=client_id, scope=scope) @@ -1310,9 +1298,8 @@ def _nous_device_code_login( interval = int(device_data["interval"]) _print_device_code_instructions( verification_url, user_code, open_browser=open_browser, failure_dash="—") - # Surface the URL/code to an out-of-band consumer (e.g. the TUI gateway, whose stdout is - # a JSON-RPC pipe where print() is dropped). Fired AFTER the print/browser block and - # BEFORE polling blocks so the consumer can render the link while we wait. Best-effort. + # Out-of-band consumer (e.g. the TUI gateway, whose stdout is a JSON-RPC pipe): fired AFTER + # the print/browser block and BEFORE polling so it can render the link while we wait. if on_verification is not None: try: on_verification(verification_url, user_code) @@ -1324,7 +1311,6 @@ def _nous_device_code_login( client=client, portal_base_url=portal_base_url, client_id=client_id, device_code=str(device_data["device_code"]), expires_in=expires_in, poll_interval=interval) - now = datetime.now(timezone.utc) token_expires_in = _coerce_ttl_seconds(token_data.get("expires_in", 0)) resolved_inference_url = ( @@ -1393,7 +1379,6 @@ def step_up_nous_billing_scope( requested.append(tok) if NOUS_BILLING_MANAGE_SCOPE not in requested: requested.append(NOUS_BILLING_MANAGE_SCOPE) - auth_state = _nous_device_code_login( portal_base_url=prior.get("portal_base_url") or None, inference_base_url=prior.get("inference_base_url") or None, @@ -1416,14 +1401,12 @@ def _pick_nous_model_after_login( runtime_key = auth_state.get("agent_key") or auth_state.get("access_token") if not isinstance(runtime_key, str) or not runtime_key: raise _nous_err("No runtime API key available to fetch models", "invalid_token") - from hermes_cli.models import ( get_curated_nous_model_ids, get_pricing_for_provider, check_nous_free_tier, partition_nous_models_by_tier, nous_policy_allowed_ids, restrict_to_nous_policy, union_with_portal_free_recommendations, union_with_portal_paid_recommendations) model_ids = get_curated_nous_model_ids() _portal = auth_state.get("portal_base_url", "") - print() unavailable_models: list = [] unavailable_message = "" @@ -1440,8 +1423,7 @@ def _pick_nous_model_after_login( except Exception: unavailable_message = "" # The Portal's free/paidRecommendedModels endpoint is the source of truth for what's - # available *right now*: augment the curated list so users on older Hermes builds still - # see newly-launched models without a CLI release. + # available *right now*: newly-launched models show without a CLI release. union = ( union_with_portal_free_recommendations if free_tier else union_with_portal_paid_recommendations) @@ -1488,10 +1470,9 @@ def _offer_shared_nous_import(timeout_seconds: float) -> Optional[Dict[str, Any] except RuntimeError: shared_path = None print() - if shared_path: - print(f"Found existing Nous OAuth credentials at {shared_path}") - else: - print("Found existing shared Nous OAuth credentials") + print( + f"Found existing Nous OAuth credentials at {shared_path}" if shared_path + else "Found existing shared Nous OAuth credentials") if not _prompt_yes_no("Import these credentials? [Y/n]: ", default="y"): return None print("Rehydrating Nous session from shared credentials...") @@ -1536,21 +1517,19 @@ def _login_nous(args, pconfig: ProviderConfig) -> None: ca_bundle=ca_bundle) inference_base_url = auth_state["inference_base_url"] - # Snapshot the prior active_provider BEFORE _save_provider_state overwrites it to "nous", - # so "Skip (keep current)" in the model picker can restore the user's previous provider. + # Snapshot BEFORE _save_provider_state overwrites active_provider to "nous", so a + # model-picker "Skip (keep current)" can restore the user's previous provider. with _auth_store_lock(): prior_active_provider = _load_auth_store().get("active_provider") saved_to = _save_active_provider_state("nous", auth_state) # Mirror to the shared store so other profiles can one-tap import (best-effort inside). _write_shared_nous_state(auth_state) _sync_nous_pool_from_auth_store() - print() print("Login successful!") print(f" Auth state: {saved_to}") - # Resolve the model BEFORE writing the provider to config.yaml so config is never - # half-updated (provider=nous but model still the previous provider's). + # Pick the model BEFORE writing the provider to config.yaml so config is never half-updated. selected_model = None try: selected_model = _pick_nous_model_after_login(auth_state, inference_base_url) @@ -1559,16 +1538,14 @@ def _login_nous(args, pconfig: ProviderConfig) -> None: print() print(f"Login succeeded, but could not fetch available models. Reason: {message}") - # No model selected (Skip, fetch failed, or nothing curated): preserve the previous - # provider rather than silently switching to Nous with a mismatched model. config.yaml - # model.provider is left untouched; the Nous OAuth tokens stay saved for future use. + # No model (Skip, fetch failed, nothing curated): keep the previous provider rather than + # switch to Nous with a mismatched model; the Nous tokens stay saved for future use. if not selected_model: _restore_active_provider(prior_active_provider) print() print("No provider change. Nous credentials saved for future use.") print(" Run `hermes model` again to switch to Nous Portal.") return - config_path = _update_config_for_provider( "nous", inference_base_url, default_model=selected_model) if selected_model: diff --git a/hermes_cli/auth_oauth_grants.py b/hermes_cli/auth_oauth_grants.py index 332f28ac06..685a338166 100644 --- a/hermes_cli/auth_oauth_grants.py +++ b/hermes_cli/auth_oauth_grants.py @@ -67,7 +67,6 @@ def strip_cloned_single_use_oauth_grants(profile_dir: Path) -> Dict[str, Any]: stripped["files"].append(name) except OSError: logger.debug("Could not remove cloned %s from %s", name, profile_dir, exc_info=True) - auth_path = profile_dir / "auth.json" if not auth_path.is_file(): return stripped @@ -77,7 +76,6 @@ def strip_cloned_single_use_oauth_grants(profile_dir: Path) -> Dict[str, Any]: return stripped if not isinstance(store, dict): return stripped - changed = False pool = store.get("credential_pool") if isinstance(pool, dict): @@ -165,7 +163,6 @@ def _oauth_freshness(entry: Dict[str, Any]) -> float: the live copy; ``last_refresh`` and the JWT ``exp`` claim are fallbacks. """ from agent.credential_pool import _parse_absolute_timestamp - best = 0.0 for key in ("expires_at_ms", "expires_at", "last_refresh"): ts = _parse_absolute_timestamp(entry.get(key)) @@ -516,7 +513,6 @@ def _heal_forked_single_use_oauth_grants(provider_id: str) -> Optional[Dict[str, singleton_row.get("expires_at_ms"), target=root_singleton) if run.profile_changed and profile_path.exists(): _save_auth_store(profile_store, target_path=profile_path) - message = run.notice(profile_home.name) logger.info(message) _oauth_heal_notices.append(message)