From 12fe7684e12677039a5c94c460b52a00cd2f58b2 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 12 Sep 2026 08:10:30 -0700 Subject: [PATCH] fix(plugins): async-await helper thread runs under the caller's ContextVars Under a running loop `resolve_plugin_command_result` awaited the coroutine on a raw thread, so an async hook saw the process-default HERMES_HOME and no secret scope (get_secret -> UnscopedSecretError on a secondary profile). Run the thread body through `contextvars.copy_context().run`, matching the bounded hook worker. Also fixes async plugin slash commands the same way. --- hermes_cli/plugins.py | 6 +++++- tests/hermes_cli/test_plugins.py | 10 +++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 0e639fe60f..fb894d4079 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -11,6 +11,7 @@ and an ``__init__.py`` exposing ``register(ctx)``. Plugins register callbacks fo from __future__ import annotations import asyncio +import contextvars import importlib.metadata import inspect import json @@ -2013,7 +2014,10 @@ def resolve_plugin_command_result(result: Any) -> Any: finally: done.set() - threading.Thread(target=_runner, name="hermes-plugin-command-await", daemon=True).start() + # copy_context: the helper thread must see the caller's profile/secret scope, else an + # async hook under a running loop reads the default HERMES_HOME and get_secret raises. + threading.Thread(target=contextvars.copy_context().run, args=(_runner,), + name="hermes-plugin-command-await", daemon=True).start() if not done.wait(timeout=_PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS): raise TimeoutError("Plugin command async handler did not complete within " f"{_PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS:.0f}s") diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index 22340a753c..02924cdb9e 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -948,20 +948,24 @@ class TestAsyncHookCallbacks: assert results == [{"context": "sync"}, {"context": "async:s1"}] def test_async_hook_resolves_under_a_running_loop(self): - """Gateway handlers call invoke_hook from inside asyncio; a bare asyncio.run would raise.""" + """Gateway handlers call invoke_hook from inside asyncio; a bare asyncio.run would raise. + The helper thread must also carry the caller's ContextVars (profile / secret scope).""" import asyncio + import contextvars + scope = contextvars.ContextVar("hook_scope", default="default") mgr = PluginManager() async def async_hook(**kwargs): - return "from-async" + return f"from-async:{scope.get()}" mgr._hooks.setdefault("post_tool_call", []).append(async_hook) async def driver(): + scope.set("profile-b") return mgr.invoke_hook("post_tool_call", tool_name="t", args={}, result="r", duration_ms=1) - assert asyncio.run(driver()) == ["from-async"] + assert asyncio.run(driver()) == ["from-async:profile-b"] class TestForceReloadSymmetry: