mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-29 23:41:35 +08:00
Compare commits
1 Commits
fix/plugin
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
603a68b918 |
@@ -496,7 +496,6 @@ class HermesACPAgent(acp.Agent):
|
|||||||
|
|
||||||
tool_call_ids: dict[str, Deque[str]] = defaultdict(deque)
|
tool_call_ids: dict[str, Deque[str]] = defaultdict(deque)
|
||||||
tool_call_meta: dict[str, dict[str, Any]] = {}
|
tool_call_meta: dict[str, dict[str, Any]] = {}
|
||||||
previous_approval_cb = None
|
|
||||||
|
|
||||||
if conn:
|
if conn:
|
||||||
tool_progress_cb = make_tool_progress_cb(conn, session_id, loop, tool_call_ids, tool_call_meta)
|
tool_progress_cb = make_tool_progress_cb(conn, session_id, loop, tool_call_ids, tool_call_meta)
|
||||||
@@ -517,10 +516,16 @@ class HermesACPAgent(acp.Agent):
|
|||||||
agent.step_callback = step_cb
|
agent.step_callback = step_cb
|
||||||
agent.message_callback = message_cb
|
agent.message_callback = message_cb
|
||||||
|
|
||||||
|
# Install the per-session approval callback into the current asyncio
|
||||||
|
# task's context. Because ``terminal_tool._approval_callback_var`` is
|
||||||
|
# a ``ContextVar`` and ``loop.run_in_executor`` propagates the caller's
|
||||||
|
# context to the worker thread, concurrent ACP sessions in the same
|
||||||
|
# process each see their own callback without stomping on each other.
|
||||||
|
# No save/restore is needed: when this coroutine returns, the context
|
||||||
|
# snapshot holding the set is discarded.
|
||||||
if approval_cb:
|
if approval_cb:
|
||||||
try:
|
try:
|
||||||
from tools import terminal_tool as _terminal_tool
|
from tools import terminal_tool as _terminal_tool
|
||||||
previous_approval_cb = getattr(_terminal_tool, "_approval_callback", None)
|
|
||||||
_terminal_tool.set_approval_callback(approval_cb)
|
_terminal_tool.set_approval_callback(approval_cb)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Could not set ACP approval callback", exc_info=True)
|
logger.debug("Could not set ACP approval callback", exc_info=True)
|
||||||
@@ -536,16 +541,16 @@ class HermesACPAgent(acp.Agent):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Agent error in session %s", session_id)
|
logger.exception("Agent error in session %s", session_id)
|
||||||
return {"final_response": f"Error: {e}", "messages": state.history}
|
return {"final_response": f"Error: {e}", "messages": state.history}
|
||||||
finally:
|
|
||||||
if approval_cb:
|
|
||||||
try:
|
|
||||||
from tools import terminal_tool as _terminal_tool
|
|
||||||
_terminal_tool.set_approval_callback(previous_approval_cb)
|
|
||||||
except Exception:
|
|
||||||
logger.debug("Could not restore approval callback", exc_info=True)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await loop.run_in_executor(_executor, _run_agent)
|
# Copy the current asyncio task's context and run the agent inside
|
||||||
|
# it so per-session ContextVar state (e.g. the approval callback
|
||||||
|
# installed above via set_approval_callback) is visible to tool code
|
||||||
|
# executing on the worker thread. ``loop.run_in_executor`` does NOT
|
||||||
|
# propagate contextvars on its own.
|
||||||
|
import contextvars as _ctxvars
|
||||||
|
_ctx = _ctxvars.copy_context()
|
||||||
|
result = await loop.run_in_executor(_executor, lambda: _ctx.run(_run_agent))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Executor error for session %s", session_id)
|
logger.exception("Executor error for session %s", session_id)
|
||||||
return PromptResponse(stop_reason="end_turn")
|
return PromptResponse(stop_reason="end_turn")
|
||||||
|
|||||||
154
tests/acp/test_concurrent_approval_isolation.py
Normal file
154
tests/acp/test_concurrent_approval_isolation.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"""Regression tests for GHSA-qg5c-hvr5-hjgr.
|
||||||
|
|
||||||
|
Before the fix, ``tools.terminal_tool._approval_callback`` was a module-global.
|
||||||
|
When two ACP sessions overlapped in the same process, session B's
|
||||||
|
``set_approval_callback`` overwrote session A's — so session A's
|
||||||
|
dangerous-command approval could be routed through session B's callback
|
||||||
|
(and vice versa).
|
||||||
|
|
||||||
|
The fix stores the callback in a ``ContextVar`` that each asyncio task
|
||||||
|
gets its own copy of, and ACP's ``prompt`` handler wraps the executor call
|
||||||
|
with ``contextvars.copy_context().run(...)`` so the per-session callback
|
||||||
|
survives the hop into the worker thread.
|
||||||
|
|
||||||
|
These tests exercise the primitive directly without spinning up a full
|
||||||
|
``HermesACPAgent`` — they verify that:
|
||||||
|
|
||||||
|
1. Two concurrent asyncio tasks can each set ``_approval_callback_var`` to
|
||||||
|
a distinct session-specific callback and each see their own value.
|
||||||
|
2. The value is still visible from inside a ``run_in_executor`` worker
|
||||||
|
thread when the caller uses ``copy_context().run``.
|
||||||
|
3. The raw ``run_in_executor`` path without ``copy_context`` does NOT
|
||||||
|
propagate contextvars — this is the asyncio contract we rely on the
|
||||||
|
ACP adapter to bridge.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextvars
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tools import terminal_tool as tt
|
||||||
|
|
||||||
|
|
||||||
|
async def _session(session_id: str, overlap_delay: float, observed: dict):
|
||||||
|
"""Simulate an ACP session.
|
||||||
|
|
||||||
|
1. Registers a session-specific approval callback via the public
|
||||||
|
``set_approval_callback`` API.
|
||||||
|
2. Yields control so sibling tasks can install their own callbacks
|
||||||
|
and create a realistic overlap window.
|
||||||
|
3. Runs a synchronous worker in a thread executor using
|
||||||
|
``copy_context().run`` (mirrors the ACP adapter's pattern) and
|
||||||
|
records which callback identity the worker observes.
|
||||||
|
"""
|
||||||
|
def approval_cb(command, description, **_):
|
||||||
|
return f"approval-from-{session_id}"
|
||||||
|
|
||||||
|
tt.set_approval_callback(approval_cb)
|
||||||
|
await asyncio.sleep(overlap_delay)
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
ctx = contextvars.copy_context()
|
||||||
|
|
||||||
|
def _in_worker():
|
||||||
|
cb = tt._approval_callback_var.get()
|
||||||
|
return cb("rm -rf /", "dangerous") if cb else None
|
||||||
|
|
||||||
|
observed[session_id] = await loop.run_in_executor(
|
||||||
|
None, lambda: ctx.run(_in_worker)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConcurrentACPApprovalIsolation:
|
||||||
|
"""Regression guard for cross-session approval callback confusion."""
|
||||||
|
|
||||||
|
def test_concurrent_sessions_see_their_own_callback(self):
|
||||||
|
"""Two overlapping ACP sessions each observe their own callback.
|
||||||
|
|
||||||
|
Session A starts first but sleeps longer, so by the time it reads
|
||||||
|
its callback, session B has already registered its own. Before
|
||||||
|
the ContextVar fix, both sessions would observe whichever callback
|
||||||
|
was set most recently in the module-global slot.
|
||||||
|
"""
|
||||||
|
observed: dict = {}
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
await asyncio.gather(
|
||||||
|
_session("A-cd0fa01e", 0.05, observed),
|
||||||
|
_session("B-cc2f5ce8", 0.02, observed),
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
|
||||||
|
assert observed["A-cd0fa01e"] == "approval-from-A-cd0fa01e"
|
||||||
|
assert observed["B-cc2f5ce8"] == "approval-from-B-cc2f5ce8"
|
||||||
|
|
||||||
|
def test_callback_visible_through_run_in_executor_with_copy_context(self):
|
||||||
|
"""``copy_context().run`` propagates the callback into the worker thread."""
|
||||||
|
async def runner():
|
||||||
|
def cb(cmd, desc, **_):
|
||||||
|
return "approved"
|
||||||
|
|
||||||
|
tt.set_approval_callback(cb)
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
ctx = contextvars.copy_context()
|
||||||
|
|
||||||
|
def _worker():
|
||||||
|
got = tt._approval_callback_var.get()
|
||||||
|
return got("x", "y") if got else None
|
||||||
|
|
||||||
|
return await loop.run_in_executor(None, lambda: ctx.run(_worker))
|
||||||
|
|
||||||
|
assert asyncio.run(runner()) == "approved"
|
||||||
|
|
||||||
|
def test_set_approval_callback_is_context_scoped(self):
|
||||||
|
"""A direct ``set_approval_callback`` call does not leak into the caller's context.
|
||||||
|
|
||||||
|
This is the asyncio-level guarantee the ACP fix relies on: a child
|
||||||
|
task's ``ContextVar.set`` mutates only the child's context copy.
|
||||||
|
"""
|
||||||
|
observed: dict = {}
|
||||||
|
|
||||||
|
async def child():
|
||||||
|
def cb(cmd, desc, **_):
|
||||||
|
return "child"
|
||||||
|
tt.set_approval_callback(cb)
|
||||||
|
observed["child"] = tt._approval_callback_var.get()("x", "y")
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
# Parent sees no callback
|
||||||
|
observed["parent_before"] = tt._approval_callback_var.get()
|
||||||
|
await asyncio.create_task(child())
|
||||||
|
# Parent still sees no callback after child completes
|
||||||
|
observed["parent_after"] = tt._approval_callback_var.get()
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
|
|
||||||
|
assert observed["parent_before"] is None
|
||||||
|
assert observed["child"] == "child"
|
||||||
|
assert observed["parent_after"] is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunInExecutorContextContract:
|
||||||
|
"""Document the asyncio contract the ACP adapter relies on."""
|
||||||
|
|
||||||
|
def test_run_in_executor_without_copy_context_does_not_propagate(self):
|
||||||
|
"""Without ``copy_context().run``, contextvars do NOT cross into the worker.
|
||||||
|
|
||||||
|
This is the asyncio standard-library behavior. If the ACP adapter
|
||||||
|
ever drops the ``copy_context().run`` wrapper around ``_run_agent``,
|
||||||
|
this test will pass (contextvars will appear empty in the worker)
|
||||||
|
while the isolation test above will fail — a clear signal that the
|
||||||
|
bridging wrapper is missing.
|
||||||
|
"""
|
||||||
|
probe: contextvars.ContextVar = contextvars.ContextVar("probe", default="unset")
|
||||||
|
|
||||||
|
async def runner():
|
||||||
|
probe.set("set-in-task")
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
return await loop.run_in_executor(None, probe.get)
|
||||||
|
|
||||||
|
# Worker thread does not inherit the task's context
|
||||||
|
assert asyncio.run(runner()) == "unset"
|
||||||
@@ -41,6 +41,7 @@ import threading
|
|||||||
import atexit
|
import atexit
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from contextvars import ContextVar
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
|
|
||||||
@@ -116,20 +117,42 @@ _cached_sudo_password: str = ""
|
|||||||
# so prompts route through prompt_toolkit's event loop.
|
# so prompts route through prompt_toolkit's event loop.
|
||||||
# _sudo_password_callback() -> str (return password or "" to skip)
|
# _sudo_password_callback() -> str (return password or "" to skip)
|
||||||
# _approval_callback(command, description) -> str ("once"/"session"/"always"/"deny")
|
# _approval_callback(command, description) -> str ("once"/"session"/"always"/"deny")
|
||||||
_sudo_password_callback = None
|
#
|
||||||
_approval_callback = None
|
# These callbacks are stored in ``ContextVar`` so that concurrent sessions
|
||||||
|
# hosted in a single process (e.g. multiple ACP sessions in one Hermes ACP
|
||||||
|
# adapter) each see their own callback. A module-global would let one
|
||||||
|
# session's callback overwrite another's while both are still running, routing
|
||||||
|
# dangerous-command approval prompts to the wrong editor/session context.
|
||||||
|
# ``asyncio`` tasks — and threads launched via ``loop.run_in_executor`` —
|
||||||
|
# each receive a copy of the caller's context, so per-task isolation is
|
||||||
|
# automatic. CLI callers that set the callback once at startup still work
|
||||||
|
# unchanged: a single context holds the single callback.
|
||||||
|
_sudo_password_callback_var: ContextVar = ContextVar(
|
||||||
|
"_sudo_password_callback", default=None
|
||||||
|
)
|
||||||
|
_approval_callback_var: ContextVar = ContextVar(
|
||||||
|
"_approval_callback", default=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def set_sudo_password_callback(cb):
|
def set_sudo_password_callback(cb):
|
||||||
"""Register a callback for sudo password prompts (used by CLI)."""
|
"""Register a callback for sudo password prompts (used by CLI)."""
|
||||||
global _sudo_password_callback
|
_sudo_password_callback_var.set(cb)
|
||||||
_sudo_password_callback = cb
|
|
||||||
|
|
||||||
|
|
||||||
def set_approval_callback(cb):
|
def set_approval_callback(cb):
|
||||||
"""Register a callback for dangerous command approval prompts (used by CLI)."""
|
"""Register a callback for dangerous command approval prompts (used by CLI)."""
|
||||||
global _approval_callback
|
_approval_callback_var.set(cb)
|
||||||
_approval_callback = cb
|
|
||||||
|
|
||||||
|
def _get_sudo_password_callback():
|
||||||
|
"""Return the sudo password callback for the current context."""
|
||||||
|
return _sudo_password_callback_var.get()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_approval_callback():
|
||||||
|
"""Return the approval callback for the current context."""
|
||||||
|
return _approval_callback_var.get()
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Dangerous Command Approval System
|
# Dangerous Command Approval System
|
||||||
@@ -144,7 +167,7 @@ from tools.approval import (
|
|||||||
def _check_all_guards(command: str, env_type: str) -> dict:
|
def _check_all_guards(command: str, env_type: str) -> dict:
|
||||||
"""Delegate to consolidated guard (tirith + dangerous cmd) with CLI callback."""
|
"""Delegate to consolidated guard (tirith + dangerous cmd) with CLI callback."""
|
||||||
return _check_all_guards_impl(command, env_type,
|
return _check_all_guards_impl(command, env_type,
|
||||||
approval_callback=_approval_callback)
|
approval_callback=_approval_callback_var.get())
|
||||||
|
|
||||||
|
|
||||||
# Allowlist: characters that can legitimately appear in directory paths.
|
# Allowlist: characters that can legitimately appear in directory paths.
|
||||||
@@ -219,9 +242,10 @@ def _prompt_for_sudo_password(timeout_seconds: int = 45) -> str:
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Use the registered callback when available (prompt_toolkit-compatible)
|
# Use the registered callback when available (prompt_toolkit-compatible)
|
||||||
if _sudo_password_callback is not None:
|
_cb = _sudo_password_callback_var.get()
|
||||||
|
if _cb is not None:
|
||||||
try:
|
try:
|
||||||
return _sudo_password_callback() or ""
|
return _cb() or ""
|
||||||
except Exception:
|
except Exception:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user