mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-18 14:52:04 +00:00
feat(memory): Honcho OAuth connect — desktop and CLI flows + token refresh (#44335)
* feat(memory): OAuth token storage and refresh for the Honcho provider * feat(memory): refresh the Honcho OAuth token in the client and session * feat(memory): zero-CLI loopback OAuth authorization flow * feat(memory): generic memory-provider OAuth connect endpoints * feat(desktop): memory-provider OAuth connect link * feat(memory): CLI OAuth sign-in with source-tagged authorize links * fix(memory): IP-literal loopback redirect and consent config_path on the authorize link * fix(memory): profile-scope the memory-provider OAuth endpoints * refactor(desktop): generic memory-provider OAuth client functions * docs(memory): trim OAuth module docstrings to the invariants * docs(memory): document OAuth connect as an optional auth method * fix(memory): send home-relative display path to consent, not the absolute path * perf(memory): cache OAuth token expiry in memory to skip the hot-path disk read * fix(memory): log OAuth refresh failures at warning, not debug * feat(memory): fall back to an OS-assigned loopback port when 8765 is taken * test(memory): cover the desktop Connect launcher, status, and provider dispatch * fix(desktop): keep the memory-provider dropdown one size regardless of connect state * fix(desktop): move the memory connect link to the description line, leaving the dropdown untouched * refactor(memory): move OAuth connect routes out of web_server into a memory-layer router * refactor(desktop): import MemoryConnect directly, drop the single-export barrel * fix(memory): launch CLI OAuth sign-in right after the auth choice, not after the wizard * fix(desktop): auto-clear the OAuth error state instead of leaving it sticky * test(honcho): isolate auth-method prompt from deployment-shape wizard tests main's wizard suite scripts the cloud prompts without the OAuth auth-method step; auto-answer it in the shared helper so the answer lists stay shape-only. * docs(honcho): document query-adaptive reasoning level (reasoningHeuristic) README never mentioned reasoningHeuristic and listed reasoningLevelCap as an orphaned cap with the wrong default (— vs "high"). Add the query-adaptive scaling note + the reasoningHeuristic/reasoningLevelCap rows (grouped under Dialectic & Reasoning), matching the wording already on the hosted honcho.md page, and add a pointer from the memory-providers overview. * fix(honcho): default the CLI peer prompt to the OAuth consent name The CLI runs the grant with apply_config=False, so the peerName the user just entered at consent was dropped and the wizard's 'Your name' prompt fell back to $USER. Surface it as a transient OAuthCredential.consent_peer_name (set even when config isn't merged) and seed the prompt default from it. * feat(honcho): split OAuth client_id by surface (cli=hermes-agent, desktop=hermes-desktop) resolve_endpoints now picks the client_id from the initiating surface and threads it through authorize -> token exchange -> persisted grant -> refresh, so the CLI and desktop register as distinct OAuth clients. Surface-specific env overrides (HONCHO_OAUTH_CLIENT_ID_CLI/_DESKTOP) win over the generic HONCHO_OAUTH_CLIENT_ID, which still overrides every surface. * feat(honcho): show OAuth vs API key in status; detect existing OAuth in setup status now prints 'Auth: OAuth (clientId, token valid Xm/expired)' instead of masking the OAuth access token as a generic API key; setup notes an existing OAuth grant when re-run. * docs(honcho): drop 'shared pool' wording from unified observation mode help * fix(honcho): cross-process lock around OAuth refresh to prevent grant revocation The in-process threading lock can't stop a sibling process (another profile or the desktop app sharing honcho.json) from replaying the single-use refresh token and tripping reuse-detection, which revokes the whole grant. Guard the read-refresh-persist section with an OS file lock on <config>.lock so only one process rotates at a time; the others re-read the freshly-persisted token. Best-effort: platforms without flock degrade to in-process serialization. * refactor(honcho): one OAuth client (hermes-agent) for all surfaces Collapse the per-surface client_id split. CLI and desktop now use a single client_id (hermes-agent); consent branding/UI still adapt via the source query param. One grant identity means no clientId-vs-refresh-token desync that could get the grant revoked. HONCHO_OAUTH_CLIENT_ID still overrides for self-hosting. * fix(honcho): per-session resolves to session_id, never remapped by title Reorder resolve_session_name so stable identifiers win over labels: gateway per-chat key first, then the per-session session_id, then the cwd map / title. A (possibly auto-generated) title can no longer remap a live per-session conversation onto a second Honcho session mid-stream — fixes the desktop, which is per-conversation via session_id. Consequence: a gateway's per-chat key now also wins over a title (titles never remap a stable id).
This commit is contained in:
parent
672ea1f894
commit
ba9e3a491b
18 changed files with 1948 additions and 53 deletions
|
|
@ -7,7 +7,8 @@ AI-native cross-session user modeling with multi-pass dialectic reasoning, sessi
|
|||
## Requirements
|
||||
|
||||
- `pip install honcho-ai`
|
||||
- Honcho API key from [app.honcho.dev](https://app.honcho.dev), or a self-hosted instance
|
||||
- A Honcho Cloud account — connect via OAuth sign-in or an API key from
|
||||
[app.honcho.dev](https://app.honcho.dev) — or a self-hosted instance
|
||||
|
||||
## Setup
|
||||
|
||||
|
|
@ -16,6 +17,11 @@ 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.
|
||||
|
||||
Or manually:
|
||||
```bash
|
||||
hermes config set memory.provider honcho
|
||||
|
|
@ -77,6 +83,10 @@ When `dialecticDepthLevels` is not set, each pass uses a proportional level rela
|
|||
|
||||
Override with `dialecticDepthLevels`: an explicit array of reasoning level strings per pass.
|
||||
|
||||
### Query-Adaptive Reasoning Level
|
||||
|
||||
The auto-injected dialectic scales `dialecticReasoningLevel` by query length: +1 level at ≥120 chars, +2 at ≥400, clamped at `reasoningLevelCap` (default `"high"`). Disable with `reasoningHeuristic: false` to pin every auto call to `dialecticReasoningLevel`.
|
||||
|
||||
### Three Orthogonal Dialectic Knobs
|
||||
|
||||
| Knob | Controls | Type |
|
||||
|
|
@ -123,7 +133,8 @@ For every key, resolution order is: **host block > root > env var > default**.
|
|||
|
||||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `apiKey` | string | — | API key. Falls back to `HONCHO_API_KEY` env var |
|
||||
| `apiKey` | string | — | API key. Falls back to `HONCHO_API_KEY` env var. When connected via OAuth, holds the auto-refreshing access token instead |
|
||||
| `oauth` | object | — | OAuth grant (refresh token, expiry, client, token endpoint). Written by the Connect/sign-in flows and rotated automatically — not hand-edited. Optional: an API key alone works without it |
|
||||
| `baseUrl` | string | — | Base URL for self-hosted Honcho. Local URLs auto-skip API key auth |
|
||||
| `environment` | string | `"production"` | SDK environment mapping |
|
||||
| `enabled` | bool | auto | Master toggle. Auto-enables when `apiKey` or `baseUrl` present |
|
||||
|
|
@ -174,7 +185,7 @@ Pick **[e]** at the prompt to set the three keys directly instead of going throu
|
|||
| Key | Type | Default | Description |
|
||||
|-----|------|---------|-------------|
|
||||
| `recallMode` | string | `"hybrid"` | `"hybrid"` (auto-inject + tools), `"context"` (auto-inject only, tools hidden), `"tools"` (tools only, no injection). Legacy `"auto"` → `"hybrid"` |
|
||||
| `observationMode` | string | `"directional"` | Preset: `"directional"` (all on) or `"unified"` (shared pool). Use `observation` object for granular control |
|
||||
| `observationMode` | string | `"directional"` | Preset: `"directional"` (all on) or `"unified"` (user observes self, AI observes others). Use `observation` object for granular control |
|
||||
| `observation` | object | — | Per-peer observation config (see Observation section) |
|
||||
|
||||
### Write Behavior
|
||||
|
|
@ -255,6 +266,8 @@ Host key is derived from the active Hermes profile: `hermes` (default) or `herme
|
|||
| `dialecticDynamic` | bool | `true` | When `true`, model can override reasoning level per-call via `honcho_reasoning` tool. When `false`, always uses `dialecticReasoningLevel` |
|
||||
| `dialecticMaxChars` | int | `600` | Max chars of dialectic result injected into system prompt |
|
||||
| `dialecticMaxInputChars` | int | `10000` | Max chars for dialectic query input to `.chat()`. Honcho cloud limit: 10k |
|
||||
| `reasoningHeuristic` | bool | `true` | Query-adaptive: auto-scale the auto-injected dialectic's level up by query length (+1 at ≥120 chars, +2 at ≥400), clamped at `reasoningLevelCap`. `false` pins every auto call to `dialecticReasoningLevel` |
|
||||
| `reasoningLevelCap` | string | `"high"` | Ceiling for `reasoningHeuristic` scaling: `"minimal"`, `"low"`, `"medium"`, `"high"`, `"max"` |
|
||||
|
||||
### Token Budgets
|
||||
|
||||
|
|
@ -270,7 +283,6 @@ Host key is derived from the active Hermes profile: `hermes` (default) or `herme
|
|||
| `contextCadence` | int | `1` | Minimum turns between base context refreshes (session summary + representation + card) |
|
||||
| `dialecticCadence` | int | `1` | Minimum turns between dialectic `.chat()` firings |
|
||||
| `injectionFrequency` | string | `"every-turn"` | `"every-turn"` or `"first-turn"` (inject context on the first user message only, skip from turn 2 onward) |
|
||||
| `reasoningLevelCap` | string | — | Hard cap on reasoning level: `"minimal"`, `"low"`, `"medium"`, `"high"` |
|
||||
|
||||
### Observation (Granular)
|
||||
|
||||
|
|
@ -309,6 +321,11 @@ Presets:
|
|||
| `HONCHO_BASE_URL` | `baseUrl` |
|
||||
| `HONCHO_ENVIRONMENT` | `environment` |
|
||||
| `HERMES_HONCHO_HOST` | Host key override |
|
||||
| `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_CLIENT_ID` | OAuth client (default `hermes-agent`) |
|
||||
| `HONCHO_OAUTH_SCOPE` | Requested scope (default `write`) |
|
||||
|
||||
## CLI Commands
|
||||
|
||||
|
|
|
|||
|
|
@ -622,21 +622,67 @@ def cmd_setup(args) -> None:
|
|||
)
|
||||
else:
|
||||
print("\n No local JWT set. Local no-auth ready.")
|
||||
else:
|
||||
# --- Cloud: set default base URL, require API key ---
|
||||
use_oauth = False
|
||||
if not is_local:
|
||||
# --- Cloud: OAuth (browser) or API key ---
|
||||
cfg.pop("baseUrl", None) # cloud uses SDK default
|
||||
|
||||
current_key = cfg.get("apiKey", "")
|
||||
masked = f"...{current_key[-8:]}" if len(current_key) > 8 else ("set" if current_key else "not set")
|
||||
print(f"\n Current API key: {masked}")
|
||||
new_key = _prompt("Honcho API key (leave blank to keep current)", secret=True)
|
||||
if new_key:
|
||||
cfg["apiKey"] = new_key
|
||||
# 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)
|
||||
|
||||
if not cfg.get("apiKey"):
|
||||
print("\n No API key configured. Get yours at https://app.honcho.dev")
|
||||
print(" Run 'hermes honcho setup' again once you have a key.\n")
|
||||
return
|
||||
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(" apikey -- paste an API key from https://app.honcho.dev")
|
||||
method = _prompt("OAuth or API key?", default="oauth").strip().lower()
|
||||
use_oauth = method in {"oauth", "o"}
|
||||
|
||||
if 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
|
||||
# them; settings stay wizard-owned (apply_config=False).
|
||||
from plugins.memory.honcho.oauth_flow import authorize_via_loopback
|
||||
|
||||
def _open(url: str) -> None:
|
||||
print(f"\n Open this link to authorize (waiting up to 5 minutes):\n\n {url}\n")
|
||||
import webbrowser
|
||||
|
||||
webbrowser.open(url)
|
||||
|
||||
print("\n Starting browser sign-in…")
|
||||
try:
|
||||
cred = authorize_via_loopback(
|
||||
config_path=write_path,
|
||||
source="hermes-cli",
|
||||
apply_config=False,
|
||||
open_url=_open,
|
||||
)
|
||||
except Exception as e:
|
||||
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
|
||||
print(" Authorized — token saved. Let's finish configuring.\n")
|
||||
else:
|
||||
current_key = cfg.get("apiKey", "")
|
||||
masked = f"...{current_key[-8:]}" if len(current_key) > 8 else ("set" if current_key else "not set")
|
||||
print(f"\n Current API key: {masked}")
|
||||
new_key = _prompt("Honcho API key (leave blank to keep current)", secret=True)
|
||||
if new_key:
|
||||
cfg["apiKey"] = new_key
|
||||
|
||||
if not cfg.get("apiKey"):
|
||||
print("\n No API key configured. Get yours at https://app.honcho.dev")
|
||||
print(" Run 'hermes honcho setup' again once you have a key.\n")
|
||||
return
|
||||
|
||||
# --- 3. Identity ---
|
||||
current_peer = hermes_host.get("peerName") or cfg.get("peerName", "")
|
||||
|
|
@ -786,7 +832,7 @@ def cmd_setup(args) -> None:
|
|||
current_obs = hermes_host.get("observationMode") or cfg.get("observationMode", "directional")
|
||||
print("\n Observation mode:")
|
||||
print(" directional -- all observations on, each AI peer builds its own view (default)")
|
||||
print(" unified -- shared pool, user observes self, AI observes others only")
|
||||
print(" unified -- user observes self, AI observes others only")
|
||||
new_obs = _prompt("Observation mode", default=current_obs)
|
||||
if new_obs in {"unified", "directional"}:
|
||||
hermes_host["observationMode"] = new_obs
|
||||
|
|
@ -1017,6 +1063,12 @@ def cmd_status(args) -> None:
|
|||
api_key = hcfg.api_key or ""
|
||||
masked = f"...{api_key[-8:]}" if len(api_key) > 8 else ("set" if api_key else "not set")
|
||||
|
||||
# Auth line distinguishes an OAuth grant (refreshable) from a static API key
|
||||
# — the OAuth access token is also stored under apiKey, so masking alone hides it.
|
||||
from plugins.memory.honcho.oauth import OAuthCredential
|
||||
host_block = (getattr(hcfg, "raw", None) or {}).get("hosts", {}).get(hcfg.host) or {}
|
||||
cred = OAuthCredential.from_host_block(host_block)
|
||||
|
||||
profile = _active_profile_name()
|
||||
profile_label = f" [{hcfg.host}]" if profile != "default" else ""
|
||||
|
||||
|
|
@ -1025,7 +1077,13 @@ def cmd_status(args) -> None:
|
|||
print(f" Profile: {profile}")
|
||||
print(f" Host: {hcfg.host}")
|
||||
print(f" Enabled: {hcfg.enabled}")
|
||||
print(f" API key: {masked}")
|
||||
if cred is not None:
|
||||
import time as _time
|
||||
remaining = int(cred.expires_at - _time.time())
|
||||
token_state = f"valid {remaining // 60}m" if remaining > 0 else "expired — refreshes on next use"
|
||||
print(f" Auth: OAuth ({cred.client_id}, token {token_state})")
|
||||
else:
|
||||
print(f" Auth: API key ({masked})")
|
||||
print(f" Workspace: {hcfg.workspace_id}")
|
||||
|
||||
# Config paths — show where config was read from and where writes go
|
||||
|
|
|
|||
|
|
@ -679,10 +679,11 @@ class HonchoClientConfig:
|
|||
"""Resolve Honcho session name.
|
||||
|
||||
Resolution order:
|
||||
1. Manual directory override from sessions map
|
||||
2. Hermes session title (from /title command)
|
||||
3. Gateway session key (stable per-chat identifier from gateway platforms)
|
||||
4. per-session strategy — Hermes session_id ({timestamp}_{hex})
|
||||
1. Gateway session key (stable per-chat identifier from gateway platforms)
|
||||
2. per-session strategy — Hermes session_id ({timestamp}_{hex}); authoritative,
|
||||
so a generated title never remaps a live conversation
|
||||
3. Manual directory override from sessions map
|
||||
4. Hermes session title (from /title command; non-per-session)
|
||||
5. per-repo strategy — git repo root directory name
|
||||
6. per-directory strategy — directory basename
|
||||
7. global strategy — workspace name
|
||||
|
|
@ -692,12 +693,27 @@ class HonchoClientConfig:
|
|||
if not cwd:
|
||||
cwd = os.getcwd()
|
||||
|
||||
# Manual override always wins
|
||||
# Gateway per-chat key wins everywhere — gateways (telegram/discord/…)
|
||||
# need per-chat isolation no cwd/strategy name can provide.
|
||||
if gateway_session_key:
|
||||
sanitized = re.sub(r'[^a-zA-Z0-9_-]+', '-', gateway_session_key).strip('-')
|
||||
if sanitized:
|
||||
return self._enforce_session_id_limit(sanitized, gateway_session_key)
|
||||
|
||||
# per-session: the run's session_id IS the identity — resolve before the
|
||||
# cwd map / title so an auto-generated title can't remap a live
|
||||
# conversation onto a second Honcho session mid-stream.
|
||||
if self.session_strategy == "per-session" and session_id:
|
||||
if self.session_peer_prefix and self.peer_name:
|
||||
return f"{self.peer_name}-{session_id}"
|
||||
return session_id
|
||||
|
||||
# Manual override (cwd → name), for non-per-session strategies.
|
||||
manual = self.sessions.get(cwd)
|
||||
if manual:
|
||||
return manual
|
||||
|
||||
# /title mid-session remap
|
||||
# /title mid-session remap (non-per-session).
|
||||
if session_title:
|
||||
sanitized = re.sub(r'[^a-zA-Z0-9_-]+', '-', session_title).strip('-')
|
||||
if sanitized:
|
||||
|
|
@ -705,22 +721,6 @@ class HonchoClientConfig:
|
|||
return f"{self.peer_name}-{sanitized}"
|
||||
return sanitized
|
||||
|
||||
# Gateway session key: stable per-chat identifier passed by the gateway
|
||||
# (e.g. "agent:main:telegram:dm:8439114563"). Sanitize colons to hyphens
|
||||
# for Honcho session ID compatibility. This takes priority over strategy-
|
||||
# based resolution because gateway platforms need per-chat isolation that
|
||||
# cwd-based strategies cannot provide.
|
||||
if gateway_session_key:
|
||||
sanitized = re.sub(r'[^a-zA-Z0-9_-]+', '-', gateway_session_key).strip('-')
|
||||
if sanitized:
|
||||
return self._enforce_session_id_limit(sanitized, gateway_session_key)
|
||||
|
||||
# per-session: inherit Hermes session_id (new Honcho session each run)
|
||||
if self.session_strategy == "per-session" and session_id:
|
||||
if self.session_peer_prefix and self.peer_name:
|
||||
return f"{self.peer_name}-{session_id}"
|
||||
return session_id
|
||||
|
||||
# per-repo: one Honcho session per git repository
|
||||
if self.session_strategy == "per-repo":
|
||||
base = self._git_repo_name(cwd) or Path(cwd).name
|
||||
|
|
@ -742,6 +742,39 @@ class HonchoClientConfig:
|
|||
_honcho_client_slot: SingletonSlot = SingletonSlot()
|
||||
|
||||
|
||||
def _apply_fresh_oauth_token(config: HonchoClientConfig) -> None:
|
||||
"""Refresh a near-expiry OAuth grant and point ``config.api_key`` at it.
|
||||
|
||||
No-op for static API keys or when refresh fails (fail-open: the stale token
|
||||
is left in place and the existing 401 handling degrades gracefully).
|
||||
"""
|
||||
try:
|
||||
from plugins.memory.honcho import oauth
|
||||
|
||||
token, _ = oauth.ensure_fresh_token(resolve_config_path(), config.host)
|
||||
if token:
|
||||
config.api_key = token
|
||||
except Exception:
|
||||
logger.warning("Honcho OAuth pre-build refresh failed", exc_info=True)
|
||||
|
||||
|
||||
def _refresh_cached_oauth(client: "Honcho", config: HonchoClientConfig | None) -> None:
|
||||
"""Rotate the cached client's Bearer in place when its OAuth token is stale.
|
||||
|
||||
If the SDK shape changed and the in-place rotation can't apply, the slot is
|
||||
reset so the next acquisition rebuilds with the fresh token.
|
||||
"""
|
||||
try:
|
||||
from plugins.memory.honcho import oauth
|
||||
|
||||
host = config.host if config is not None else resolve_active_host()
|
||||
token, refreshed = oauth.ensure_fresh_token(resolve_config_path(), host)
|
||||
if refreshed and token and not oauth.apply_token_to_client(client, token):
|
||||
_honcho_client_slot.reset()
|
||||
except Exception:
|
||||
logger.warning("Honcho OAuth cached refresh failed", exc_info=True)
|
||||
|
||||
|
||||
def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
|
||||
"""Get or create the Honcho client singleton.
|
||||
|
||||
|
|
@ -754,11 +787,16 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
|
|||
"""
|
||||
cached = _honcho_client_slot.peek()
|
||||
if cached is not None:
|
||||
_refresh_cached_oauth(cached, config)
|
||||
return cached
|
||||
|
||||
if config is None:
|
||||
config = HonchoClientConfig.from_global_config()
|
||||
|
||||
# Refresh a near-expiry OAuth grant before the first build so the client
|
||||
# starts with a live access token rather than 401ing an hour in.
|
||||
_apply_fresh_oauth_token(config)
|
||||
|
||||
if not config.api_key and not config.base_url:
|
||||
raise ValueError(
|
||||
"Honcho API key not found. "
|
||||
|
|
|
|||
371
plugins/memory/honcho/oauth.py
Normal file
371
plugins/memory/honcho/oauth.py
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
"""OAuth credential storage and refresh for the Honcho memory provider.
|
||||
|
||||
An access token authenticates exactly like a scoped API key, so it is stored
|
||||
as the host's ``apiKey``; this module exchanges the refresh token before
|
||||
expiry to keep it live.
|
||||
|
||||
Refresh tokens rotate with single-use reuse detection: a replayed stale token
|
||||
revokes the whole grant. So every refresh must persist the rotated token
|
||||
atomically and be serialized — and a failed refresh never raises into the
|
||||
agent (stale token stays; the fail-open path absorbs the eventual 401).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ACCESS_TOKEN_PREFIX = "hch-at-"
|
||||
REFRESH_TOKEN_PREFIX = "hch-rt-"
|
||||
|
||||
# Refresh this many seconds before the access token actually expires, so an
|
||||
# in-flight request never races the expiry boundary.
|
||||
_REFRESH_SKEW_SECONDS = 120
|
||||
|
||||
# Default HTTP timeout for the token exchange. Kept short — the refresh happens
|
||||
# on the path to a memory call, and a stalled auth server must not hang it.
|
||||
_REFRESH_TIMEOUT_SECONDS = 15.0
|
||||
|
||||
# Serializes refresh across threads sharing one process's config. Re-checked
|
||||
# under the lock (double-checked) so racing callers don't replay a rotated
|
||||
# refresh token and trip reuse detection.
|
||||
_refresh_lock = threading.Lock()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _config_refresh_lock(path: Path):
|
||||
"""Machine-wide advisory lock around read-refresh-persist.
|
||||
|
||||
The in-process ``_refresh_lock`` can't stop a second process (a sibling
|
||||
Hermes profile or the desktop app sharing this honcho.json) from replaying
|
||||
the single-use refresh token and tripping reuse-detection — which revokes
|
||||
the whole grant. An OS file lock on ``<config>.lock`` serializes rotation
|
||||
across processes; best-effort, so a platform without flock degrades to
|
||||
in-process serialization only.
|
||||
"""
|
||||
lock_path = Path(f"{path}.lock")
|
||||
fh = None
|
||||
try:
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fh = open(lock_path, "a+b")
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
fh.seek(0)
|
||||
msvcrt.locking(fh.fileno(), msvcrt.LK_LOCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
|
||||
except Exception:
|
||||
logger.debug("Honcho OAuth cross-process lock unavailable; in-process only", exc_info=True)
|
||||
if fh is not None:
|
||||
fh.close()
|
||||
fh = None
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if fh is not None:
|
||||
try:
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
fh.seek(0)
|
||||
msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
|
||||
except Exception:
|
||||
pass
|
||||
fh.close()
|
||||
|
||||
# In-memory expiry cache keyed by (config path, host) → (expires_at, access).
|
||||
# Lets the hot path (every memory access calls this) skip the honcho.json read
|
||||
# while the token is comfortably live; disk is only touched near expiry, on a
|
||||
# cache miss, or when an explicit ``raw`` is supplied. Single-key dict ops are
|
||||
# atomic under the GIL, so no separate lock is needed. An access token stays
|
||||
# valid until its own expiry regardless of out-of-band rotation, so a stale
|
||||
# cache entry can't break auth — it just defers picking up external changes
|
||||
# until the token nears expiry and disk is read again.
|
||||
_expiry_cache: dict[tuple[str, str], tuple[float, str]] = {}
|
||||
|
||||
|
||||
def is_oauth_access_token(value: str | None) -> bool:
|
||||
"""True when ``value`` is an OAuth access token (vs a static API key)."""
|
||||
return bool(value) and value.startswith(ACCESS_TOKEN_PREFIX)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OAuthCredential:
|
||||
"""An OAuth grant as stored in a honcho.json host block.
|
||||
|
||||
``access_token`` mirrors the host's ``apiKey``; the remaining fields live in
|
||||
the host's ``oauth`` sub-block. ``expires_at`` is absolute epoch seconds.
|
||||
"""
|
||||
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
expires_at: float
|
||||
client_id: str
|
||||
token_endpoint: str
|
||||
scope: str = "write"
|
||||
token_type: str = "Bearer"
|
||||
# Transient consent peer name — set only on a fresh grant, never persisted.
|
||||
consent_peer_name: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_host_block(cls, block: dict[str, Any]) -> "OAuthCredential | None":
|
||||
"""Build a credential from a honcho.json host block, or None if incomplete."""
|
||||
oauth = block.get("oauth")
|
||||
access = block.get("apiKey")
|
||||
if not isinstance(oauth, dict) or not is_oauth_access_token(access):
|
||||
return None
|
||||
refresh = oauth.get("refreshToken")
|
||||
endpoint = oauth.get("tokenEndpoint")
|
||||
client_id = oauth.get("clientId")
|
||||
if not (refresh and endpoint and client_id):
|
||||
return None
|
||||
try:
|
||||
expires_at = float(oauth.get("expiresAt", 0))
|
||||
except (TypeError, ValueError):
|
||||
expires_at = 0.0
|
||||
return cls(
|
||||
access_token=access,
|
||||
refresh_token=str(refresh),
|
||||
expires_at=expires_at,
|
||||
client_id=str(client_id),
|
||||
token_endpoint=str(endpoint),
|
||||
scope=str(oauth.get("scope", "write")),
|
||||
token_type=str(oauth.get("tokenType", "Bearer")),
|
||||
)
|
||||
|
||||
def oauth_block(self) -> dict[str, Any]:
|
||||
"""The ``oauth`` sub-block to persist (the access token lives in apiKey)."""
|
||||
return {
|
||||
"refreshToken": self.refresh_token,
|
||||
"expiresAt": int(self.expires_at),
|
||||
"clientId": self.client_id,
|
||||
"tokenEndpoint": self.token_endpoint,
|
||||
"scope": self.scope,
|
||||
"tokenType": self.token_type,
|
||||
}
|
||||
|
||||
def is_expired(self, *, now: float, skew: float = _REFRESH_SKEW_SECONDS) -> bool:
|
||||
"""True when the access token is within ``skew`` seconds of expiry."""
|
||||
return now >= (self.expires_at - skew)
|
||||
|
||||
|
||||
# Indirection so tests can drive the exchange without a live server.
|
||||
def _http_post_form(url: str, data: dict[str, str], timeout: float) -> dict[str, Any]:
|
||||
"""POST form-encoded ``data`` to ``url`` and return the parsed JSON body."""
|
||||
import httpx
|
||||
|
||||
resp = httpx.post(url, data=data, timeout=timeout)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _exchange_refresh_token(cred: OAuthCredential, *, now: float) -> OAuthCredential:
|
||||
"""Run the refresh_token grant and return the rotated credential.
|
||||
|
||||
Raises on any transport/protocol failure; callers fail open.
|
||||
"""
|
||||
body = _http_post_form(
|
||||
cred.token_endpoint,
|
||||
{
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": cred.client_id,
|
||||
"refresh_token": cred.refresh_token,
|
||||
},
|
||||
_REFRESH_TIMEOUT_SECONDS,
|
||||
)
|
||||
access = body.get("access_token")
|
||||
refresh = body.get("refresh_token")
|
||||
if not is_oauth_access_token(access) or not refresh:
|
||||
raise ValueError("refresh response missing access_token/refresh_token")
|
||||
try:
|
||||
expires_in = int(body.get("expires_in", 0))
|
||||
except (TypeError, ValueError):
|
||||
expires_in = 0
|
||||
return OAuthCredential(
|
||||
access_token=access,
|
||||
refresh_token=str(refresh),
|
||||
expires_at=now + expires_in,
|
||||
client_id=cred.client_id,
|
||||
token_endpoint=cred.token_endpoint,
|
||||
scope=str(body.get("scope", cred.scope)),
|
||||
token_type=str(body.get("token_type", cred.token_type)),
|
||||
)
|
||||
|
||||
|
||||
def _read_config(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _atomic_write_config(path: Path, raw: dict[str, Any]) -> None:
|
||||
"""Write ``raw`` to ``path`` atomically, preserving 0600 on the new file."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_name(f".{path.name}.tmp")
|
||||
text = json.dumps(raw, indent=2) + "\n"
|
||||
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
fh.write(text)
|
||||
except Exception:
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def _deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Recursively merge ``overlay`` into ``base`` (overlay wins on scalars/lists)."""
|
||||
for key, value in overlay.items():
|
||||
if isinstance(value, dict) and isinstance(base.get(key), dict):
|
||||
_deep_merge(base[key], value)
|
||||
else:
|
||||
base[key] = value
|
||||
return base
|
||||
|
||||
|
||||
def _persist_credential(path: Path, host: str, cred: OAuthCredential) -> None:
|
||||
"""Persist ``cred`` into ``host``'s block (apiKey + oauth), leaving all else intact."""
|
||||
raw = _read_config(path)
|
||||
hosts = raw.setdefault("hosts", {})
|
||||
block = hosts.setdefault(host, {})
|
||||
block["apiKey"] = cred.access_token
|
||||
block["oauth"] = cred.oauth_block()
|
||||
_atomic_write_config(path, raw)
|
||||
_expiry_cache[(str(path), host)] = (cred.expires_at, cred.access_token)
|
||||
|
||||
|
||||
def ensure_fresh_token(
|
||||
path: Path,
|
||||
host: str,
|
||||
raw: dict[str, Any] | None = None,
|
||||
*,
|
||||
now: float | None = None,
|
||||
) -> tuple[str | None, bool]:
|
||||
"""Return ``(access_token, refreshed)`` for ``host``, refreshing if near expiry.
|
||||
|
||||
Returns ``(None, False)`` when the host has no OAuth credential (e.g. a plain
|
||||
API key) so callers leave the existing token untouched. Refresh failures are
|
||||
swallowed: the current (possibly stale) token is returned with
|
||||
``refreshed=False`` and the fail-open path handles any resulting 401.
|
||||
"""
|
||||
now = time.time() if now is None else now
|
||||
key = (str(path), host)
|
||||
|
||||
# Hot path: trust the cached expiry while the token is well clear of the
|
||||
# skew window — no disk read. Bypassed when an explicit ``raw`` is supplied.
|
||||
if raw is None:
|
||||
cached = _expiry_cache.get(key)
|
||||
if cached is not None and now < cached[0] - _REFRESH_SKEW_SECONDS:
|
||||
return cached[1], False
|
||||
|
||||
source = raw if raw is not None else _read_config(path)
|
||||
block = (source.get("hosts") or {}).get(host) or {}
|
||||
cred = OAuthCredential.from_host_block(block)
|
||||
if cred is None:
|
||||
_expiry_cache.pop(key, None)
|
||||
return None, False
|
||||
|
||||
_expiry_cache[key] = (cred.expires_at, cred.access_token)
|
||||
if not cred.is_expired(now=now):
|
||||
return cred.access_token, False
|
||||
|
||||
with _refresh_lock, _config_refresh_lock(path):
|
||||
# Re-read under both locks: another thread or process may have just
|
||||
# rotated the token — adopt theirs instead of replaying the old one.
|
||||
fresh_block = (_read_config(path).get("hosts") or {}).get(host) or {}
|
||||
current = OAuthCredential.from_host_block(fresh_block) or cred
|
||||
if not current.is_expired(now=now):
|
||||
return current.access_token, current.access_token != cred.access_token
|
||||
try:
|
||||
rotated = _exchange_refresh_token(current, now=now)
|
||||
except Exception as exc:
|
||||
logger.warning("Honcho OAuth refresh failed for host %s: %s", host, exc)
|
||||
return current.access_token, False
|
||||
_persist_credential(path, host, rotated)
|
||||
logger.info("Honcho OAuth token refreshed for host %s", host)
|
||||
return rotated.access_token, True
|
||||
|
||||
|
||||
def install_grant(
|
||||
path: Path,
|
||||
host: str,
|
||||
grant: dict[str, Any],
|
||||
*,
|
||||
client_id: str,
|
||||
token_endpoint: str,
|
||||
apply_config: bool = True,
|
||||
now: float | None = None,
|
||||
) -> OAuthCredential:
|
||||
"""Apply a fresh OAuth grant to ``path`` for ``host``.
|
||||
|
||||
Deep-merges the grant's ``config`` (the manifest default_config) into the
|
||||
file root — preserving other hosts and root keys — then writes the host's
|
||||
``apiKey`` and ``oauth`` block. ``grant`` is an OAuthTokenResponse dict
|
||||
(access_token, refresh_token, expires_in, scope, config).
|
||||
``apply_config=False`` skips the config merge and stores tokens only.
|
||||
"""
|
||||
now = time.time() if now is None else now
|
||||
access = grant.get("access_token")
|
||||
refresh = grant.get("refresh_token")
|
||||
if not is_oauth_access_token(access) or not refresh:
|
||||
raise ValueError("grant missing access_token/refresh_token")
|
||||
try:
|
||||
expires_in = int(grant.get("expires_in", 0))
|
||||
except (TypeError, ValueError):
|
||||
expires_in = 0
|
||||
|
||||
cred = OAuthCredential(
|
||||
access_token=access,
|
||||
refresh_token=str(refresh),
|
||||
expires_at=now + expires_in,
|
||||
client_id=client_id,
|
||||
token_endpoint=token_endpoint,
|
||||
scope=str(grant.get("scope", "write")),
|
||||
token_type=str(grant.get("token_type", "Bearer")),
|
||||
)
|
||||
|
||||
raw = _read_config(path)
|
||||
granted_config = grant.get("config")
|
||||
if isinstance(granted_config, dict):
|
||||
cred.consent_peer_name = granted_config.get("peerName")
|
||||
if apply_config:
|
||||
_deep_merge(raw, granted_config)
|
||||
_expiry_cache[(str(path), host)] = (cred.expires_at, cred.access_token)
|
||||
hosts = raw.setdefault("hosts", {})
|
||||
block = hosts.setdefault(host, {})
|
||||
block["apiKey"] = cred.access_token
|
||||
block["oauth"] = cred.oauth_block()
|
||||
_atomic_write_config(path, raw)
|
||||
return cred
|
||||
|
||||
|
||||
def apply_token_to_client(client: Any, token: str) -> bool:
|
||||
"""Rotate the live Honcho client's Bearer in place. Returns success.
|
||||
|
||||
The SDK builds its auth header per request from the HTTP client's
|
||||
``api_key``, so mutating it rotates every holder of the singleton without a
|
||||
rebuild. Guarded: an SDK shape change degrades to False and the caller can
|
||||
fall back to resetting the client.
|
||||
"""
|
||||
http = getattr(client, "_http", None)
|
||||
if http is None or not hasattr(http, "api_key"):
|
||||
return False
|
||||
http.api_key = token
|
||||
return True
|
||||
431
plugins/memory/honcho/oauth_flow.py
Normal file
431
plugins/memory/honcho/oauth_flow.py
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
"""Browser sign-in flow for the Honcho memory provider — no CLI step.
|
||||
|
||||
``begin_authorization`` / ``complete_authorization`` are the transport-agnostic
|
||||
core: the code can arrive via the loopback listener here or a future
|
||||
``hermes://`` handler. Endpoints are env-overridable with local-dev defaults
|
||||
because ``/authorize`` (dashboard) and ``/oauth/token`` (API) live on
|
||||
different origins.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
from urllib.parse import parse_qs, urlencode, urlparse
|
||||
|
||||
from plugins.memory.honcho import oauth
|
||||
from plugins.memory.honcho.client import resolve_active_host, resolve_config_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The loopback redirect registered for the Hermes OAuth client. IP-literal so
|
||||
# the browser can't resolve the advertised host to ::1 and miss the IPv4 bind.
|
||||
LOOPBACK_HOST = "127.0.0.1"
|
||||
LOOPBACK_PORT = 8765
|
||||
LOOPBACK_REDIRECT_URI = f"http://{LOOPBACK_HOST}:{LOOPBACK_PORT}/callback"
|
||||
|
||||
# Pending authorizations live only until their callback returns; keyed by the
|
||||
# CSRF ``state`` so a stray/forged callback can't complete a grant.
|
||||
_PENDING_TTL_SECONDS = 600
|
||||
|
||||
|
||||
def _display_config_path(path: object) -> str:
|
||||
"""Home-relative display string for the consent screen.
|
||||
|
||||
The absolute path (username + home layout) never leaves the machine — it's
|
||||
only shown to the user. Collapse ``$HOME`` to ``~``; for a path outside
|
||||
home, send the bare filename rather than leak an arbitrary absolute path.
|
||||
"""
|
||||
from pathlib import Path as _Path
|
||||
|
||||
p = _Path(str(path))
|
||||
try:
|
||||
return "~/" + str(p.relative_to(_Path.home()))
|
||||
except ValueError:
|
||||
return p.name
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OAuthEndpoints:
|
||||
"""Resolved authorization-server URLs and client identity."""
|
||||
|
||||
authorize_url: str # dashboard /authorize
|
||||
token_url: str # API /oauth/token
|
||||
client_id: str
|
||||
scope: str
|
||||
|
||||
|
||||
# Cloud (production) hosts; dashboard serves /authorize, API serves /oauth/token.
|
||||
_CLOUD_DASHBOARD = "https://app.honcho.dev"
|
||||
_CLOUD_TOKEN_URL = "https://api.honcho.dev/oauth/token"
|
||||
_LOCAL_DASHBOARD = "http://localhost:3000"
|
||||
_LOCAL_TOKEN_URL = "http://localhost:8000/oauth/token"
|
||||
|
||||
# One OAuth client for every surface. Consent branding/UI adapt via the
|
||||
# ``source`` query param (not a separate client_id), so there's a single grant
|
||||
# identity to refresh — no clientId-vs-refresh-token desync to revoke the grant.
|
||||
_DEFAULT_CLIENT_ID = "hermes-agent"
|
||||
|
||||
|
||||
def _is_loopback_url(url: str | None) -> bool:
|
||||
return bool(url) and any(h in url for h in ("localhost", "127.0.0.1", "::1"))
|
||||
|
||||
|
||||
def resolve_endpoints(
|
||||
environment: str | None = None, base_url: str | None = None
|
||||
) -> OAuthEndpoints:
|
||||
"""Resolve OAuth endpoints, zero-config by default.
|
||||
|
||||
Keys off the host's honcho ``environment`` (production → cloud, local →
|
||||
localhost); a self-hosted ``base_url`` derives the token endpoint from the
|
||||
API host. Env vars override every field for unusual deployments.
|
||||
"""
|
||||
if environment is None or base_url is None:
|
||||
try:
|
||||
from plugins.memory.honcho.client import HonchoClientConfig
|
||||
|
||||
cfg = HonchoClientConfig.from_global_config()
|
||||
environment = environment or cfg.environment
|
||||
base_url = base_url if base_url is not None else cfg.base_url
|
||||
except Exception:
|
||||
environment = environment or "production"
|
||||
|
||||
is_local = (environment or "").lower() == "local" or _is_loopback_url(base_url)
|
||||
default_dashboard = _LOCAL_DASHBOARD if is_local else _CLOUD_DASHBOARD
|
||||
default_token = _LOCAL_TOKEN_URL if is_local else _CLOUD_TOKEN_URL
|
||||
# Self-hosted API (non-loopback base_url): token rides the same host.
|
||||
if base_url and not is_local:
|
||||
default_token = f"{base_url.rstrip('/')}/oauth/token"
|
||||
|
||||
dashboard = os.environ.get("HONCHO_OAUTH_DASHBOARD", default_dashboard).rstrip("/")
|
||||
return OAuthEndpoints(
|
||||
authorize_url=os.environ.get("HONCHO_OAUTH_AUTHORIZE_URL", f"{dashboard}/authorize"),
|
||||
token_url=os.environ.get("HONCHO_OAUTH_TOKEN_URL", default_token),
|
||||
client_id=os.environ.get("HONCHO_OAUTH_CLIENT_ID", _DEFAULT_CLIENT_ID),
|
||||
scope=os.environ.get("HONCHO_OAUTH_SCOPE", "write"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Pending:
|
||||
verifier: str
|
||||
redirect_uri: str
|
||||
created_at: float
|
||||
|
||||
|
||||
_pending: dict[str, _Pending] = {}
|
||||
_pending_lock = threading.Lock()
|
||||
|
||||
|
||||
def _pkce() -> tuple[str, str]:
|
||||
"""Return (verifier, S256 challenge) for an authorization-code request."""
|
||||
verifier = secrets.token_urlsafe(64)
|
||||
challenge = (
|
||||
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
|
||||
.rstrip(b"=")
|
||||
.decode()
|
||||
)
|
||||
return verifier, challenge
|
||||
|
||||
|
||||
def _prune_pending(now: float) -> None:
|
||||
expired = [s for s, p in _pending.items() if now - p.created_at > _PENDING_TTL_SECONDS]
|
||||
for state in expired:
|
||||
_pending.pop(state, None)
|
||||
|
||||
|
||||
def begin_authorization(
|
||||
endpoints: OAuthEndpoints,
|
||||
redirect_uri: str = LOOPBACK_REDIRECT_URI,
|
||||
*,
|
||||
source: str | None = None,
|
||||
config_path: str | None = None,
|
||||
now: float | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""Start an authorization: return ``(authorize_url, state)`` and stash PKCE.
|
||||
|
||||
``source`` tags the authorize link with the initiating surface
|
||||
(``hermes-desktop`` / ``hermes-cli``) so the consent side can attribute
|
||||
connects and vary behavior per surface. ``config_path`` is a home-relative
|
||||
*display* string for the consent screen (never the absolute path); callers
|
||||
pass the actual write path separately to ``complete_authorization``.
|
||||
"""
|
||||
now = time.time() if now is None else now
|
||||
verifier, challenge = _pkce()
|
||||
state = secrets.token_urlsafe(32)
|
||||
with _pending_lock:
|
||||
_prune_pending(now)
|
||||
_pending[state] = _Pending(verifier=verifier, redirect_uri=redirect_uri, created_at=now)
|
||||
params = {
|
||||
"client_id": endpoints.client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": endpoints.scope,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"response_type": "code",
|
||||
"state": state,
|
||||
}
|
||||
if source:
|
||||
params["source"] = source
|
||||
if config_path:
|
||||
params["config_path"] = config_path
|
||||
return f"{endpoints.authorize_url}?{urlencode(params)}", state
|
||||
|
||||
|
||||
def complete_authorization(
|
||||
endpoints: OAuthEndpoints,
|
||||
code: str,
|
||||
state: str,
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
host: str | None = None,
|
||||
apply_config: bool = True,
|
||||
now: float | None = None,
|
||||
) -> oauth.OAuthCredential:
|
||||
"""Exchange ``code`` for a grant and persist it. Raises on bad state/exchange.
|
||||
|
||||
``apply_config=False`` stores the tokens only, skipping the grant's config
|
||||
block — the CLI path, where settings stay wizard-owned.
|
||||
"""
|
||||
with _pending_lock:
|
||||
pending = _pending.pop(state, None)
|
||||
if pending is None:
|
||||
raise ValueError("unknown or expired authorization state")
|
||||
|
||||
grant = oauth._http_post_form(
|
||||
endpoints.token_url,
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": endpoints.client_id,
|
||||
"code": code,
|
||||
"redirect_uri": pending.redirect_uri,
|
||||
"code_verifier": pending.verifier,
|
||||
},
|
||||
oauth._REFRESH_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
path = config_path or resolve_config_path()
|
||||
target_host = host or resolve_active_host()
|
||||
cred = oauth.install_grant(
|
||||
path,
|
||||
target_host,
|
||||
grant,
|
||||
client_id=endpoints.client_id,
|
||||
token_endpoint=endpoints.token_url,
|
||||
apply_config=apply_config,
|
||||
now=now,
|
||||
)
|
||||
# Drop the singleton so the next acquisition builds with the new token.
|
||||
from plugins.memory.honcho.client import reset_honcho_client
|
||||
|
||||
reset_honcho_client()
|
||||
logger.info("Honcho OAuth grant installed for host %s", target_host)
|
||||
return cred
|
||||
|
||||
|
||||
_CALLBACK_HTML = (
|
||||
b"<!doctype html><meta charset=utf-8>"
|
||||
b"<title>Honcho connected</title>"
|
||||
b"<body style='font:14px ui-monospace,monospace;background:#0b0e14;color:#c9d1d9;"
|
||||
b"display:flex;align-items:center;justify-content:center;height:100vh;margin:0'>"
|
||||
b"<div>Connected to Honcho. You can close this tab and return to Hermes.</div>"
|
||||
)
|
||||
|
||||
|
||||
def _bind_loopback_server() -> tuple[HTTPServer, dict[str, str]]:
|
||||
"""Bind the one-shot callback server, returning it and its capture dict.
|
||||
|
||||
Prefers :8765; if that's taken, falls back to an OS-assigned port. groudon's
|
||||
redirect matcher relaxes the port for loopback hosts, so the fallback still
|
||||
matches the seeded ``127.0.0.1`` redirect URI — the caller advertises the
|
||||
actual bound port.
|
||||
"""
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self): # noqa: N802 - stdlib API name
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path != "/callback":
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
return
|
||||
params = parse_qs(parsed.query)
|
||||
captured["code"] = (params.get("code") or [""])[0]
|
||||
captured["state"] = (params.get("state") or [""])[0]
|
||||
captured["error"] = (params.get("error") or [""])[0]
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.end_headers()
|
||||
self.wfile.write(_CALLBACK_HTML)
|
||||
|
||||
def log_message(self, *args): # silence stdlib request logging
|
||||
return
|
||||
|
||||
try:
|
||||
server = HTTPServer((LOOPBACK_HOST, LOOPBACK_PORT), _Handler)
|
||||
except OSError:
|
||||
server = HTTPServer((LOOPBACK_HOST, 0), _Handler) # OS-assigned fallback
|
||||
return server, captured
|
||||
|
||||
|
||||
def capture_loopback_code(
|
||||
server: HTTPServer, captured: dict[str, str], *, timeout: float = 300.0
|
||||
) -> tuple[str, str]:
|
||||
"""Serve a single ``/callback`` GET on ``server`` and return ``(code, state)``.
|
||||
|
||||
Replies with a close-this-tab page, then stops. Raises ``TimeoutError`` if no
|
||||
callback arrives within ``timeout``.
|
||||
"""
|
||||
server.timeout = timeout
|
||||
try:
|
||||
# handle_request honors server.timeout; loop until our callback lands so a
|
||||
# stray probe to another path doesn't end the wait empty-handed.
|
||||
deadline = time.monotonic() + timeout
|
||||
while "code" not in captured and time.monotonic() < deadline:
|
||||
server.handle_request()
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
if captured.get("error"):
|
||||
raise ValueError(f"authorization denied: {captured['error']}")
|
||||
if "code" not in captured:
|
||||
raise TimeoutError("no OAuth callback received before timeout")
|
||||
return captured["code"], captured.get("state", "")
|
||||
|
||||
|
||||
def authorize_via_loopback(
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
host: str | None = None,
|
||||
source: str | None = None,
|
||||
apply_config: bool = True,
|
||||
open_url: Callable[[str], None] | None = None,
|
||||
timeout: float = 300.0,
|
||||
) -> oauth.OAuthCredential:
|
||||
"""Drive the full loopback flow: open browser → capture code → exchange → persist.
|
||||
|
||||
``open_url`` defaults to the system browser; tests inject a driver that
|
||||
follows the authorize redirect into the loopback callback. It always
|
||||
receives the authorize URL, so a CLI caller can also print it for
|
||||
browserless environments.
|
||||
"""
|
||||
# Bind first so the advertised redirect_uri carries the actual bound port
|
||||
# (which may differ from :8765 if it was taken).
|
||||
server, captured = _bind_loopback_server()
|
||||
redirect_uri = f"http://{LOOPBACK_HOST}:{server.server_address[1]}/callback"
|
||||
|
||||
endpoints = resolve_endpoints()
|
||||
path = config_path or resolve_config_path()
|
||||
authorize_url, state = begin_authorization(
|
||||
endpoints, redirect_uri, source=source, config_path=_display_config_path(path)
|
||||
)
|
||||
|
||||
if open_url is None:
|
||||
import webbrowser
|
||||
|
||||
open_url = webbrowser.open
|
||||
|
||||
# Browser opens from a short-lived thread; the socket is already bound, so a
|
||||
# fast redirect can't beat it.
|
||||
opener = threading.Thread(target=lambda: open_url(authorize_url), daemon=True)
|
||||
opener.start()
|
||||
|
||||
code, returned_state = capture_loopback_code(server, captured, timeout=timeout)
|
||||
if returned_state != state:
|
||||
raise ValueError("OAuth state mismatch — possible CSRF, aborting")
|
||||
return complete_authorization(
|
||||
endpoints,
|
||||
code,
|
||||
returned_state,
|
||||
config_path=path,
|
||||
host=host,
|
||||
apply_config=apply_config,
|
||||
)
|
||||
|
||||
|
||||
# — Background launcher + status, for the desktop "Connect" button —
|
||||
# The flow blocks on a browser round-trip, so the web_server endpoint kicks it
|
||||
# off in a thread and the UI polls status rather than holding the request open.
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlowStatus:
|
||||
state: str = "idle" # idle | pending | connected | error
|
||||
detail: str = ""
|
||||
|
||||
|
||||
_status = FlowStatus()
|
||||
_status_lock = threading.Lock()
|
||||
_flow_thread: threading.Thread | None = None
|
||||
|
||||
|
||||
def _detect_connection() -> tuple[bool, str | None]:
|
||||
"""Report whether a credential is already stored: 'oauth', 'apikey', or none."""
|
||||
try:
|
||||
from plugins.memory.honcho.client import HonchoClientConfig
|
||||
|
||||
cfg = HonchoClientConfig.from_global_config()
|
||||
block = (cfg.raw.get("hosts") or {}).get(cfg.host) or {}
|
||||
if oauth.OAuthCredential.from_host_block(block) is not None:
|
||||
return True, "oauth"
|
||||
if cfg.api_key:
|
||||
return True, "apikey"
|
||||
except Exception:
|
||||
pass
|
||||
return False, None
|
||||
|
||||
|
||||
def get_flow_status() -> dict[str, object]:
|
||||
with _status_lock:
|
||||
state, detail = _status.state, _status.detail
|
||||
connected, auth = _detect_connection()
|
||||
return {"state": state, "detail": detail, "connected": connected, "auth": auth}
|
||||
|
||||
|
||||
def _set_status(state: str, detail: str = "") -> None:
|
||||
with _status_lock:
|
||||
_status.state, _status.detail = state, detail
|
||||
|
||||
|
||||
def start_loopback_flow_background(
|
||||
*,
|
||||
config_path: Path | None = None,
|
||||
host: str | None = None,
|
||||
source: str = "hermes-desktop",
|
||||
timeout: float = 300.0,
|
||||
) -> dict[str, str]:
|
||||
"""Launch the loopback flow in a daemon thread; returns the initial status.
|
||||
|
||||
Idempotent while a flow is pending — a second call is a no-op so a
|
||||
double-clicked button can't open two browser tabs / bind :8765 twice.
|
||||
"""
|
||||
global _flow_thread
|
||||
# Resolve under the caller's profile scope NOW — the worker thread outlives
|
||||
# the request, where a context-local HERMES_HOME override can't reach.
|
||||
config_path = config_path or resolve_config_path()
|
||||
host = host or resolve_active_host()
|
||||
with _status_lock:
|
||||
if _status.state == "pending" and _flow_thread and _flow_thread.is_alive():
|
||||
return {"state": _status.state, "detail": _status.detail}
|
||||
_status.state, _status.detail = "pending", "waiting for browser consent"
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
authorize_via_loopback(config_path=config_path, host=host, source=source, timeout=timeout)
|
||||
_set_status("connected", "Honcho connected")
|
||||
except Exception as exc:
|
||||
logger.warning("Honcho OAuth loopback flow failed: %s", exc)
|
||||
_set_status("error", str(exc))
|
||||
|
||||
_flow_thread = threading.Thread(target=_run, name="honcho-oauth-loopback", daemon=True)
|
||||
_flow_thread.start()
|
||||
return get_flow_status()
|
||||
|
|
@ -154,9 +154,12 @@ class HonchoSessionManager:
|
|||
|
||||
@property
|
||||
def honcho(self) -> Honcho:
|
||||
"""Get the Honcho client, initializing if needed."""
|
||||
if self._honcho is None:
|
||||
self._honcho = get_honcho_client()
|
||||
"""Get the Honcho client, refreshing a near-expiry OAuth token in place.
|
||||
|
||||
Routes every access through ``get_honcho_client`` (which returns the same
|
||||
cached singleton) so a long session can't outlive its 1h access token.
|
||||
"""
|
||||
self._honcho = get_honcho_client()
|
||||
return self._honcho
|
||||
|
||||
def _get_or_create_peer(self, peer_id: str) -> Any:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue