Files
hermes-agent/hermes_cli/memory_oauth.py
teknium1 9318b7a74d fix(memory): user-dir memory providers keep Desktop config + OAuth
A memory provider installed from the catalog under $HERMES_HOME/plugins/
(the honcho handoff package) loads under the loader's synthetic user
namespace, but the host-side surfaces still imported the bundled path
`plugins.memory.honcho.*` by name: the Desktop GET/PUT
/api/memory/providers/honcho/config 500'd, /oauth/start|status 404'd
("does not support OAuth connect"), doctor reported "honcho-ai not
installed", and profile clone / post-update sync silently skipped.

Add `plugins.memory.import_provider_module(name, submodule=None)`: the
provider package (or one of its submodules) of whichever copy
`find_provider_dir` resolves — bundled today, user dir after the removal
PR — under the module name the loader already owns, so both copies
behave identically. Route every host-side absolute import through it:
the dashboard host-block resolvers and honcho.json writer, the OAuth
route resolver, doctor's honcho/mem0 checks, `profile create --clone`,
the update hook's profile sync and the holographic store release.
In-process, no lock, no child process.

Two invariant tests (user-dir honcho with the bundled copy gone:
/config?surface=declared is 200 with the schema; the oauth_flow module
resolves from the user directory), red on base. The honcho write test's
`_honcho_resolvers` stub gains the provider-name argument the seam now
carries.

Shape follows the host-module resolution slice of #116566 by @erosika;
the contract module, installer lock and child-process OAuth runner from
that PR are not needed once the modules resolve in-process.

Co-authored-by: Erosika <eri@plasticlabs.ai>
2026-09-23 01:07:08 -07:00

76 lines
2.8 KiB
Python

"""HTTP routes for memory-provider OAuth connect, mounted by ``web_server``."""
from __future__ import annotations
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 (bundled or user-dir copy), or raise 404."""
if not provider.isidentifier():
raise HTTPException(status_code=404, detail=f"unknown memory provider {provider!r}")
from plugins.memory import import_provider_module
try:
return import_provider_module(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 (browser + loopback listener); returns immediately, poll status."""
flow = _resolve_flow(provider)
try:
# The flow resolves its config path eagerly inside this scope; its worker thread outlives it.
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}")