mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
feat(mcp-oauth): stdin paste-back fallback for headless OAuth flow (#32053)
When the user runs OAuth on a remote/SSH machine without a port forward, the OAuth provider redirects to http://127.0.0.1:<port>/callback which only the listener on the remote machine can receive — the user's browser on another box just shows a connection error. _wait_for_callback() now races the HTTP listener against a stdin reader on interactive TTYs. The user can copy the URL from the browser's address bar after authorization (which contains code=...&state=...) and paste it back at the prompt. Whichever fills the result dict first wins; the HTTP listener remains the primary path for local sessions and SSH tunnels. Accepts any of: - Full local redirect URL: http://127.0.0.1:N/callback?code=...&state=... - Provider URL after redirect: https://mcp.linear.app/callback?code=...&state=... - Just the query string: ?code=...&state=... or code=...&state=... The paste thread only spawns when _is_interactive() is true, preserving the existing 'no input() in headless runs' invariant — verified by TestWaitForCallbackPasteIntegration.test_paste_prompt_NOT_shown_when_noninteractive. The SSH-session hint in _redirect_handler is updated to surface the paste option as the primary remedy, with ssh -L tunneling as the alternative.
This commit is contained in:
parent
e9119e0eb8
commit
0ff7c09e2f
2 changed files with 234 additions and 6 deletions
|
|
@ -403,17 +403,25 @@ async def _redirect_handler(authorization_url: str) -> None:
|
|||
# On a remote SSH session the OAuth provider redirects to
|
||||
# http://127.0.0.1:<port>/callback, which reaches the callback server on
|
||||
# the *remote* machine — not the user's local machine where the browser
|
||||
# opened. Print a port-forward hint so the user knows to tunnel first.
|
||||
# opened. Two ways out: paste the redirect URL back (default fallback,
|
||||
# offered by _wait_for_callback on interactive TTYs), or set up an SSH
|
||||
# port forward so the redirect tunnels through.
|
||||
if _oauth_port and (os.getenv("SSH_CLIENT") or os.getenv("SSH_TTY")):
|
||||
print(
|
||||
f" Remote session detected. The OAuth provider will redirect your browser to\n"
|
||||
f" Remote session detected. After you authorize, the provider redirects to\n"
|
||||
f" http://127.0.0.1:{_oauth_port}/callback\n"
|
||||
f" which the callback listener on THIS machine is waiting on. If your browser\n"
|
||||
f" is on a different machine, forward the port first in a separate terminal:\n"
|
||||
f" which only the listener on THIS machine can receive. Two options:\n"
|
||||
f"\n"
|
||||
f" ssh -N -L {_oauth_port}:127.0.0.1:{_oauth_port} <user>@<this-host>\n"
|
||||
f" 1. Easiest — when your browser shows a connection error after\n"
|
||||
f" authorizing, copy the full URL from the address bar and paste\n"
|
||||
f" it at the prompt below. The pasted ``code=...&state=...`` is\n"
|
||||
f" enough to complete the flow.\n"
|
||||
f"\n"
|
||||
f" Then open the URL above. See: https://hermes-agent.nousresearch.com/docs/guides/oauth-over-ssh\n",
|
||||
f" 2. Or forward the port first in a separate terminal:\n"
|
||||
f" ssh -N -L {_oauth_port}:127.0.0.1:{_oauth_port} <user>@<this-host>\n"
|
||||
f" then open the URL above and let it redirect normally.\n"
|
||||
f"\n"
|
||||
f" See: https://hermes-agent.nousresearch.com/docs/guides/oauth-over-ssh\n",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
|
@ -437,6 +445,12 @@ async def _wait_for_callback() -> tuple[str, str | None]:
|
|||
before this is ever called. Polls for the result without blocking the
|
||||
event loop.
|
||||
|
||||
On an interactive TTY, races the HTTP listener against a stdin paste
|
||||
fallback so users without an SSH tunnel can copy the redirect URL (or
|
||||
just the ``code=...&state=...`` query string) from a browser on another
|
||||
machine and paste it back. The HTTP listener wins when the redirect
|
||||
reaches it first; the paste fallback wins when it doesn't.
|
||||
|
||||
Raises:
|
||||
OAuthNonInteractiveError: If the callback times out (no user present
|
||||
to complete the browser auth).
|
||||
|
|
@ -468,6 +482,23 @@ async def _wait_for_callback() -> tuple[str, str | None]:
|
|||
server_thread = threading.Thread(target=server.handle_request, daemon=True)
|
||||
server_thread.start()
|
||||
|
||||
# Optional paste-fallback thread: only on interactive TTYs. Reads one
|
||||
# line from stdin and writes the parsed code/state into the shared
|
||||
# result dict. The HTTP listener and this thread race for the result;
|
||||
# whichever fills it first wins.
|
||||
paste_thread: threading.Thread | None = None
|
||||
if _is_interactive():
|
||||
print(
|
||||
"\n Or paste the redirect URL here (or the ``?code=...&state=...`` "
|
||||
"portion) and press Enter:",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
paste_thread = threading.Thread(
|
||||
target=_paste_callback_reader, args=(result,), daemon=True
|
||||
)
|
||||
paste_thread.start()
|
||||
|
||||
timeout = 300.0
|
||||
poll_interval = 0.5
|
||||
elapsed = 0.0
|
||||
|
|
@ -491,6 +522,70 @@ async def _wait_for_callback() -> tuple[str, str | None]:
|
|||
return result["auth_code"], result["state"]
|
||||
|
||||
|
||||
def _paste_callback_reader(result: dict) -> None:
|
||||
"""Read one line from stdin, parse it as an OAuth redirect, write to result.
|
||||
|
||||
Accepts any of:
|
||||
- Full redirect URL: ``http://127.0.0.1:37949/callback?code=...&state=...``
|
||||
- The provider's own callback URL: ``https://mcp.example.com/callback?code=...&state=...``
|
||||
- Just the query string: ``?code=...&state=...`` or ``code=...&state=...``
|
||||
|
||||
Failures to parse, EOF, or interrupts are swallowed — this is best-effort
|
||||
fallback alongside the HTTP listener, which remains the primary path.
|
||||
"""
|
||||
try:
|
||||
line = sys.stdin.readline()
|
||||
except (KeyboardInterrupt, OSError, ValueError):
|
||||
return
|
||||
if not line:
|
||||
return # EOF
|
||||
line = line.strip()
|
||||
if not line:
|
||||
return
|
||||
|
||||
# Skip if HTTP listener already won.
|
||||
if result.get("auth_code") is not None or result.get("error") is not None:
|
||||
return
|
||||
|
||||
# Strip a leading "?" if user pasted just a query string.
|
||||
query = line
|
||||
if "?" in line:
|
||||
# Either a full URL or "?code=...". Take everything after the first "?".
|
||||
query = line.split("?", 1)[1]
|
||||
if query.startswith("?"):
|
||||
query = query[1:]
|
||||
|
||||
try:
|
||||
params = parse_qs(query)
|
||||
except (ValueError, TypeError):
|
||||
print(
|
||||
" Could not parse pasted input as an OAuth redirect — ignoring.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
|
||||
code = params.get("code", [None])[0]
|
||||
state = params.get("state", [None])[0]
|
||||
error = params.get("error", [None])[0]
|
||||
|
||||
if not code and not error:
|
||||
print(
|
||||
" Pasted input did not contain ``code=`` or ``error=`` — ignoring.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return
|
||||
|
||||
# One more race-check before writing.
|
||||
if result.get("auth_code") is not None or result.get("error") is not None:
|
||||
return
|
||||
|
||||
result["auth_code"] = code
|
||||
result["state"] = state
|
||||
result["error"] = error
|
||||
if code:
|
||||
print(" Got authorization code from paste — completing flow.", file=sys.stderr)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue