From b6accee0d793f9b4dc50dced6904b59b930daed5 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:11:01 -0700 Subject: [PATCH] fix(acp): pin the session cwd for slash-command handlers too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slash commands run on the event-loop thread, outside the per-turn contextvars.copy_context() that pins the session cwd for the agent call. /compress reaches agent._build_system_prompt(), whose "Current working directory" line comes from resolve_agent_cwd() — so an unpinned handler rebuilt the prompt against the Hermes install tree and PERSISTED it as the session's cached prompt, re-poisoning every later turn even though the turn itself is now pinned. Pin inside a fresh context copy so the write cannot leak into other concurrent ACP sessions on the shared loop and needs no teardown. --- acp_adapter/server.py | 20 +++++- contributors/emails/steve.darlow@gmail.com | 1 + tests/acp/test_server.py | 71 ++++++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 contributors/emails/steve.darlow@gmail.com diff --git a/acp_adapter/server.py b/acp_adapter/server.py index 2b76163488be..7fee2d932f84 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -2057,8 +2057,26 @@ class HermesACPAgent(acp.Agent): if handler is None: return None # not a known command — let the LLM handle it - try: + # Slash handlers run on the event-loop thread, OUTSIDE the per-turn + # contextvars.copy_context() that pins the session cwd for the agent + # call. ``/compress`` and ``/model`` reach code that REBUILDS the + # system prompt (agent._build_system_prompt -> resolve_agent_cwd), so + # an unpinned handler bakes the Hermes install tree into the session's + # cached prompt — persisted, and therefore poisoning every later turn + # even though the turn itself is pinned. Pin inside a fresh context so + # the write can't leak into other concurrent ACP sessions and needs no + # teardown. + def _dispatch() -> str | None: + try: + from agent.runtime_cwd import set_session_cwd + + set_session_cwd(state.cwd) + except Exception: + logger.debug("Could not pin ACP session cwd for slash command", exc_info=True) return handler(args, state) + + try: + return contextvars.copy_context().run(_dispatch) except Exception as e: logger.error("Slash command /%s error: %s", cmd, e, exc_info=True) return f"Error executing /{cmd}: {e}" diff --git a/contributors/emails/steve.darlow@gmail.com b/contributors/emails/steve.darlow@gmail.com new file mode 100644 index 000000000000..bc2e0fbbc86e --- /dev/null +++ b/contributors/emails/steve.darlow@gmail.com @@ -0,0 +1 @@ +kerpopule diff --git a/tests/acp/test_server.py b/tests/acp/test_server.py index c7d6fda498cc..66f50b1d9a48 100644 --- a/tests/acp/test_server.py +++ b/tests/acp/test_server.py @@ -2041,6 +2041,77 @@ class TestSlashCommands: result = agent._handle_slash_command("/nonexistent", state) assert result is None + def test_slash_handler_pins_the_session_cwd(self, agent, mock_manager, tmp_path): + """A slash handler must resolve the client's cwd, not the install tree. + + Slash commands run on the event-loop thread, outside the per-turn + ``copy_context()`` that pins the cwd for the agent call. ``/compress`` + reaches ``agent._build_system_prompt()``, whose "Current working + directory" line comes from ``resolve_agent_cwd()`` — and the rebuilt + prompt is PERSISTED as the session's cached prompt, so an unpinned + handler poisons every later turn even though the turn is pinned. + """ + workspace = tmp_path / "project" + workspace.mkdir() + state = mock_manager.create_session(cwd=str(workspace)) + state.cwd = str(workspace) + state.agent.model = "test-model" + state.agent.provider = "openrouter" + state.history = [ + {"role": "user", "content": "one"}, + {"role": "assistant", "content": "two"}, + ] + state.agent.compression_enabled = True + state.agent._cached_system_prompt = "system" + state.agent.tools = None + state.agent._session_db = None + + observed = {} + + def _compress(messages, system_prompt, **kwargs): + from agent.runtime_cwd import resolve_agent_cwd + + observed["cwd"] = str(resolve_agent_cwd()) + return [{"role": "user", "content": "summary"}], "new-system" + + state.agent._compress_context = _compress + + with ( + patch.object(agent.session_manager, "save_session"), + patch( + "agent.model_metadata.estimate_request_tokens_rough", + side_effect=[40, 12], + ), + ): + agent._handle_slash_command("/compress", state) + + assert observed.get("cwd") == str(workspace), ( + "the slash handler resolved " + f"{observed.get('cwd')!r} instead of the ACP client's cwd " + f"{str(workspace)!r}" + ) + + def test_slash_handler_cwd_pin_does_not_leak(self, agent, mock_manager, tmp_path): + """The pin is scoped to the handler's own context copy. + + Concurrent ACP sessions share the event loop, so a handler that pinned + the ambient context would leave its workspace bound for whatever runs + next. Asserting the ambient value is unchanged after dispatch keeps the + fix from trading one cross-session leak for another. + """ + from agent.runtime_cwd import resolve_agent_cwd + + workspace = tmp_path / "project" + workspace.mkdir() + state = mock_manager.create_session(cwd=str(workspace)) + state.cwd = str(workspace) + state.agent.model = "test-model" + state.agent.provider = "openrouter" + + before = str(resolve_agent_cwd()) + agent._handle_slash_command("/help", state) + assert str(resolve_agent_cwd()) == before + @pytest.mark.asyncio async def test_slash_command_intercepted_in_prompt(self, agent, mock_manager): """Slash commands should be handled without calling the LLM."""