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>
76 lines
2.8 KiB
Python
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}")
|