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