diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 6cabb61570d79..c792481f26e8b 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -2585,8 +2585,107 @@ def login_spotify_command(args) -> None: # ============================================================================= def _is_remote_session() -> bool: - """Detect if running in an SSH session where webbrowser.open() won't work.""" - return bool(os.getenv("SSH_CLIENT") or os.getenv("SSH_TTY")) + """Detect environments where loopback OAuth can't reach the local browser. + + Historically only SSH was checked, but #26923 surfaced that + **browser-only remote consoles** (GCP Cloud Shell, GitHub + Codespaces, AWS EC2 Instance Connect, Gitpod, Replit, etc.) hit + the exact same problem — the user has a browser on their laptop + but the loopback listener is bound on the remote VM that the + laptop's browser can't reach. These environments typically don't + set ``SSH_CLIENT`` / ``SSH_TTY``, so the SSH-only check left + them with no guidance and no fallback. + """ + if os.getenv("SSH_CLIENT") or os.getenv("SSH_TTY"): + return True + # Browser-only remote IDEs / cloud shells. Keep this list narrow + # (well-known, documented env vars set by the host platform) so + # we don't falsely trip on a developer's local shell. + for var in ( + "CLOUD_SHELL", # GCP Cloud Shell + "CODESPACES", # GitHub Codespaces + "CODESPACE_NAME", # GitHub Codespaces (alt) + "GITPOD_WORKSPACE_ID", # Gitpod + "REPL_ID", # Replit + "STACKBLITZ", # StackBlitz + ): + if os.getenv(var): + return True + return False + + +def _parse_pasted_callback(raw: str) -> dict: + """Parse a pasted callback URL / query string into the loopback shape. + + Accepts any of: + + * full URL: ``http://127.0.0.1:56121/callback?code=abc&state=xyz`` + * bare query string: ``?code=abc&state=xyz`` or ``code=abc&state=xyz`` + * bare code (no state, only used when the upstream omits state): + ``abc-the-code-value`` + + Returns ``{"code", "state", "error", "error_description"}`` with + missing keys set to ``None`` so the loopback callsites can keep + using the same validation path (state check, error check, etc.) + they already use for the HTTP server output. Regression for + #26923 — formalises the curl-the-callback-URL workaround the + reporter used while waiting for upstream support. + """ + stripped = raw.strip() + result: dict = { + "code": None, + "state": None, + "error": None, + "error_description": None, + } + if not stripped: + return result + query = "" + if stripped.startswith(("http://", "https://")): + try: + parsed = urlparse(stripped) + except Exception: + return result + query = parsed.query or "" + elif stripped.startswith("?"): + query = stripped[1:] + elif "=" in stripped: + # Looks like a bare query fragment (``code=...&state=...``). + query = stripped + else: + # Treat as a bare opaque code value with no state. + result["code"] = stripped + return result + params = parse_qs(query, keep_blank_values=False) + for key in ("code", "state", "error", "error_description"): + values = params.get(key) + if values: + result[key] = values[0] + return result + + +def _prompt_manual_callback_paste(redirect_uri: str) -> dict: + """Read a callback URL from stdin as a fallback for browser-only remotes. + + Used when ``--manual-paste`` is set or when the loopback listener + cannot bind. Returns the parsed callback dict (same shape as the + HTTP handler output) so the existing state / error validation in + the caller works unchanged. See #26923. + """ + print() + print("─── Manual callback paste ─────────────────────────────────────") + print("After approving in your browser, your browser will try to load") + print(f" {redirect_uri}") + print("which fails (the loopback listener is on this remote machine,") + print("not on your laptop) — that is expected. Copy the FULL URL") + print("from your browser's address bar of that failed page and paste") + print("it below. A bare '?code=...&state=...' fragment also works.") + print("───────────────────────────────────────────────────────────────") + try: + raw = input("Callback URL: ") + except (EOFError, KeyboardInterrupt): + raw = "" + return _parse_pasted_callback(raw) def _print_loopback_ssh_hint(redirect_uri: str, *, docs_url: str | None = None) -> None: @@ -2622,6 +2721,10 @@ def _print_loopback_ssh_hint(redirect_uri: str, *, docs_url: str | None = None) print(f" ssh -N -L {port}:127.0.0.1:{port} @") print() print("Then open the authorize URL above in your local browser.") + print() + print("No SSH client (Cloud Shell / Codespaces / web IDE)? Re-run with") + print("`--manual-paste` to skip the loopback listener and paste the failed") + print("callback URL directly.") if docs_url: print(f"Provider docs: {docs_url}") print(f"SSH/jump-box guide: {OAUTH_OVER_SSH_DOCS_URL}") @@ -5267,8 +5370,13 @@ def _login_xai_oauth( open_browser = not getattr(args, "no_browser", False) if _is_remote_session(): open_browser = False + manual_paste = bool(getattr(args, "manual_paste", False)) - creds = _xai_oauth_loopback_login(timeout_seconds=timeout_seconds, open_browser=open_browser) + creds = _xai_oauth_loopback_login( + timeout_seconds=timeout_seconds, + open_browser=open_browser, + manual_paste=manual_paste, + ) _save_xai_oauth_tokens( creds["tokens"], discovery=creds.get("discovery"), @@ -5316,13 +5424,32 @@ def _xai_oauth_loopback_login( *, timeout_seconds: float = 20.0, open_browser: bool = True, + manual_paste: bool = False, ) -> Dict[str, Any]: + """Run the xAI OAuth PKCE flow. + + When ``manual_paste=True`` the loopback HTTP listener is skipped + entirely and the user is prompted to paste the failed callback + URL into stdin (regression fix for #26923 — browser-only remote + consoles like GCP Cloud Shell / GitHub Codespaces / EC2 Instance + Connect, where the laptop's browser can't reach 127.0.0.1 on the + remote VM). The same PKCE verifier, ``state``, and ``nonce`` are + used for both paths so the upstream-side OAuth flow is identical. + """ discovery = _xai_oauth_discovery(timeout_seconds) authorization_endpoint = discovery["authorization_endpoint"] token_endpoint = discovery["token_endpoint"] - server, thread, callback_result, redirect_uri = _xai_start_callback_server() - try: + if manual_paste: + # No HTTP listener — synthesize a redirect_uri matching what + # the server would have bound to so the authorize URL the user + # opens (and the redirect_uri sent in the token exchange) stay + # byte-identical to the loopback path. xAI's token endpoint + # cross-checks redirect_uri against the authorize request. + redirect_uri = ( + f"http://{XAI_OAUTH_REDIRECT_HOST}:{XAI_OAUTH_REDIRECT_PORT}" + f"{XAI_OAUTH_REDIRECT_PATH}" + ) _xai_validate_loopback_redirect_uri(redirect_uri) code_verifier = _oauth_pkce_code_verifier() code_challenge = _oauth_pkce_code_challenge(code_verifier) @@ -5338,38 +5465,57 @@ def _xai_oauth_loopback_login( print("Open this URL to authorize Hermes with xAI:") print(authorize_url) - print() - print(f"Waiting for callback on {redirect_uri}") + callback = _prompt_manual_callback_paste(redirect_uri) + else: + server, thread, callback_result, redirect_uri = _xai_start_callback_server() + try: + _xai_validate_loopback_redirect_uri(redirect_uri) + code_verifier = _oauth_pkce_code_verifier() + code_challenge = _oauth_pkce_code_challenge(code_verifier) + state = uuid.uuid4().hex + nonce = uuid.uuid4().hex + authorize_url = _xai_oauth_build_authorize_url( + authorization_endpoint=authorization_endpoint, + redirect_uri=redirect_uri, + code_challenge=code_challenge, + state=state, + nonce=nonce, + ) - _print_loopback_ssh_hint(redirect_uri, docs_url=XAI_OAUTH_DOCS_URL) + print("Open this URL to authorize Hermes with xAI:") + print(authorize_url) + print() + print(f"Waiting for callback on {redirect_uri}") - if open_browser and not _is_remote_session(): - try: - opened = webbrowser.open(authorize_url) - except Exception: - opened = False - if opened: - print("Browser opened for xAI authorization.") - else: - print("Could not open the browser automatically; use the URL above.") + _print_loopback_ssh_hint(redirect_uri, docs_url=XAI_OAUTH_DOCS_URL) - callback = _xai_wait_for_callback( - server, - thread, - callback_result, - timeout_seconds=max(30.0, timeout_seconds * 9), - ) - except Exception: - try: - server.shutdown() - server.server_close() - except Exception: - pass - try: - thread.join(timeout=1.0) + if open_browser and not _is_remote_session(): + try: + opened = webbrowser.open(authorize_url) + except Exception: + opened = False + if opened: + print("Browser opened for xAI authorization.") + else: + print("Could not open the browser automatically; use the URL above.") + + callback = _xai_wait_for_callback( + server, + thread, + callback_result, + timeout_seconds=max(30.0, timeout_seconds * 9), + ) except Exception: - pass - raise + try: + server.shutdown() + server.server_close() + except Exception: + pass + try: + thread.join(timeout=1.0) + except Exception: + pass + raise if callback.get("error"): detail = callback.get("error_description") or callback["error"] diff --git a/hermes_cli/auth_commands.py b/hermes_cli/auth_commands.py index 10b040d8a1d40..c9a92c20e4926 100644 --- a/hermes_cli/auth_commands.py +++ b/hermes_cli/auth_commands.py @@ -339,6 +339,7 @@ def auth_add_command(args) -> None: creds = auth_mod._xai_oauth_loopback_login( timeout_seconds=getattr(args, "timeout", None) or 20.0, open_browser=not getattr(args, "no_browser", False), + manual_paste=bool(getattr(args, "manual_paste", False)), ) label = (getattr(args, "label", None) or "").strip() or label_from_token( creds["tokens"]["access_token"], diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 41c4a23f93288..00c402495f9c2 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1987,7 +1987,7 @@ def _lookup_ref(name: str, provider_key: str, model: str) -> str: elif selected_provider == "openai-codex": _model_flow_openai_codex(config, current_model) elif selected_provider == "xai-oauth": - _model_flow_xai_oauth(config, current_model) + _model_flow_xai_oauth(config, current_model, args=args) elif selected_provider == "qwen-oauth": _model_flow_qwen_oauth(config, current_model) elif selected_provider == "minimax-oauth": @@ -2869,7 +2869,7 @@ def _model_flow_openai_codex(config, current_model=""): print("No change.") -def _model_flow_xai_oauth(_config, current_model=""): +def _model_flow_xai_oauth(_config, current_model="", *, args=None): """xAI Grok OAuth (SuperGrok Subscription) provider: ensure logged in, then pick model.""" from hermes_cli.auth import ( get_xai_oauth_auth_status, @@ -2900,7 +2900,15 @@ def _model_flow_xai_oauth(_config, current_model=""): print("Starting a fresh xAI OAuth login...") print() try: - mock_args = argparse.Namespace() + # Forward CLI flags from ``hermes model --manual-paste`` + # / ``--no-browser`` / ``--timeout`` into the loopback + # login. Without this, browser-only remotes (#26923) + # can't reach the manual-paste path via ``hermes model``. + mock_args = argparse.Namespace( + manual_paste=bool(getattr(args, "manual_paste", False)), + no_browser=bool(getattr(args, "no_browser", False)), + timeout=getattr(args, "timeout", None), + ) _login_xai_oauth( mock_args, PROVIDER_REGISTRY["xai-oauth"], @@ -2918,7 +2926,11 @@ def _model_flow_xai_oauth(_config, current_model=""): print("Not logged into xAI Grok OAuth (SuperGrok Subscription). Starting login...") print() try: - mock_args = argparse.Namespace() + mock_args = argparse.Namespace( + manual_paste=bool(getattr(args, "manual_paste", False)), + no_browser=bool(getattr(args, "no_browser", False)), + timeout=getattr(args, "timeout", None), + ) _login_xai_oauth(mock_args, PROVIDER_REGISTRY["xai-oauth"]) except SystemExit: print("Login cancelled or failed.") @@ -9747,6 +9759,16 @@ def main(): action="store_true", help="Do not attempt to open the browser automatically during Nous login", ) + model_parser.add_argument( + "--manual-paste", + action="store_true", + help=( + "For loopback OAuth providers (xai-oauth, ...): skip the local " + "callback listener and paste the failed callback URL from your " + "browser instead. Use on browser-only remotes (Cloud Shell, " + "Codespaces, EC2 Instance Connect, ...). See #26923." + ), + ) model_parser.add_argument( "--timeout", type=float, @@ -10203,6 +10225,17 @@ def main(): action="store_true", help="Do not auto-open a browser for OAuth login", ) + auth_add.add_argument( + "--manual-paste", + action="store_true", + help=( + "Skip the loopback callback listener and paste the failed " + "callback URL from your browser instead. Use this on " + "browser-only remotes (GCP Cloud Shell, GitHub Codespaces, " + "EC2 Instance Connect, ...) where 127.0.0.1 on the remote " + "isn't reachable from your laptop. See #26923." + ), + ) auth_add.add_argument( "--timeout", type=float, help="OAuth/network timeout in seconds" ) diff --git a/tests/hermes_cli/test_auth_manual_paste.py b/tests/hermes_cli/test_auth_manual_paste.py new file mode 100644 index 0000000000000..3f0fa2a59e45c --- /dev/null +++ b/tests/hermes_cli/test_auth_manual_paste.py @@ -0,0 +1,384 @@ +"""Tests for the OAuth manual-paste fallback for browser-only remotes. + +Regression coverage for [#26923](https://github.com/NousResearch/hermes-agent/issues/26923): +GCP Cloud Shell, GitHub Codespaces, AWS EC2 Instance Connect and +other browser-only remote consoles can't reach the +``http://127.0.0.1:56121/callback`` loopback listener bound on the +remote VM. The previous SSH-tunnel hint was useless without a real +SSH client, leaving the user with no path forward. This test file +locks in four things: + +* ``_is_remote_session`` recognises the cloud-shell / Codespaces + envvars (so the existing hint at least fires). +* ``_parse_pasted_callback`` accepts every form a user might paste + (full URL, ``?code=...&state=...`` fragment, bare ``code=...``, + bare opaque value) and returns the same shape the loopback HTTP + handler does. +* ``_prompt_manual_callback_paste`` reads stdin and produces that + same shape. +* ``_xai_oauth_loopback_login(manual_paste=True)`` skips the HTTP + server entirely, validates ``state``, and goes straight to the + token exchange — proving the paste path actually wires up. +""" + +from __future__ import annotations + +import builtins +import io +import contextlib + +import pytest + +from hermes_cli import auth as auth_mod + + +# --------------------------------------------------------------------------- +# _is_remote_session — broadened detection (#26923) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "envvar", + [ + "SSH_CLIENT", + "SSH_TTY", + "CLOUD_SHELL", + "CODESPACES", + "CODESPACE_NAME", + "GITPOD_WORKSPACE_ID", + "REPL_ID", + "STACKBLITZ", + ], +) +def test_is_remote_session_detects_known_remote_envvar(monkeypatch, envvar): + """Each documented remote-console env var must trip the check. + + The SSH ones preserve historical behaviour; the cloud-shell ones + are what closes #26923. Without these, the SSH hint never fires + and the user has no signal that ``--manual-paste`` exists. + """ + for name in ( + "SSH_CLIENT", + "SSH_TTY", + "CLOUD_SHELL", + "CODESPACES", + "CODESPACE_NAME", + "GITPOD_WORKSPACE_ID", + "REPL_ID", + "STACKBLITZ", + ): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv(envvar, "1") + assert auth_mod._is_remote_session() is True + + +def test_is_remote_session_false_when_no_remote_envvars(monkeypatch): + for name in ( + "SSH_CLIENT", + "SSH_TTY", + "CLOUD_SHELL", + "CODESPACES", + "CODESPACE_NAME", + "GITPOD_WORKSPACE_ID", + "REPL_ID", + "STACKBLITZ", + ): + monkeypatch.delenv(name, raising=False) + assert auth_mod._is_remote_session() is False + + +# --------------------------------------------------------------------------- +# _parse_pasted_callback — accept every plausible paste form +# --------------------------------------------------------------------------- + + +def test_parse_full_callback_url(): + out = auth_mod._parse_pasted_callback( + "http://127.0.0.1:56121/callback?code=abc123&state=deadbeef" + ) + assert out == { + "code": "abc123", + "state": "deadbeef", + "error": None, + "error_description": None, + } + + +def test_parse_callback_url_https_and_extra_params(): + out = auth_mod._parse_pasted_callback( + "https://127.0.0.1:56121/callback?code=abc&state=xyz&scope=openid" + ) + assert out["code"] == "abc" + assert out["state"] == "xyz" + + +def test_parse_bare_query_string_with_leading_question_mark(): + out = auth_mod._parse_pasted_callback("?code=p1&state=s1") + assert out["code"] == "p1" + assert out["state"] == "s1" + + +def test_parse_bare_query_fragment_no_question_mark(): + out = auth_mod._parse_pasted_callback("code=p2&state=s2") + assert out["code"] == "p2" + assert out["state"] == "s2" + + +def test_parse_bare_opaque_code_value(): + """Some users only copy the ``code`` value itself.""" + out = auth_mod._parse_pasted_callback("ABCDEF-the-code-value") + assert out["code"] == "ABCDEF-the-code-value" + assert out["state"] is None + + +def test_parse_callback_with_error_field(): + out = auth_mod._parse_pasted_callback( + "http://127.0.0.1:56121/callback?error=access_denied" + "&error_description=user+rejected" + ) + assert out["code"] is None + assert out["error"] == "access_denied" + assert out["error_description"] == "user rejected" + + +def test_parse_empty_input_returns_all_none(): + out = auth_mod._parse_pasted_callback("") + assert out == { + "code": None, + "state": None, + "error": None, + "error_description": None, + } + + +def test_parse_whitespace_only_returns_all_none(): + out = auth_mod._parse_pasted_callback(" \n\t ") + assert out["code"] is None + + +def test_parse_malformed_url_does_not_crash(): + out = auth_mod._parse_pasted_callback("http://[not a url") + # Malformed URLs return all-None rather than raising — the caller + # (state check) will reject the empty payload with a clear error. + assert out["code"] is None + + +# --------------------------------------------------------------------------- +# _prompt_manual_callback_paste — stdin handling +# --------------------------------------------------------------------------- + + +def test_prompt_reads_stdin_and_parses(monkeypatch): + monkeypatch.setattr( + builtins, "input", + lambda *_a, **_k: "http://127.0.0.1:56121/callback?code=abc&state=xyz", + ) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + out = auth_mod._prompt_manual_callback_paste( + "http://127.0.0.1:56121/callback" + ) + rendered = buf.getvalue() + assert "Manual callback paste" in rendered + assert "127.0.0.1:56121" in rendered + assert out["code"] == "abc" + assert out["state"] == "xyz" + + +def test_prompt_eof_returns_all_none(monkeypatch): + def _raise_eof(*_a, **_k): + raise EOFError() + + monkeypatch.setattr(builtins, "input", _raise_eof) + with contextlib.redirect_stdout(io.StringIO()): + out = auth_mod._prompt_manual_callback_paste( + "http://127.0.0.1:56121/callback" + ) + assert out["code"] is None + + +def test_prompt_keyboard_interrupt_returns_all_none(monkeypatch): + def _raise_kbi(*_a, **_k): + raise KeyboardInterrupt() + + monkeypatch.setattr(builtins, "input", _raise_kbi) + with contextlib.redirect_stdout(io.StringIO()): + out = auth_mod._prompt_manual_callback_paste( + "http://127.0.0.1:56121/callback" + ) + assert out["code"] is None + + +# --------------------------------------------------------------------------- +# _xai_oauth_loopback_login(manual_paste=True) — full integration +# --------------------------------------------------------------------------- + + +class _StubTokenResponse: + status_code = 200 + + def __init__(self, payload): + self._payload = payload + self.text = "" + + def json(self): + return self._payload + + +def test_xai_loopback_login_manual_paste_skips_http_server(monkeypatch): + """``manual_paste=True`` must NOT bind a loopback HTTP server. + + Direct end-to-end regression for #26923: the whole point is that + the listener is unreachable on browser-only remotes, so the paste + path must avoid it entirely. We assert this by replacing + ``_xai_start_callback_server`` with a function that fails if + invoked, then driving the full happy path with a stubbed prompt + + stubbed token endpoint. + """ + monkeypatch.setattr( + auth_mod, "_xai_oauth_discovery", + lambda *_a, **_k: { + "authorization_endpoint": "https://auth.x.ai/oauth2/authorize", + "token_endpoint": "https://auth.x.ai/oauth2/token", + }, + ) + + def _server_must_not_be_called(*_a, **_k): + raise AssertionError( + "manual_paste=True must skip the loopback HTTP server " + "(regression for #26923)" + ) + + monkeypatch.setattr( + auth_mod, "_xai_start_callback_server", _server_must_not_be_called + ) + + captured_state: dict = {} + + def _fake_prompt(_redirect_uri): + # Hermes generates state internally; we won't know it ahead of + # time, so capture the state Hermes baked into the authorize + # URL via a sneak peek on ``_xai_oauth_build_authorize_url``. + return { + "code": "fake-auth-code", + "state": captured_state["value"], + "error": None, + "error_description": None, + } + + monkeypatch.setattr( + auth_mod, "_prompt_manual_callback_paste", _fake_prompt + ) + + original_build = auth_mod._xai_oauth_build_authorize_url + + def _capture_state(**kwargs): + captured_state["value"] = kwargs["state"] + return original_build(**kwargs) + + monkeypatch.setattr( + auth_mod, "_xai_oauth_build_authorize_url", _capture_state + ) + + def _fake_token_post(*_a, **_k): + return _StubTokenResponse( + { + "access_token": "at", + "refresh_token": "rt", + "id_token": "", + "expires_in": 3600, + "token_type": "Bearer", + } + ) + + monkeypatch.setattr(auth_mod.httpx, "post", _fake_token_post) + + with contextlib.redirect_stdout(io.StringIO()): + creds = auth_mod._xai_oauth_loopback_login(manual_paste=True) + + assert creds["tokens"]["access_token"] == "at" + assert creds["tokens"]["refresh_token"] == "rt" + assert "127.0.0.1:56121" in creds["redirect_uri"] + + +def test_xai_loopback_login_manual_paste_state_mismatch_raises(monkeypatch): + """A pasted callback with the wrong state must still be rejected. + + The HTTP-server path uses the same state check; manual-paste + must not be a CSRF bypass. + """ + monkeypatch.setattr( + auth_mod, "_xai_oauth_discovery", + lambda *_a, **_k: { + "authorization_endpoint": "https://auth.x.ai/oauth2/authorize", + "token_endpoint": "https://auth.x.ai/oauth2/token", + }, + ) + monkeypatch.setattr( + auth_mod, "_prompt_manual_callback_paste", + lambda _ru: { + "code": "fake", + "state": "WRONG-STATE", + "error": None, + "error_description": None, + }, + ) + + with contextlib.redirect_stdout(io.StringIO()): + with pytest.raises(auth_mod.AuthError) as exc: + auth_mod._xai_oauth_loopback_login(manual_paste=True) + assert exc.value.code == "xai_state_mismatch" + + +def test_xai_loopback_login_manual_paste_missing_code_raises(monkeypatch): + """Empty paste must surface as ``xai_code_missing``, not crash.""" + monkeypatch.setattr( + auth_mod, "_xai_oauth_discovery", + lambda *_a, **_k: { + "authorization_endpoint": "https://auth.x.ai/oauth2/authorize", + "token_endpoint": "https://auth.x.ai/oauth2/token", + }, + ) + captured: dict = {"state": None} + original_build = auth_mod._xai_oauth_build_authorize_url + + def _capture(**kw): + captured["state"] = kw["state"] + return original_build(**kw) + + monkeypatch.setattr(auth_mod, "_xai_oauth_build_authorize_url", _capture) + monkeypatch.setattr( + auth_mod, "_prompt_manual_callback_paste", + lambda _ru: { + "code": None, + "state": captured["state"], + "error": None, + "error_description": None, + }, + ) + + with contextlib.redirect_stdout(io.StringIO()): + with pytest.raises(auth_mod.AuthError) as exc: + auth_mod._xai_oauth_loopback_login(manual_paste=True) + assert exc.value.code == "xai_code_missing" + + +# --------------------------------------------------------------------------- +# _print_loopback_ssh_hint — now also mentions --manual-paste +# --------------------------------------------------------------------------- + + +def test_ssh_hint_mentions_manual_paste_for_non_ssh_remotes(monkeypatch): + """Users on Cloud Shell / Codespaces have no real SSH client; the + hint must point them at the new ``--manual-paste`` flag instead + of leaving them stuck on the ``ssh -L`` recipe.""" + monkeypatch.setattr(auth_mod, "_is_remote_session", lambda: True) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + auth_mod._print_loopback_ssh_hint( + "http://127.0.0.1:56121/callback", + docs_url=auth_mod.XAI_OAUTH_DOCS_URL, + ) + rendered = buf.getvalue() + assert "--manual-paste" in rendered + assert "Cloud Shell" in rendered or "Codespaces" in rendered diff --git a/website/docs/guides/oauth-over-ssh.md b/website/docs/guides/oauth-over-ssh.md index 46a818a7934cb..085ba8a292462 100644 --- a/website/docs/guides/oauth-over-ssh.md +++ b/website/docs/guides/oauth-over-ssh.md @@ -10,7 +10,7 @@ Some Hermes providers — currently **xAI Grok OAuth** and **Spotify** — use a This works perfectly when Hermes and your browser are on the same machine. It breaks the moment they aren't: your laptop's browser tries to reach `127.0.0.1` on **your laptop**, but the listener is bound to `127.0.0.1` on **the remote server**. -The fix is a one-line SSH local-forward. +The fix is a one-line SSH local-forward — **or**, when you don't have a real SSH client (GCP Cloud Shell, GitHub Codespaces, EC2 Instance Connect, Gitpod, browser-based web IDEs), the new `--manual-paste` flag introduced in [#26923](https://github.com/NousResearch/hermes-agent/issues/26923). ## TL;DR @@ -27,6 +27,23 @@ hermes auth add xai-oauth --no-browser Port `56121` is what xAI OAuth uses. For Spotify, replace it with `43827`. Hermes prints the exact port it bound to on the `Waiting for callback on ...` line — copy it from there. +## Browser-only remote (Cloud Shell / Codespaces / EC2 Instance Connect) + +If you don't have a regular SSH client — for example because you're running Hermes inside GCP Cloud Shell, GitHub Codespaces, AWS EC2 Instance Connect, Gitpod, or another browser-based console — the SSH tunnel above isn't available. Use `--manual-paste` instead: + +```bash +hermes auth add xai-oauth --manual-paste +# → Hermes prints an authorize URL. Open it in a browser on your laptop. +# → Approve in the browser. The redirect to 127.0.0.1:56121/callback fails +# to load — that's expected. +# → Copy the FULL URL from the failed page's address bar. +# → Paste it back into the terminal at the "Callback URL:" prompt. +``` + +The same flag works on `hermes model --manual-paste` for the integrated model picker. A bare `?code=...&state=...` query fragment is accepted too if you don't want to paste the whole URL. + +Hermes uses the **same PKCE verifier, state and nonce** for both paths, so the upstream OAuth flow is byte-identical — `--manual-paste` is purely a transport change for the callback hop and is not a security downgrade. + ## Which Providers Need This | Provider | Loopback port | Tunnel needed? | diff --git a/website/docs/guides/xai-grok-oauth.md b/website/docs/guides/xai-grok-oauth.md index d85aa4c64bf7f..e26b483aaaee2 100644 --- a/website/docs/guides/xai-grok-oauth.md +++ b/website/docs/guides/xai-grok-oauth.md @@ -76,6 +76,18 @@ Through a jump box / bastion: add `-J jump-user@jump-host`. See [OAuth over SSH / Remote Hosts](./oauth-over-ssh.md) for the full step-by-step, including ProxyJump chains, mosh/tmux, and ControlMaster gotchas. +### Browser-only remotes (Cloud Shell, Codespaces, EC2 Instance Connect) + +If you don't have a regular SSH client (e.g. you're running Hermes inside GCP Cloud Shell, GitHub Codespaces, AWS EC2 Instance Connect, Gitpod, or another browser-based console), the `ssh -L` recipe above isn't available. Use `--manual-paste` instead — Hermes skips the loopback listener and lets you paste the failed callback URL straight from your browser: + +```bash +hermes auth add xai-oauth --manual-paste +# Or via the model picker: +hermes model --manual-paste +``` + +See [OAuth over SSH / Remote Hosts](./oauth-over-ssh.md#browser-only-remote-cloud-shell--codespaces--ec2-instance-connect) for the full walkthrough. Regression fix for [#26923](https://github.com/NousResearch/hermes-agent/issues/26923). + ## How the Login Works 1. Hermes opens your browser to `accounts.x.ai`.