From ba9e3a491bfaa04fbadbb165d3691aca2f80a9e8 Mon Sep 17 00:00:00 2001 From: Eri Barrett Date: Mon, 22 Jun 2026 20:16:47 -0400 Subject: [PATCH] =?UTF-8?q?feat(memory):=20Honcho=20OAuth=20connect=20?= =?UTF-8?q?=E2=80=94=20desktop=20and=20CLI=20flows=20+=20token=20refresh?= =?UTF-8?q?=20(#44335)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 .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). --- .../src/app/settings/config-settings.tsx | 21 +- .../src/app/settings/memory/connect.tsx | 162 +++++++ apps/desktop/src/hermes.ts | 19 + apps/desktop/src/types/hermes.ts | 7 + hermes_cli/memory_oauth.py | 83 ++++ hermes_cli/web_server.py | 5 + plugins/memory/honcho/README.md | 25 +- plugins/memory/honcho/cli.py | 86 +++- plugins/memory/honcho/client.py | 82 +++- plugins/memory/honcho/oauth.py | 371 +++++++++++++++ plugins/memory/honcho/oauth_flow.py | 431 ++++++++++++++++++ plugins/memory/honcho/session.py | 9 +- tests/honcho_plugin/test_async_memory.py | 26 +- tests/honcho_plugin/test_cli.py | 63 +++ tests/honcho_plugin/test_client.py | 8 +- tests/honcho_plugin/test_oauth.py | 254 +++++++++++ tests/honcho_plugin/test_oauth_flow.py | 347 ++++++++++++++ .../user-guide/features/memory-providers.md | 2 + 18 files changed, 1948 insertions(+), 53 deletions(-) create mode 100644 apps/desktop/src/app/settings/memory/connect.tsx create mode 100644 hermes_cli/memory_oauth.py create mode 100644 plugins/memory/honcho/oauth.py create mode 100644 plugins/memory/honcho/oauth_flow.py create mode 100644 tests/honcho_plugin/test_oauth.py create mode 100644 tests/honcho_plugin/test_oauth_flow.py diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index 771ba2836f4..3f570f7adfb 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -21,6 +21,7 @@ import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes' import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants' import { fieldCopyForSchemaKey } from './field-copy' import { enumOptionsFor, getNested, prettyName, setNested } from './helpers' +import { MemoryConnect } from './memory/connect' import { ModelSettings } from './model-settings' import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives' import { ProviderConfigPanel } from './provider-config-panel' @@ -31,7 +32,8 @@ function ConfigField({ value, enumOptions, optionLabels, - onChange + onChange, + descriptionExtra }: { schemaKey: string schema: ConfigFieldSchema @@ -39,6 +41,7 @@ function ConfigField({ enumOptions?: string[] optionLabels?: Record onChange: (value: unknown) => void + descriptionExtra?: ReactNode }) { const { t } = useI18n() const c = t.settings.config @@ -64,8 +67,17 @@ function ConfigField({ ? rawDescription : undefined + const descriptionNode: ReactNode = descriptionExtra ? ( + + {description} + {descriptionExtra} + + ) : ( + description + ) + const row = (action: ReactNode, wide = false) => ( - + ) if (schema.type === 'boolean') { @@ -358,6 +370,11 @@ export function ConfigSettings({ {fields.map(([key, field]) => (
+ ) : undefined + } enumOptions={ key === 'tts.elevenlabs.voice_id' ? enumOptionsFor(key, getNested(config, key), config, elevenLabsVoiceOptions ?? undefined) diff --git a/apps/desktop/src/app/settings/memory/connect.tsx b/apps/desktop/src/app/settings/memory/connect.tsx new file mode 100644 index 00000000000..75ff9a64750 --- /dev/null +++ b/apps/desktop/src/app/settings/memory/connect.tsx @@ -0,0 +1,162 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { getMemoryProviderOAuthStatus, startMemoryProviderOAuth } from '@/hermes' +import { Check, ExternalLink, Loader2 } from '@/lib/icons' +import { notifyError } from '@/store/notifications' +import type { MemoryProviderOAuthStatus } from '@/types/hermes' + +const POLL_MS = 1500 +const POLL_TIMEOUT_MS = 120_000 + +// Small connect affordance rendered under the provider dropdown. Capability is +// backend-driven: the status route 404s for providers without an oauth_flow +// module, so non-OAuth providers render nothing. +export function MemoryConnect({ provider }: { provider: string }) { + const [capable, setCapable] = useState<'no' | 'unknown' | 'yes'>('unknown') + const [connected, setConnected] = useState(false) + const [auth, setAuth] = useState(null) + const [phase, setPhase] = useState<'error' | 'idle' | 'pending'>('idle') + const [detail, setDetail] = useState('') + const timer = useRef | null>(null) + const deadline = useRef(0) + + const stop = useCallback(() => { + if (timer.current !== null) { + clearInterval(timer.current) + timer.current = null + } + }, []) + + useEffect(() => { + let active = true + setCapable('unknown') + getMemoryProviderOAuthStatus(provider) + .then(s => { + if (!active) { + return + } + + setCapable('yes') + setConnected(s.connected) + setAuth(s.auth) + }) + .catch(() => { + if (active) { + setCapable('no') + } + }) + + return () => { + active = false + stop() + } + }, [provider, stop]) + + // An error message isn't sticky — it clears back to the steady state + // (Connect link, plus the connected badge if a credential is stored). + useEffect(() => { + if (phase !== 'error') { + return + } + + const t = setTimeout(() => { + setPhase('idle') + setDetail('') + }, 6000) + + return () => clearTimeout(t) + }, [phase]) + + const connect = useCallback(async () => { + setPhase('pending') + + try { + await startMemoryProviderOAuth(provider) + } catch (err) { + setPhase('error') + setDetail('Could not start the connection.') + notifyError(err, 'Failed to start connection') + + return + } + + deadline.current = Date.now() + POLL_TIMEOUT_MS + stop() + timer.current = setInterval(() => { + void (async () => { + try { + const next = await getMemoryProviderOAuthStatus(provider) + + if (next.state === 'pending') { + if (Date.now() > deadline.current) { + stop() + setPhase('error') + setDetail('Timed out — try again.') + } + + return + } + + stop() + setConnected(next.connected) + setAuth(next.auth) + + if (next.state === 'error') { + setPhase('error') + setDetail(next.detail || 'Connection failed.') + } else { + setPhase('idle') + } + } catch { + // Transient poll failure — keep trying until the deadline. + } + })() + }, POLL_MS) + }, [provider, stop]) + + const cancel = useCallback(() => { + stop() + setPhase('idle') + }, [stop]) + + if (capable !== 'yes') { + return null + } + + const connectLabel = connected ? (auth === 'apikey' ? 'Connect via OAuth' : 'Reconnect') : 'Connect' + + return ( + + {phase === 'idle' && connected && ( + + + {auth === 'apikey' ? 'api key set' : 'oauth set'} + + )} + {phase === 'pending' ? ( + <> + + + Waiting for browser consent… + + + + ) : ( + + )} + {phase === 'error' && detail && {detail}} + + ) +} diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index a7b5ae14307..e29ca5b5ac1 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -19,6 +19,7 @@ import type { HermesConfigRecord, LogsResponse, MemoryProviderConfig, + MemoryProviderOAuthStatus, MessagingPlatformsResponse, MessagingPlatformTestResponse, MessagingPlatformUpdate, @@ -77,6 +78,7 @@ export type { HermesConfigRecord, LogsResponse, MemoryProviderConfig, + MemoryProviderOAuthStatus, MessagingEnvVarInfo, MessagingHomeChannel, MessagingPlatformInfo, @@ -457,6 +459,23 @@ export function cancelOAuthSession(sessionId: string): Promise<{ ok: boolean }> }) } +// Memory-provider OAuth connect (provider-keyed; 404s for providers without an +// OAuth flow). Profile-scoped: the grant lands in the active profile's config. +export function startMemoryProviderOAuth(provider: string): Promise { + return window.hermesDesktop.api({ + ...profileScoped(), + path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/start`, + method: 'POST' + }) +} + +export function getMemoryProviderOAuthStatus(provider: string): Promise { + return window.hermesDesktop.api({ + ...profileScoped(), + path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/status` + }) +} + export function getSkills(): Promise { return window.hermesDesktop.api({ ...profileScoped(), diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 338ed2d3544..1dc2d6be50e 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -98,6 +98,13 @@ export interface OAuthPollResponse { status: 'approved' | 'denied' | 'error' | 'expired' | 'pending' } +export interface MemoryProviderOAuthStatus { + auth: 'apikey' | 'oauth' | null + connected: boolean + detail: string + state: 'connected' | 'error' | 'idle' | 'pending' +} + export interface EnvVarInfo { advanced: boolean category: string diff --git a/hermes_cli/memory_oauth.py b/hermes_cli/memory_oauth.py new file mode 100644 index 00000000000..34ee3e8c70e --- /dev/null +++ b/hermes_cli/memory_oauth.py @@ -0,0 +1,83 @@ +"""HTTP routes for memory-provider OAuth connect, mounted by ``web_server``. + +Kept out of ``web_server.py`` so the memory feature's surface stays in the +memory layer. Dispatch is by convention: a provider's flow lives at +``plugins.memory..oauth_flow`` exposing ``start_loopback_flow_background`` +and ``get_flow_status``; a provider without that module simply 404s. No provider +is named here. +""" + +from __future__ import annotations + +import importlib +from contextlib import contextmanager +from typing import Optional + +from fastapi import APIRouter, HTTPException + +router = APIRouter(prefix="/api/memory/providers") + + +def _resolve_flow(provider: str): + """Return a provider's OAuth flow module by convention, or raise 404.""" + if not provider.isidentifier(): + raise HTTPException(status_code=404, detail=f"unknown memory provider {provider!r}") + try: + return importlib.import_module(f"plugins.memory.{provider}.oauth_flow") + except ImportError: + raise HTTPException(status_code=404, detail=f"{provider} does not support OAuth connect") + + +@contextmanager +def _scope_to_profile(profile: Optional[str]): + """Scope config resolution to ``profile`` so the flow's eager path resolve + targets that profile's honcho.json. None/""/"current" leaves it untouched.""" + requested = (profile or "").strip() + if not requested or requested.lower() == "current": + yield + return + + from hermes_cli import profiles as profiles_mod + from hermes_constants import reset_hermes_home_override, set_hermes_home_override + + try: + profiles_mod.validate_profile_name(requested) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + if not profiles_mod.profile_exists(requested): + raise HTTPException(status_code=404, detail=f"Profile '{requested}' does not exist.") + + token = set_hermes_home_override(str(profiles_mod.get_profile_dir(requested))) + try: + yield + finally: + reset_hermes_home_override(token) + + +@router.post("/{provider}/oauth/start") +async def start_memory_oauth(provider: str, profile: Optional[str] = None): + """Begin a provider's zero-CLI OAuth flow — opens the browser and captures + the grant via the loopback listener. Returns immediately; poll status.""" + flow = _resolve_flow(provider) + try: + # The flow resolves its config path eagerly inside this scope; the worker + # thread it spawns outlives the request and the override. + with _scope_to_profile(profile): + return flow.start_loopback_flow_background() + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Failed to start {provider} OAuth: {exc}") + + +@router.get("/{provider}/oauth/status") +async def memory_oauth_status(provider: str, profile: Optional[str] = None): + """Poll a provider's OAuth flow: idle | pending | connected | error.""" + flow = _resolve_flow(provider) + try: + with _scope_to_profile(profile): + return flow.get_flow_status() + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Failed to read {provider} OAuth status: {exc}") diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index c6a6b065589..aa92cdd548f 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -234,6 +234,11 @@ def _get_chat_argv_lock(app: "FastAPI") -> asyncio.Lock: app = FastAPI(title="Hermes Agent", version=__version__, lifespan=_lifespan) +# Memory-provider OAuth connect routes live in the memory layer, not here. +from hermes_cli.memory_oauth import router as _memory_oauth_router # noqa: E402 + +app.include_router(_memory_oauth_router) + # --------------------------------------------------------------------------- # Session token for protecting sensitive endpoints (reveal). # The desktop shell mints the token and injects it via diff --git a/plugins/memory/honcho/README.md b/plugins/memory/honcho/README.md index cb9b720bf56..1eef9451c62 100644 --- a/plugins/memory/honcho/README.md +++ b/plugins/memory/honcho/README.md @@ -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 diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index cc19711e956..8fc37448fd4 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -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 diff --git a/plugins/memory/honcho/client.py b/plugins/memory/honcho/client.py index df8c839aa81..271eea63e22 100644 --- a/plugins/memory/honcho/client.py +++ b/plugins/memory/honcho/client.py @@ -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. " diff --git a/plugins/memory/honcho/oauth.py b/plugins/memory/honcho/oauth.py new file mode 100644 index 00000000000..0926ab2f0cc --- /dev/null +++ b/plugins/memory/honcho/oauth.py @@ -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 ``.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 diff --git a/plugins/memory/honcho/oauth_flow.py b/plugins/memory/honcho/oauth_flow.py new file mode 100644 index 00000000000..fad4cc9c86e --- /dev/null +++ b/plugins/memory/honcho/oauth_flow.py @@ -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"" + b"Honcho connected" + b"" + b"
Connected to Honcho. You can close this tab and return to Hermes.
" +) + + +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() diff --git a/plugins/memory/honcho/session.py b/plugins/memory/honcho/session.py index e83c714b51b..cff81916a7e 100644 --- a/plugins/memory/honcho/session.py +++ b/plugins/memory/honcho/session.py @@ -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: diff --git a/tests/honcho_plugin/test_async_memory.py b/tests/honcho_plugin/test_async_memory.py index e1f2f5ea97b..6e28e8aecb4 100644 --- a/tests/honcho_plugin/test_async_memory.py +++ b/tests/honcho_plugin/test_async_memory.py @@ -155,15 +155,31 @@ class TestResolveSessionNameTitle: result = cfg.resolve_session_name("/some/dir", session_id=None) assert result == "dir" - def test_title_beats_session_id(self): + def test_per_session_id_beats_title(self): + # per-session: the run's session_id is authoritative; an (auto-)generated + # title must NOT remap a live conversation onto a second Honcho session. cfg = HonchoClientConfig(session_strategy="per-session") result = cfg.resolve_session_name("/some/dir", session_title="my-title", session_id="20260309_175514_9797dd") + assert result == "20260309_175514_9797dd" + + def test_per_session_id_beats_manual_map(self): + # per-session: session_id also wins over a stale cwd map entry (e.g. the + # desktop launching from a mapped home dir). + cfg = HonchoClientConfig(session_strategy="per-session", sessions={"/some/dir": "pinned"}) + result = cfg.resolve_session_name("/some/dir", session_id="20260309_175514_9797dd") + assert result == "20260309_175514_9797dd" + + def test_title_still_applies_for_non_per_session(self): + # Outside per-session, /title still names the Honcho session. + cfg = HonchoClientConfig(session_strategy="per-directory") + result = cfg.resolve_session_name("/some/dir", session_title="my-title", session_id="20260309_175514_9797dd") assert result == "my-title" - def test_manual_beats_session_id(self): - cfg = HonchoClientConfig(session_strategy="per-session", sessions={"/some/dir": "pinned"}) - result = cfg.resolve_session_name("/some/dir", session_id="20260309_175514_9797dd") - assert result == "pinned" + def test_gateway_key_beats_per_session_id(self): + # Gateways keep per-chat isolation even in per-session. + cfg = HonchoClientConfig(session_strategy="per-session") + result = cfg.resolve_session_name("/some/dir", gateway_session_key="agent:main:telegram:dm:42", session_id="20260309_175514_9797dd") + assert result == "agent-main-telegram-dm-42" def test_global_strategy_returns_workspace(self): cfg = HonchoClientConfig(session_strategy="global", workspace_id="my-workspace") diff --git a/tests/honcho_plugin/test_cli.py b/tests/honcho_plugin/test_cli.py index c021cdb8cfe..217c37fb3a5 100644 --- a/tests/honcho_plugin/test_cli.py +++ b/tests/honcho_plugin/test_cli.py @@ -234,6 +234,66 @@ class TestCmdStatus: assert "FAILED (Invalid API key)" in out assert "Connection... OK" not in out + def test_auth_line_detects_oauth_grant(self, monkeypatch, capsys, tmp_path): + import plugins.memory.honcho.cli as honcho_cli + + cfg_path = tmp_path / "honcho.json" + cfg_path.write_text("{}") + + class FakeConfig: + enabled = True + api_key = "hch-at-deadbeef" + workspace_id = "claude-code" + host = "hermes" + base_url = None + ai_peer = "hermes" + peer_name = "eri" + recall_mode = "hybrid" + user_observe_me = True + user_observe_others = False + ai_observe_me = False + ai_observe_others = True + write_frequency = "async" + session_strategy = "per-session" + context_tokens = None + dialectic_reasoning_level = "low" + reasoning_level_cap = "high" + reasoning_heuristic = True + raw = { + "hosts": { + "hermes": { + "apiKey": "hch-at-deadbeef", + "oauth": { + "refreshToken": "hch-rt-x", + "clientId": "hermes-agent", + "tokenEndpoint": "https://api.honcho.dev/oauth/token", + "expiresAt": 9999999999, + }, + } + } + } + + def resolve_session_name(self): + return "hermes" + + monkeypatch.setattr(honcho_cli, "_read_config", lambda: {}) + monkeypatch.setattr(honcho_cli, "_config_path", lambda: cfg_path) + monkeypatch.setattr(honcho_cli, "_local_config_path", lambda: cfg_path) + monkeypatch.setattr(honcho_cli, "_active_profile_name", lambda: "default") + monkeypatch.setattr( + "plugins.memory.honcho.client.HonchoClientConfig.from_global_config", + lambda host=None: FakeConfig(), + ) + monkeypatch.setattr("plugins.memory.honcho.client.get_honcho_client", lambda cfg: object()) + monkeypatch.setattr(honcho_cli, "_show_peer_cards", lambda hcfg, client: None) + monkeypatch.setitem(__import__("sys").modules, "honcho", SimpleNamespace()) + + honcho_cli.cmd_status(SimpleNamespace(all=False)) + + out = capsys.readouterr().out + assert "Auth: OAuth (hermes-agent" in out + assert "API key:" not in out + class TestCloneHonchoForProfile: """Identity-key carryover during profile cloning. @@ -389,6 +449,9 @@ class TestSetupWizardDeploymentShape: # Scripted _prompt: pop answers in order. Default-return for unconsumed prompts. answer_iter = iter(answers) def _scripted_prompt(label, default=None, secret=False): + # Auth-method prompt is orthogonal to shape; auto-answer apikey so the answer lists stay shape-only. + if "OAuth" in label: + return "apikey" try: return next(answer_iter) except StopIteration: diff --git a/tests/honcho_plugin/test_client.py b/tests/honcho_plugin/test_client.py index 7e956aa54c3..858b98a5554 100644 --- a/tests/honcho_plugin/test_client.py +++ b/tests/honcho_plugin/test_client.py @@ -711,15 +711,17 @@ class TestResolveSessionNameGatewayKey: ) assert result == "agent-main-telegram-dm-8439114563" - def test_session_title_still_wins_over_gateway_key(self): - """Explicit /title remap takes priority over gateway_session_key.""" + def test_gateway_key_not_remapped_by_title(self): + """A title never remaps a stable identifier — the gateway per-chat key + wins over the title so a generated title can't split a live conversation + onto a new Honcho session.""" config = HonchoClientConfig(session_strategy="per-session") result = config.resolve_session_name( session_title="my-custom-title", session_id="20260412_171002_69bb38", gateway_session_key="agent:main:telegram:dm:8439114563", ) - assert result == "my-custom-title" + assert result == "agent-main-telegram-dm-8439114563" def test_per_session_fallback_without_gateway_key(self): """Without gateway_session_key, per-session returns session_id (CLI path).""" diff --git a/tests/honcho_plugin/test_oauth.py b/tests/honcho_plugin/test_oauth.py new file mode 100644 index 00000000000..ed4644cc74c --- /dev/null +++ b/tests/honcho_plugin/test_oauth.py @@ -0,0 +1,254 @@ +"""Tests for plugins/memory/honcho/oauth.py — OAuth grant storage + refresh.""" + +import json +from pathlib import Path + +import pytest + +from plugins.memory.honcho import oauth +from plugins.memory.honcho.oauth import OAuthCredential + + +def _host_block(refresh="hch-rt-old", expires_at=10_000): + return { + "apiKey": "hch-at-old", + "oauth": { + "refreshToken": refresh, + "expiresAt": expires_at, + "clientId": "hermes-desktop", + "tokenEndpoint": "http://localhost:8000/oauth/token", + "scope": "write", + "tokenType": "Bearer", + }, + } + + +def _write(path: Path, raw: dict) -> None: + path.write_text(json.dumps(raw), encoding="utf-8") + + +class TestTokenDetection: + def test_access_token_prefix(self): + assert oauth.is_oauth_access_token("hch-at-abc") + assert not oauth.is_oauth_access_token("hch-v3-abc") + assert not oauth.is_oauth_access_token("hch-rt-abc") + assert not oauth.is_oauth_access_token(None) + + +class TestCredentialModel: + def test_roundtrip(self): + cred = OAuthCredential.from_host_block(_host_block()) + assert cred is not None + block = cred.oauth_block() + assert block["refreshToken"] == "hch-rt-old" + assert block["expiresAt"] == 10_000 + assert block["clientId"] == "hermes-desktop" + + def test_incomplete_block_returns_none(self): + # plain API key (no oauth sub-block) + assert OAuthCredential.from_host_block({"apiKey": "hch-v3-x"}) is None + # oauth block missing refreshToken + bad = _host_block() + del bad["oauth"]["refreshToken"] + assert OAuthCredential.from_host_block(bad) is None + + def test_is_expired_respects_skew(self): + cred = OAuthCredential.from_host_block(_host_block(expires_at=1000)) + assert not cred.is_expired(now=800, skew=120) # 1000-120=880 > 800 + assert cred.is_expired(now=900, skew=120) # 900 >= 880 + + +class TestEnsureFreshToken: + def test_no_oauth_credential_is_noop(self, tmp_path): + path = tmp_path / "honcho.json" + _write(path, {"hosts": {"hermes": {"apiKey": "hch-v3-static"}}}) + token, refreshed = oauth.ensure_fresh_token(path, "hermes", now=0) + assert token is None and refreshed is False + + def test_fresh_token_skips_refresh(self, tmp_path, monkeypatch): + path = tmp_path / "honcho.json" + _write(path, {"hosts": {"hermes": _host_block(expires_at=10_000)}}) + monkeypatch.setattr( + oauth, "_http_post_form", + lambda *a, **k: pytest.fail("refresh must not be called when fresh"), + ) + token, refreshed = oauth.ensure_fresh_token(path, "hermes", now=0) + assert token == "hch-at-old" and refreshed is False + + def test_fresh_token_served_from_cache_without_disk(self, tmp_path, monkeypatch): + path = tmp_path / "honcho.json" + _write(path, {"hosts": {"hermes": _host_block(expires_at=10_000)}}) + oauth._expiry_cache.clear() + # First call seeds the cache from disk. + oauth.ensure_fresh_token(path, "hermes", now=0) + # Second call must not touch disk while the token is well clear of expiry. + monkeypatch.setattr( + oauth, "_read_config", + lambda *a, **k: pytest.fail("disk must not be read while token is fresh"), + ) + token, refreshed = oauth.ensure_fresh_token(path, "hermes", now=100) + assert token == "hch-at-old" and refreshed is False + + def test_expired_token_refreshes_and_persists_rotation(self, tmp_path, monkeypatch): + path = tmp_path / "honcho.json" + _write(path, {"hosts": {"hermes": _host_block(expires_at=100)}}) + + def fake_post(url, data, timeout): + assert data["grant_type"] == "refresh_token" + assert data["refresh_token"] == "hch-rt-old" + assert data["client_id"] == "hermes-desktop" + return { + "access_token": "hch-at-new", + "refresh_token": "hch-rt-new", + "expires_in": 3600, + "scope": "write", + "token_type": "Bearer", + } + + monkeypatch.setattr(oauth, "_http_post_form", fake_post) + token, refreshed = oauth.ensure_fresh_token(path, "hermes", now=1000) + assert token == "hch-at-new" and refreshed is True + + # Rotated refresh token + new access token + absolute expiry persisted. + saved = json.loads(path.read_text())["hosts"]["hermes"] + assert saved["apiKey"] == "hch-at-new" + assert saved["oauth"]["refreshToken"] == "hch-rt-new" + assert saved["oauth"]["expiresAt"] == 1000 + 3600 + + def test_refresh_failure_fails_open(self, tmp_path, monkeypatch): + path = tmp_path / "honcho.json" + _write(path, {"hosts": {"hermes": _host_block(expires_at=100)}}) + + def boom(*a, **k): + raise RuntimeError("network down") + + monkeypatch.setattr(oauth, "_http_post_form", boom) + token, refreshed = oauth.ensure_fresh_token(path, "hermes", now=1000) + # Stale token returned, no crash, file untouched. + assert token == "hch-at-old" and refreshed is False + assert json.loads(path.read_text())["hosts"]["hermes"]["apiKey"] == "hch-at-old" + + def test_double_check_uses_disk_when_already_rotated(self, tmp_path, monkeypatch): + # Simulates a concurrent thread that rotated the token on disk after our + # stale in-memory snapshot: the locked re-read must skip the HTTP call. + path = tmp_path / "honcho.json" + _write(path, {"hosts": {"hermes": _host_block(refresh="hch-rt-fresh", expires_at=10_000)}}) + stale_raw = {"hosts": {"hermes": _host_block(refresh="hch-rt-old", expires_at=100)}} + stale_raw["hosts"]["hermes"]["apiKey"] = "hch-at-stale" + monkeypatch.setattr( + oauth, "_http_post_form", + lambda *a, **k: pytest.fail("must not refresh; disk token is fresh"), + ) + token, refreshed = oauth.ensure_fresh_token(path, "hermes", stale_raw, now=1000) + assert token == "hch-at-old" # the on-disk fresh credential's access token + + def test_refresh_holds_cross_process_lock(self, tmp_path, monkeypatch): + # A second opener must not grab .lock mid-refresh — proving the + # rotation is serialized machine-wide so peers can't replay the token. + fcntl = pytest.importorskip("fcntl") + path = tmp_path / "honcho.json" + _write(path, {"hosts": {"hermes": _host_block(expires_at=100)}}) + seen = {} + + def fake_post(url, data, timeout): + with open(f"{path}.lock", "a+b") as other: + try: + fcntl.flock(other.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + fcntl.flock(other.fileno(), fcntl.LOCK_UN) + seen["held"] = False + except OSError: + seen["held"] = True + return {"access_token": "hch-at-new", "refresh_token": "hch-rt-new", + "expires_in": 3600, "scope": "write", "token_type": "Bearer"} + + monkeypatch.setattr(oauth, "_http_post_form", fake_post) + token, refreshed = oauth.ensure_fresh_token(path, "hermes", now=1000) + assert refreshed is True and seen.get("held") is True + # Released afterward: a non-blocking acquire now succeeds. + with open(f"{path}.lock", "a+b") as fh: + fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + fcntl.flock(fh.fileno(), fcntl.LOCK_UN) + + def test_refresh_degrades_when_lock_unavailable(self, tmp_path, monkeypatch): + # No flock (unsupported FS/platform) must not block refresh — it falls + # back to in-process serialization only. + fcntl = pytest.importorskip("fcntl") + path = tmp_path / "honcho.json" + _write(path, {"hosts": {"hermes": _host_block(expires_at=100)}}) + + def no_flock(*a, **k): + raise OSError("flock unsupported") + + monkeypatch.setattr(fcntl, "flock", no_flock) + monkeypatch.setattr( + oauth, "_http_post_form", + lambda *a, **k: {"access_token": "hch-at-new", "refresh_token": "hch-rt-new", + "expires_in": 3600, "scope": "write", "token_type": "Bearer"}, + ) + token, refreshed = oauth.ensure_fresh_token(path, "hermes", now=1000) + assert token == "hch-at-new" and refreshed is True + + +class TestInstallGrant: + def test_deep_merges_config_and_preserves_other_hosts(self, tmp_path): + path = tmp_path / "honcho.json" + _write(path, { + "apiKey": "hch-v3-root", # root static key preserved + "hosts": { + "obsidian": {"workspace": "obsidian"}, + "hermes": {"workspace": "hermes", "saveMessages": False}, + }, + }) + grant = { + "access_token": "hch-at-fresh", + "refresh_token": "hch-rt-fresh", + "expires_in": 3600, + "scope": "write", + "config": { + "environment": "production", + "hosts": {"hermes": {"saveMessages": True, "recallMode": "hybrid"}}, + }, + } + cred = oauth.install_grant( + path, "hermes", grant, + client_id="hermes-desktop", + token_endpoint="http://localhost:8000/oauth/token", + now=1000, + ) + assert cred.expires_at == 1000 + 3600 + + saved = json.loads(path.read_text()) + assert saved["apiKey"] == "hch-v3-root" # untouched + assert saved["hosts"]["obsidian"] == {"workspace": "obsidian"} # untouched + h = saved["hosts"]["hermes"] + assert h["apiKey"] == "hch-at-fresh" + assert h["oauth"]["refreshToken"] == "hch-rt-fresh" + assert h["saveMessages"] is True # grant config won the deep-merge + assert h["recallMode"] == "hybrid" # new key added + assert h["workspace"] == "hermes" # pre-existing key preserved + assert saved["environment"] == "production" # root key from grant + + def test_rejects_grant_without_tokens(self, tmp_path): + path = tmp_path / "honcho.json" + _write(path, {}) + with pytest.raises(ValueError): + oauth.install_grant( + path, "hermes", {"access_token": "hch-at-x"}, # no refresh_token + client_id="c", token_endpoint="e", + ) + + +class TestApplyTokenToClient: + def test_mutates_live_bearer(self): + class FakeHttp: + api_key = "hch-at-old" + + class FakeClient: + _http = FakeHttp() + + client = FakeClient() + assert oauth.apply_token_to_client(client, "hch-at-new") is True + assert client._http.api_key == "hch-at-new" + + def test_returns_false_when_shape_unknown(self): + assert oauth.apply_token_to_client(object(), "hch-at-new") is False diff --git a/tests/honcho_plugin/test_oauth_flow.py b/tests/honcho_plugin/test_oauth_flow.py new file mode 100644 index 00000000000..99c835ed139 --- /dev/null +++ b/tests/honcho_plugin/test_oauth_flow.py @@ -0,0 +1,347 @@ +"""End-to-end test for the zero-CLI Honcho OAuth flow against a fake AS. + +Stands up a real local authorization server (no network, no browser) and drives +the full path: begin → /authorize 302 → loopback :8765 callback → token +exchange → install_grant → forced-expiry refresh with rotation. This is the +deterministic "real smoke test" for the consumer flow. +""" + +import json +import threading +import time +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +import httpx +import pytest + +from plugins.memory.honcho import oauth, oauth_flow + + +class _FakeAS(BaseHTTPRequestHandler): + """Minimal OAuth 2.1 AS: /authorize 302s to the callback; /oauth/token mints.""" + + # Rotation counter shared across requests so refresh returns a new token. + issued = {"n": 0} + + def do_GET(self): # noqa: N802 + parsed = urlparse(self.path) + if parsed.path != "/authorize": + self.send_response(404) + self.end_headers() + return + q = parse_qs(parsed.query) + redirect = q["redirect_uri"][0] + # The redirect must be the IP literal matching the bound host — a + # `localhost` redirect can resolve to ::1 and miss the IPv4 listener. + # Host must be the IP literal (port may fall back off :8765). + assert redirect.startswith("http://127.0.0.1:") and "/callback" in redirect, redirect + # Consent shows a home-relative display path — never an absolute path + # that would leak the username / home layout off the machine. + cp = q["config_path"][0] + assert cp.endswith("honcho.json"), q.get("config_path") + assert not cp.startswith("/"), cp + state = q["state"][0] + location = f"{redirect}?code=test-auth-code&state={state}" + self.send_response(302) + self.send_header("Location", location) + self.end_headers() + + def do_POST(self): # noqa: N802 + parsed = urlparse(self.path) + if parsed.path != "/oauth/token": + self.send_response(404) + self.end_headers() + return + length = int(self.headers.get("Content-Length", 0)) + form = parse_qs(self.rfile.read(length).decode()) + grant_type = form["grant_type"][0] + self.issued["n"] += 1 + n = self.issued["n"] + body = { + "access_token": f"hch-at-{n}", + "refresh_token": f"hch-rt-{n}", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "write", + } + if grant_type == "authorization_code": + body["config"] = { + "peerName": "lyra", + "environment": "production", + "hosts": {"hermes": {"saveMessages": True, "recallMode": "hybrid"}}, + } + payload = json.dumps(body).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(payload) + + def log_message(self, *args): + return + + +@pytest.fixture +def fake_as(monkeypatch): + _FakeAS.issued["n"] = 0 + server = HTTPServer(("127.0.0.1", 0), _FakeAS) + port = server.server_address[1] + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base = f"http://127.0.0.1:{port}" + monkeypatch.setenv("HONCHO_OAUTH_AUTHORIZE_URL", f"{base}/authorize") + monkeypatch.setenv("HONCHO_OAUTH_TOKEN_URL", f"{base}/oauth/token") + monkeypatch.setenv("HONCHO_OAUTH_CLIENT_ID", "hermes-desktop") + try: + yield base + finally: + server.shutdown() + server.server_close() + + +def _browser_driver(authorize_url: str) -> None: + """Stand in for the user's browser: follow /authorize's 302 into the callback. + + Retries the callback GET so it can't lose the race to the loopback bind. + """ + resp = httpx.get(authorize_url, follow_redirects=False) + location = resp.headers["Location"] + for _ in range(50): + try: + httpx.get(location, timeout=2) + return + except httpx.ConnectError: + time.sleep(0.05) + raise RuntimeError("loopback callback never came up") + + +def test_full_loopback_flow_then_refresh(tmp_path, fake_as): + config_path = tmp_path / "honcho.json" + config_path.write_text(json.dumps({"hosts": {"obsidian": {"workspace": "obsidian"}}})) + + cred = oauth_flow.authorize_via_loopback( + config_path=config_path, + host="hermes", + open_url=lambda url: _browser_driver(url), + timeout=10, + ) + + # Grant installed: token stored, config deep-merged, other host preserved. + assert cred.access_token == "hch-at-1" + saved = json.loads(config_path.read_text()) + assert saved["hosts"]["hermes"]["apiKey"] == "hch-at-1" + assert saved["hosts"]["hermes"]["oauth"]["refreshToken"] == "hch-rt-1" + assert saved["hosts"]["hermes"]["recallMode"] == "hybrid" + assert saved["environment"] == "production" + assert saved["hosts"]["obsidian"] == {"workspace": "obsidian"} + + # Force expiry; ensure_fresh_token refreshes against the same AS and rotates. + token, refreshed = oauth.ensure_fresh_token( + config_path, "hermes", now=saved["hosts"]["hermes"]["oauth"]["expiresAt"] + 10 + ) + assert refreshed is True + assert token == "hch-at-2" + rotated = json.loads(config_path.read_text())["hosts"]["hermes"]["oauth"] + assert rotated["refreshToken"] == "hch-rt-2" + + +def test_state_mismatch_is_rejected(fake_as, tmp_path): + endpoints = oauth_flow.resolve_endpoints() + _, state = oauth_flow.begin_authorization(endpoints) + with pytest.raises(ValueError, match="unknown or expired"): + oauth_flow.complete_authorization( + endpoints, "code", "not-the-real-state", + config_path=tmp_path / "honcho.json", host="hermes", + ) + + +def test_source_tags_the_authorize_link(fake_as): + endpoints = oauth_flow.resolve_endpoints() + url, _ = oauth_flow.begin_authorization(endpoints, source="hermes-cli") + assert "source=hermes-cli" in url + untagged, _ = oauth_flow.begin_authorization(endpoints) + assert "source=" not in untagged + + +def test_client_id_defaults_to_hermes_agent(monkeypatch): + # One client for every surface; the env var overrides for unusual deployments. + monkeypatch.delenv("HONCHO_OAUTH_CLIENT_ID", raising=False) + common = {"environment": "production", "base_url": "https://api.honcho.dev"} + assert oauth_flow.resolve_endpoints(**common).client_id == "hermes-agent" + monkeypatch.setenv("HONCHO_OAUTH_CLIENT_ID", "custom-id") + assert oauth_flow.resolve_endpoints(**common).client_id == "custom-id" + + +def test_grant_persists_default_client_id(tmp_path, fake_as, monkeypatch): + # Drop the fixture's override so the default takes effect; the grant must + # store client_id=hermes-agent so refresh reuses the right client. + monkeypatch.delenv("HONCHO_OAUTH_CLIENT_ID", raising=False) + config_path = tmp_path / "honcho.json" + config_path.write_text(json.dumps({"hosts": {}})) + + oauth_flow.authorize_via_loopback( + config_path=config_path, + host="hermes", + source="hermes-cli", + apply_config=False, + open_url=lambda url: _browser_driver(url), + timeout=10, + ) + saved = json.loads(config_path.read_text()) + assert saved["hosts"]["hermes"]["oauth"]["clientId"] == "hermes-agent" + + +def test_config_path_rides_the_authorize_link(fake_as): + endpoints = oauth_flow.resolve_endpoints() + url, _ = oauth_flow.begin_authorization(endpoints, config_path="~/.hermes/honcho.json") + q = parse_qs(urlparse(url).query) + assert q["config_path"][0] == "~/.hermes/honcho.json" + bare, _ = oauth_flow.begin_authorization(endpoints) + assert "config_path=" not in bare + + +def test_display_config_path_never_leaks_absolute_path(): + from pathlib import Path + + # Under home → collapsed to ~/…; outside home → bare filename only. + under_home = Path.home() / ".hermes" / "profiles" / "work" / "honcho.json" + assert oauth_flow._display_config_path(under_home) == "~/.hermes/profiles/work/honcho.json" + assert oauth_flow._display_config_path("/var/folders/tmp/honcho.json") == "honcho.json" + + +def test_cli_flow_stores_tokens_without_applying_config(tmp_path, fake_as): + # apply_config=False (the CLI path): grant config must NOT touch settings. + config_path = tmp_path / "honcho.json" + config_path.write_text(json.dumps({"hosts": {"hermes": {"saveMessages": False}}})) + + cred = oauth_flow.authorize_via_loopback( + config_path=config_path, + host="hermes", + source="hermes-cli", + apply_config=False, + open_url=lambda url: _browser_driver(url), + timeout=10, + ) + + saved = json.loads(config_path.read_text()) + host = saved["hosts"]["hermes"] + assert host["apiKey"] == cred.access_token + assert host["oauth"]["refreshToken"] == cred.refresh_token + # Wizard-owned setting untouched; grant config keys absent. + assert host["saveMessages"] is False + assert "recallMode" not in host + assert "environment" not in saved + # consent peer name still surfaced (seeds the CLI wizard prompt) despite no merge + assert cred.consent_peer_name == "lyra" + + +# ── Desktop "Connect" button path: background launcher, status, dispatch ── + + +@pytest.fixture +def reset_flow(): + oauth_flow._status = oauth_flow.FlowStatus() + oauth_flow._flow_thread = None + yield + oauth_flow._status = oauth_flow.FlowStatus() + oauth_flow._flow_thread = None + + +def _wait_until(predicate, timeout=2.0): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(0.02) + return False + + +def test_launcher_runs_flow_in_background_and_reports_connected(monkeypatch, reset_flow): + seen = {} + gate = threading.Event() + + def fake(**kwargs): + seen.update(kwargs) # captures source default + eagerly-resolved path/host + gate.wait(2) # hold the flow open so the launcher returns while pending + + monkeypatch.setattr(oauth_flow, "authorize_via_loopback", fake) + monkeypatch.setattr(oauth_flow, "_detect_connection", lambda: (True, "oauth")) + + st = oauth_flow.start_loopback_flow_background(config_path=Path("/t/honcho.json"), host="hermes") + assert st["state"] == "pending" # returns immediately, before the flow finishes + assert _wait_until(lambda: seen.get("source") == "hermes-desktop") # default source tag + assert seen["host"] == "hermes" + gate.set() + assert _wait_until(lambda: oauth_flow.get_flow_status()["state"] == "connected") + + +def test_launcher_reports_error_on_flow_failure(monkeypatch, reset_flow): + def boom(**kwargs): + raise RuntimeError("loopback bind failed") + + monkeypatch.setattr(oauth_flow, "authorize_via_loopback", boom) + monkeypatch.setattr(oauth_flow, "_detect_connection", lambda: (False, None)) + + oauth_flow.start_loopback_flow_background(config_path=Path("/t/honcho.json"), host="hermes") + assert _wait_until(lambda: oauth_flow.get_flow_status()["state"] == "error") + assert "loopback bind failed" in oauth_flow.get_flow_status()["detail"] + + +def test_launcher_is_idempotent_while_pending(monkeypatch, reset_flow): + block = threading.Event() + calls = [] + + def fake(**kwargs): + calls.append(1) + block.wait(2) + + monkeypatch.setattr(oauth_flow, "authorize_via_loopback", fake) + monkeypatch.setattr(oauth_flow, "_detect_connection", lambda: (False, None)) + + s1 = oauth_flow.start_loopback_flow_background(config_path=Path("/t/h.json"), host="hermes") + assert _wait_until(lambda: len(calls) == 1) # first flow is running + s2 = oauth_flow.start_loopback_flow_background(config_path=Path("/t/h.json"), host="hermes") + block.set() + assert s1["state"] == "pending" and s2["state"] == "pending" + assert _wait_until(lambda: oauth_flow.get_flow_status()["state"] == "connected") + assert calls == [1] # the second call did not spawn a second flow + + +def test_get_flow_status_reports_stored_connection(tmp_path, monkeypatch, reset_flow): + from plugins.memory.honcho import client as honcho_client + + cfgfile = tmp_path / "honcho.json" + monkeypatch.setattr(honcho_client, "resolve_config_path", lambda: cfgfile) + monkeypatch.setattr(honcho_client, "resolve_active_host", lambda: "hermes") + monkeypatch.delenv("HONCHO_API_KEY", raising=False) + + cfgfile.write_text(json.dumps({"hosts": {"hermes": {}}})) + assert oauth_flow.get_flow_status()["connected"] is False + + cfgfile.write_text(json.dumps({"hosts": {"hermes": {"apiKey": "hch-v3-static"}}})) + s = oauth_flow.get_flow_status() + assert s["connected"] is True and s["auth"] == "apikey" + + cfgfile.write_text(json.dumps({"hosts": {"hermes": { + "apiKey": "hch-at-tok", + "oauth": {"refreshToken": "hch-rt-x", "expiresAt": 9_999_999_999, + "clientId": "hermes-desktop", "tokenEndpoint": "http://x/oauth/token"}, + }}})) + s = oauth_flow.get_flow_status() + assert s["connected"] is True and s["auth"] == "oauth" + + +def test_memory_oauth_router_dispatches_by_provider_convention(): + # The generic seam behind the two routes: provider → plugins.memory.

.oauth_flow. + from fastapi import HTTPException + + from hermes_cli.memory_oauth import _resolve_flow + + mod = _resolve_flow("honcho") + assert hasattr(mod, "start_loopback_flow_background") and hasattr(mod, "get_flow_status") + + for bad in ("builtin", "no-such-provider", "../etc"): + with pytest.raises(HTTPException) as exc: + _resolve_flow(bad) + assert exc.value.status_code == 404 diff --git a/website/docs/user-guide/features/memory-providers.md b/website/docs/user-guide/features/memory-providers.md index 6ba95342b49..b41548ce0e8 100644 --- a/website/docs/user-guide/features/memory-providers.md +++ b/website/docs/user-guide/features/memory-providers.md @@ -61,6 +61,8 @@ AI-native cross-session user modeling with dialectic reasoning, session-scoped c - `dialecticCadence` — how often the dialectic LLM fires (LLM call frequency) - `dialecticDepth` — how many `.chat()` passes per dialectic invocation (1–3, depth of reasoning) +The auto-injected dialectic also scales its reasoning level by query length (longer query → deeper reasoning, capped at `reasoningLevelCap`); see [Query-Adaptive Reasoning Level](./honcho.md#query-adaptive-reasoning-level). + **Setup Wizard:** ```bash hermes memory setup # select "honcho" — runs the Honcho-specific post-setup