Files
PRATHAMESH75 5c6ff927f5 fix(dashboard): plugin API routes run in the request profile's scope (#120310, salvage #120332)
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)
2026-09-28 05:37:55 -07:00

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__}