WHAT: `_mount_plugin_api_routes` mounts every `/api/plugins/<name>/` router behind an async yield-dependency, `_plugin_route_secret_scope`, which enters `hermes_cli.web_server_profiles._config_profile_scope(profile)` - the launch profile's home + `launch_secret_scope`, or the `?profile=`-requested profile's home + secret scope - the same seam the built-in routers (actions/analytics/mcp) already use. WHY: plugin route dispatch bound no profile scope at all, so once multi-profile hosting activated (`set_multiplex_active(True)`) every `get_secret` / `resolve_runtime_provider` inside a plugin handler raised `UnscopedSecretError`. User plugins fold that into a silent "no data" state; the bundled kanban plugin's Decompose / Specify / Estimate aux-LLM calls fail loudly with `LLM error: UnscopedSecretError` (#123372). One mount-level bind covers every plugin router, sync handlers included (`run_in_threadpool` copies the request context). Test drives the real mount (discovery -> import -> `_mount_plugin_api_routes` -> live request against `app`): launch profile A and `?profile=workerb` resolve distinct keys and B does not leak back into A; an unknown profile is rejected before the handler runs. Fixes #120310 (cherry picked from commit 7392500f38b4cf3f6ef3c44aeacbadd8cc9b85e4)
43 lines
1.8 KiB
Python
43 lines
1.8 KiB
Python
"""Example dashboard plugin — backend API routes (test fixture).
|
|
|
|
This plugin lives under ``tests/fixtures/plugins/`` so it is NOT shipped as
|
|
part of the bundled-plugins set; a stock hermes-agent install does not see
|
|
an "Example" tab in its sidebar. The ``_install_example_plugin`` pytest
|
|
fixture in ``tests/hermes_cli/test_web_server.py`` copies this directory
|
|
into ``$HERMES_HOME/plugins/example-dashboard/`` and forces the dashboard
|
|
plugin discovery cache to rescan, so tests that need a stable, side-effect-
|
|
free GET endpoint to verify plugin API auth + static-asset behaviour can
|
|
hit ``/api/plugins/example/hello`` (and ``/dashboard-plugins/example/
|
|
manifest.json``) without depending on any production-facing plugin.
|
|
|
|
Mounted at /api/plugins/example/ by the dashboard plugin system.
|
|
"""
|
|
|
|
from fastapi import APIRouter
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
@router.get("/hello")
|
|
async def hello():
|
|
"""Simple greeting endpoint to demonstrate plugin API routes."""
|
|
return {"message": "Hello from the example plugin!", "plugin": "example", "version": "1.0.0"}
|
|
|
|
|
|
@router.get("/whoami")
|
|
async def whoami():
|
|
"""Side-effect-free credential probe: read a secret and fold any failure into the
|
|
plugin "no data" contract (a plugin handler must never raise out to the client).
|
|
|
|
Tests use this to prove the *production* mount path (discovery → import →
|
|
``_mount_plugin_api_routes`` → scoped handler) resolves the caller's profile
|
|
credentials under multi-profile hosting (#120310). It reads a made-up key, so it
|
|
never touches real credentials or the network.
|
|
"""
|
|
from agent.secret_scope import get_secret
|
|
|
|
try:
|
|
return {"ok": True, "key": get_secret("EXAMPLE_PLUGIN_PROBE_KEY")}
|
|
except Exception as exc: # plugin contract: never raise out of the handler
|
|
return {"ok": False, "error": type(exc).__name__}
|