Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions plugins/memory/honcho/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ hermes memory setup honcho # configure Honcho directly (works on a fresh insta
hermes memory setup # generic picker, choose Honcho from the list
```

For cloud, the wizard asks **OAuth or API key**. OAuth opens a browser
sign-in and stores the grant itself β€” nothing to copy; tokens refresh
automatically. The desktop app offers the same flow as a **Connect** link
next to the memory-provider dropdown.
For cloud, the wizard asks **OAuth, device code, or API key**. OAuth opens a
browser sign-in and stores the grant itself β€” nothing to copy; tokens refresh
automatically. On SSH/headless machines choose **device**: the CLI prints a
short code and a link you open in a browser on any other machine; setup
completes once you approve there. The desktop app offers the browser flow as
a **Connect** link next to the memory-provider dropdown.

Or manually:
```bash
Expand Down Expand Up @@ -324,6 +326,7 @@ Presets:
| `HONCHO_OAUTH_DASHBOARD` | OAuth authorize origin (default: cloud dashboard; local-dev `localhost:3000`) |
| `HONCHO_OAUTH_AUTHORIZE_URL` | Full authorize URL (overrides the dashboard origin) |
| `HONCHO_OAUTH_TOKEN_URL` | Token endpoint (default: cloud API; local-dev `localhost:8000`) |
| `HONCHO_OAUTH_DEVICE_AUTH_URL` | Device-authorization endpoint (default: derived from the token URL) |
| `HONCHO_OAUTH_CLIENT_ID` | OAuth client (default `hermes-agent`) |
| `HONCHO_OAUTH_SCOPE` | Requested scope (default `write`) |

Expand Down
116 changes: 107 additions & 9 deletions plugins/memory/honcho/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -620,23 +620,96 @@ def cmd_setup(args) -> None:
print("\n No local JWT set. Local no-auth ready.")
use_oauth = False
if not is_local:
# --- Cloud: OAuth (browser) or API key ---
# --- Cloud: OAuth (browser), device code, or API key ---
cfg.pop("baseUrl", None) # cloud uses SDK default

# Detect an existing OAuth grant so re-running setup reflects it instead
# of looking like a fresh connect.
from plugins.memory.honcho.oauth import OAuthCredential
existing_oauth = OAuthCredential.from_host_block(hermes_host)

device_available = _device_login_available()
is_remote, can_browse = _headless()

print("\n Auth method:")
if existing_oauth is not None:
print(f" (currently connected via OAuth β€” client {existing_oauth.client_id})")
print(" oauth -- sign in via browser (recommended)")
print(" oauth -- sign in via browser on this machine (recommended)")
if device_available:
print(" device -- device code: approve from a browser on another machine (SSH / headless)")
print(" apikey -- paste an API key from https://app.honcho.dev")
method = _prompt("OAuth or API key?", default="oauth").strip().lower()

default_method = "oauth"
if is_remote or not can_browse:
if device_available:
print(" (no usable local browser detected β€” device code recommended)")
default_method = "device"
else:
print(" (no usable local browser detected β€” browser sign-in may need an SSH tunnel to 127.0.0.1:8765)")
prompt_label = "oauth, device, or apikey?" if device_available else "OAuth or API key?"
method = _prompt(prompt_label, default=default_method).strip().lower()
use_oauth = method in {"oauth", "o"}
use_device = device_available and method in {"device", "d"}

if use_device:
from plugins.memory.honcho.oauth_flow import (
AccessDenied,
AuthorizationTimeout,
DeviceCode,
DeviceCodeExpired,
DeviceFlowError,
authorize_via_device_code,
)

def _show(device: DeviceCode) -> None:
print("\n To connect, on any device with a browser:")
print(f"\n 1. Open {device.verification_uri}")
print(f" 2. Enter {device.user_code}")
print(f"\n Or open directly:\n\n {device.verification_uri_complete}\n")
mins = max(1, device.expires_in // 60)
print(f" Waiting for approval (expires in {mins} min, Ctrl-C to cancel) ", end="", flush=True)

def _open_local(url: str) -> None:
import webbrowser

webbrowser.open(url)

if use_oauth:
print("\n Requesting device code…")
try:
cred = authorize_via_device_code(
config_path=write_path,
source="hermes-cli",
apply_config=False,
display=_show,
open_url=_open_local if can_browse and not is_remote else None,
on_poll=lambda: print(".", end="", flush=True),
)
except KeyboardInterrupt:
print("\n Cancelled. Re-run 'hermes honcho setup' to try again.\n")
return
except (AuthorizationTimeout, DeviceCodeExpired):
print("\n Device code expired before approval.")
print(" Re-run 'hermes honcho setup' to get a new code.\n")
return
except AccessDenied:
print("\n Sign-in was denied on the approval page.")
print(" Re-run 'hermes honcho setup' to retry, or choose an API key instead.\n")
return
except DeviceFlowError as e:
if e.error == "http_429":
print("\n Too many device-code requests β€” wait a minute and re-run setup.\n")
else:
print(f"\n Device sign-in failed: {e}")
print(" Re-run 'hermes honcho setup' to retry, or choose an API key instead.\n")
return
except Exception as e:
print(f"\n Device sign-in failed: {e}")
print(" Re-run 'hermes honcho setup' to retry, or choose an API key instead.\n")
return
print(" approved")
_apply_grant_to_host(hermes_host, cred)
print(" Authorized β€” token saved. Let's finish configuring.\n")
elif use_oauth:
# Sign in now, up front β€” the browser link is the whole point, so
# don't bury it behind the identity prompts. The grant's tokens are
# merged into the in-memory cfg so the wizard's final save preserves
Expand All @@ -661,11 +734,7 @@ def _open(url: str) -> None:
print(f" OAuth sign-in failed: {e}")
print(" Re-run 'hermes honcho setup' to retry, or choose an API key instead.\n")
return
hermes_host["apiKey"] = cred.access_token
hermes_host["oauth"] = cred.oauth_block()
# Default the peer prompt to the name entered at consent.
if cred.consent_peer_name:
hermes_host["peerName"] = cred.consent_peer_name
_apply_grant_to_host(hermes_host, cred)
print(" Authorized β€” token saved. Let's finish configuring.\n")
else:
current_key = cfg.get("apiKey", "")
Expand Down Expand Up @@ -974,6 +1043,35 @@ def _open(url: str) -> None:
print(" hermes honcho map <name> -- map this directory to a session name\n")


def _device_login_available() -> bool:
"""Whether the resolved host offers the RFC 8628 device grant. Fails closed."""
try:
from plugins.memory.honcho.oauth_flow import resolve_endpoints, supports_device_login

return supports_device_login(resolve_endpoints())
except Exception:
return False


def _headless() -> tuple[bool, bool]:
"""(is_remote, can_open_browser) β€” degrades safely if hermes_cli internals move."""
try:
from hermes_cli.auth import _can_open_graphical_browser, _is_remote_session

return _is_remote_session(), _can_open_graphical_browser()
except Exception:
return False, True


def _apply_grant_to_host(hermes_host: dict, cred) -> None:
"""Store an OAuth grant on the host block; the wizard's final save persists it."""
hermes_host["apiKey"] = cred.access_token
hermes_host["oauth"] = cred.oauth_block()
# Default the peer prompt to the name entered at consent.
if cred.consent_peer_name:
hermes_host["peerName"] = cred.consent_peer_name


def _active_profile_name() -> str:
"""Return the active Hermes profile name (respects --target-profile override)."""
if _profile_override:
Expand Down
30 changes: 30 additions & 0 deletions plugins/memory/honcho/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,36 @@ def _http_post_form(url: str, data: dict[str, str], timeout: float) -> dict[str,
return resp.json()


def _http_post_form_status(
url: str, data: dict[str, str], timeout: float
) -> tuple[int, dict[str, Any]]:
"""POST form-encoded ``data``; return ``(status, parsed JSON body)``.

Unlike ``_http_post_form``, 4xx does not raise β€” RFC 8628 polling reads the
OAuth error body off a 400. A non-JSON body parses to ``{}``.
"""
import httpx

resp = httpx.post(url, data=data, timeout=timeout)
try:
body = resp.json()
except ValueError:
body = {}
if not isinstance(body, dict):
body = {}
return resp.status_code, body


def _http_get_json(url: str, timeout: float) -> dict[str, Any]:
"""GET ``url`` and return the parsed JSON body. Raises on non-2xx/non-JSON."""
import httpx

resp = httpx.get(url, timeout=timeout)
resp.raise_for_status()
body = resp.json()
return body if isinstance(body, dict) else {}


def _exchange_refresh_token(cred: OAuthCredential, *, now: float) -> OAuthCredential:
"""Run the refresh_token grant and return the rotated credential.

Expand Down
Loading
Loading