Files
hermes-agent/tests/gateway/test_feishu_profile_scope_hops.py
T
Teknium 2952dc62bc fix(feishu): drive-comment turns and WS-thread callbacks run under their own profile, not the launch profile
Under `gateway.multiplex_profiles`, a Feishu adapter is built and connected inside
`_profile_runtime_scope` (HERMES_HOME override + secret scope as contextvars), but two
hops started from an EMPTY context and so executed under the LAUNCH profile:

- `feishu_comment.handle_drive_comment_event` ran the whole comment AIAgent turn on a bare
  `loop.run_in_executor(None, ...)`: model/credential resolution raised
  `UnscopedSecretError` (silent empty reply), or — when the default profile held the same
  key — used the default profile's config/model/state for a secondary profile's doc.
- `FeishuAdapter._connect_websocket` ran the lark WS client on a bare executor thread. The
  SDK fires every event/card callback on that thread and they hop back to the adapter loop via
  `run_coroutine_threadsafe`, which copies the CALLER's context — so all pre-handler work
  (inbound media caching, `.update_response` marker, FEISHU_REACTIONS env, drive comments)
  ran unscoped. The pending-inbound drainer thread spawned from that callback had the same
  shape.

Carry the scope across each hop with `contextvars.copy_context().run`. For the SDK-owned
thread the snapshot is taken once at `_connect_websocket` (inside the profile scope; the
restart supervisor task inherits it too), so no per-callback re-scoping is needed.

Supersedes the `_submit_on_loop` re-scoping approach of #63962 (nateEc, earliest fix):
scoping the WS thread at its source covers every callback without rebuilding the secret
scope per call.

Co-authored-by: Nathan Shan <nathanielcrush51@gmail.com>
2026-09-10 18:12:02 -07:00

131 lines
5.8 KiB
Python

"""Profile scope must survive the Feishu adapter's thread hops under a multiplexed gateway.
The adapter is constructed and connected inside ``_profile_runtime_scope`` (HERMES_HOME override +
secret scope as contextvars). Two hops used to start from an EMPTY context, so the work ran under
the LAUNCH profile: the drive-comment agent turn (a full ``AIAgent`` on a bare default executor)
and the lark WS client thread (every SDK callback, and its ``run_coroutine_threadsafe`` hop back
onto the adapter loop, inherits the WS thread's context). The lark SDK is optional, so a fake
client drives the REAL ``_connect_websocket`` / ``_run_official_feishu_ws_client``.
"""
import asyncio
import sys
import types
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from agent.secret_scope import (
UnscopedSecretError,
get_secret,
reset_secret_scope,
set_multiplex_active,
set_secret_scope,
)
from hermes_constants import get_hermes_home, reset_hermes_home_override, set_hermes_home_override
def _observe(routed_home: Path) -> tuple:
try:
secret = get_secret("FEISHU_PROBE_TOKEN")
except UnscopedSecretError:
secret = "<unscoped>"
return ("routed" if Path(get_hermes_home()) == routed_home else "launch", secret)
@pytest.fixture
def routed_scope(tmp_path):
"""Enter a secondary profile's scope the way gateway/run.py::_profile_runtime_scope does."""
routed_home = tmp_path / "profiles" / "b"
routed_home.mkdir(parents=True)
set_multiplex_active(True)
home_token = set_hermes_home_override(str(routed_home))
secret_token = set_secret_scope({"FEISHU_PROBE_TOKEN": "routed-value"})
try:
yield routed_home
finally:
reset_secret_scope(secret_token)
reset_hermes_home_override(home_token)
set_multiplex_active(False)
def test_drive_comment_agent_turn_runs_under_the_adapter_profile_scope(routed_scope):
"""The comment agent thread must resolve model/credentials/home for the routed profile,
not the launch profile (bare run_in_executor drops the contextvar scope)."""
from plugins.platforms.feishu import feishu_comment as fc
from plugins.platforms.feishu import feishu_comment_rules as rules
seen = {}
def _fake_agent(prompt, client, session_key=""):
seen["turn"] = _observe(routed_scope)
return "NO_REPLY"
event = SimpleNamespace(event={
"comment_id": "c1", "reply_id": "", "is_mentioned": True, "timestamp": "1",
"notice_meta": {"file_token": "docx_t", "file_type": "docx", "notice_type": "add_reply",
"from_user_id": {"open_id": "ou_user"}, "to_user_id": {"open_id": "ou_bot"}},
})
with patch.object(rules, "load_config", return_value=object()), \
patch.object(rules, "resolve_rule", return_value=rules.ResolvedCommentRule(True, "allowlist", frozenset(), "top")), \
patch.object(rules, "has_wiki_keys", return_value=False), \
patch.object(rules, "is_user_allowed", return_value=True), \
patch.object(fc, "query_document_meta", AsyncMock(return_value={"title": "T", "url": "u"})), \
patch.object(fc, "batch_query_comment", AsyncMock(return_value={"is_whole": False, "quote": ""})), \
patch.object(fc, "_local_comment_prompt", AsyncMock(return_value="prompt")), \
patch.object(fc, "_run_comment_agent", _fake_agent):
asyncio.run(fc.handle_drive_comment_event(object(), event, self_open_id="ou_bot"))
assert seen["turn"] == ("routed", "routed-value")
def test_ws_client_thread_and_its_loop_callbacks_carry_the_adapter_profile_scope(routed_scope, monkeypatch):
"""Lark SDK callbacks run on the WS thread and re-enter the adapter loop through
run_coroutine_threadsafe (which copies the caller's context): both must see the profile that
connected the adapter, so media caching / markers / env reads before handle_message stay in it."""
from plugins.platforms.feishu import adapter as fa
client_mod = types.ModuleType("lark_oapi.ws.client")
client_mod.loop = SimpleNamespace(name="sdk-default-loop")
client_mod.websockets = SimpleNamespace(connect=lambda *a, **k: None)
lark_ws = types.ModuleType("lark_oapi.ws")
lark_ws.client = client_mod
lark = types.ModuleType("lark_oapi")
lark.ws = lark_ws
for name, mod in (("lark_oapi", lark), ("lark_oapi.ws", lark_ws), ("lark_oapi.ws.client", client_mod)):
monkeypatch.setitem(sys.modules, name, mod)
monkeypatch.setattr(fa, "_WS_ISOLATION_INSTALLED", False)
monkeypatch.setattr(fa, "FEISHU_WEBSOCKET_AVAILABLE", True)
monkeypatch.setattr(fa, "lark", SimpleNamespace(LogLevel=SimpleNamespace(INFO=1)))
seen = {}
async def scenario():
adapter_loop = asyncio.get_running_loop()
async def _on_loop():
return _observe(routed_scope)
class FakeWSClient:
def __init__(self, **kwargs):
pass
def start(self): # the SDK-owned thread every lark callback fires on
seen["ws_thread"] = _observe(routed_scope)
seen["loop_callback"] = asyncio.run_coroutine_threadsafe(_on_loop(), adapter_loop).result(5)
monkeypatch.setattr(fa, "FeishuWSClient", FakeWSClient)
stub = SimpleNamespace(
_loop=adapter_loop, _app_id="cli_x", _app_secret="s", _domain_name="feishu", _event_handler=None,
_ws_thread_loop=None, _ws_reconnect_nonce=None, _ws_reconnect_interval=None,
_ws_ping_interval=None, _ws_ping_timeout=None, _ws_client=None, _ws_future=None,
_prepare_client=lambda: "feishu-domain", _hydrate_bot_identity=AsyncMock(),
)
await fa.FeishuAdapter._connect_websocket(stub)
await stub._ws_future
asyncio.run(scenario())
assert seen == {"ws_thread": ("routed", "routed-value"), "loop_callback": ("routed", "routed-value")}