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:
Eri Barrett 2026-06-22 20:16:47 -04:00 committed by GitHub
parent 672ea1f894
commit ba9e3a491b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1948 additions and 53 deletions

View file

@ -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<string, string>
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 ? (
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-1">
{description}
{descriptionExtra}
</span>
) : (
description
)
const row = (action: ReactNode, wide = false) => (
<ListRow action={action} description={description} title={label} wide={wide} />
<ListRow action={action} description={descriptionNode} title={label} wide={wide} />
)
if (schema.type === 'boolean') {
@ -358,6 +370,11 @@ export function ConfigSettings({
{fields.map(([key, field]) => (
<div className="scroll-mt-6 rounded-lg" id={`setting-field-${key}`} key={key}>
<ConfigField
descriptionExtra={
key === 'memory.provider' && Boolean(getNested(config, key)) ? (
<MemoryConnect provider={String(getNested(config, key))} />
) : undefined
}
enumOptions={
key === 'tts.elevenlabs.voice_id'
? enumOptionsFor(key, getNested(config, key), config, elevenLabsVoiceOptions ?? undefined)

View file

@ -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<MemoryProviderOAuthStatus['auth']>(null)
const [phase, setPhase] = useState<'error' | 'idle' | 'pending'>('idle')
const [detail, setDetail] = useState('')
const timer = useRef<ReturnType<typeof setInterval> | 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 (
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
{phase === 'idle' && connected && (
<span className="inline-flex items-center gap-1 text-muted-foreground">
<Check className="size-3" />
{auth === 'apikey' ? 'api key set' : 'oauth set'}
</span>
)}
{phase === 'pending' ? (
<>
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
Waiting for browser consent
</span>
<Button className="h-auto p-0 text-xs" onClick={cancel} size="sm" type="button" variant="link">
Cancel
</Button>
</>
) : (
<Button
className="h-auto gap-1 p-0 text-xs"
onClick={() => void connect()}
size="sm"
type="button"
variant="link"
>
<ExternalLink className="size-3" />
{connectLabel}
</Button>
)}
{phase === 'error' && detail && <span className="text-destructive">{detail}</span>}
</span>
)
}

View file

@ -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<MemoryProviderOAuthStatus> {
return window.hermesDesktop.api<MemoryProviderOAuthStatus>({
...profileScoped(),
path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/start`,
method: 'POST'
})
}
export function getMemoryProviderOAuthStatus(provider: string): Promise<MemoryProviderOAuthStatus> {
return window.hermesDesktop.api<MemoryProviderOAuthStatus>({
...profileScoped(),
path: `/api/memory/providers/${encodeURIComponent(provider)}/oauth/status`
})
}
export function getSkills(): Promise<SkillInfo[]> {
return window.hermesDesktop.api<SkillInfo[]>({
...profileScoped(),

View file

@ -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

View file

@ -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.<provider>.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}")

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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. "

View 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

View 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()

View file

@ -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:

View file

@ -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")

View file

@ -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:

View file

@ -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)."""

View file

@ -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 <config>.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

View file

@ -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.<p>.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

View file

@ -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 (13, 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