From 9b430f2e50c17a7084f85a6adfc1f857050acf5e Mon Sep 17 00:00:00 2001 From: Christopher <210261288+Christopher-Schulze@users.noreply.github.com> Date: Fri, 14 Aug 2026 17:20:07 +0200 Subject: [PATCH 1/8] fix(tools): empty execute_code capability set denies all sandbox tools enabled_tools=[] was conflated with enabled_tools=None via truthiness, so an explicit empty grant broadened to every SANDBOX_ALLOWED_TOOLS stub instead of denying all. Preserve tri-state semantics: None keeps the legacy default (every sandbox tool), an explicit list (possibly empty) uses the exact intersection, so [] and a non-overlapping list both deny all. Fixes #84271 --- tests/tools/test_code_execution.py | 59 +++++++++++++++++++++++++----- tools/code_execution_tool.py | 9 ++++- 2 files changed, 57 insertions(+), 11 deletions(-) diff --git a/tests/tools/test_code_execution.py b/tests/tools/test_code_execution.py index d8a899d5150f9..55da8776f7ac2 100644 --- a/tests/tools/test_code_execution.py +++ b/tests/tools/test_code_execution.py @@ -681,21 +681,62 @@ def test_windows_returns_error(self): @unittest.skipIf(sys.platform == "win32", "UDS not available on Windows") - def test_nonoverlapping_tools_fallback(self): - """When enabled_tools has no overlap with SANDBOX_ALLOWED_TOOLS, - should fall back to all allowed tools.""" - code = ( - "from hermes_tools import terminal\n" - "print('fallback ok')\n" + def test_empty_enabled_tools_denies_all(self): + """``enabled_tools=[]`` is an explicit deny-all: no sandbox tool stubs + are generated, so ``from hermes_tools import terminal`` raises + ImportError. Previously ``[]`` was conflated with ``None`` and + broadened to every sandbox tool (#84271).""" + code = "from hermes_tools import terminal\nprint('should not reach')\n" + with patch("model_tools.handle_function_call", + side_effect=_mock_handle_function_call): + result = json.loads(execute_code( + code, task_id="test-empty-deny-all", + enabled_tools=[], + )) + self.assertEqual(result["status"], "error", msg=result) + self.assertIn( + "ImportError", + result.get("error", "") + result.get("output", ""), ) + + + @unittest.skipIf(sys.platform == "win32", "UDS not available on Windows") + def test_nonoverlapping_tools_deny_all(self): + """A non-empty ``enabled_tools`` with no sandbox overlap must not fall + back to every sandbox tool — the exact authenticated manifest is used, + and an empty intersection denies all (#84271).""" + code = "from hermes_tools import terminal\nprint('fallback')\n" with patch("model_tools.handle_function_call", - return_value=json.dumps({"ok": True})): + side_effect=_mock_handle_function_call): result = json.loads(execute_code( code, task_id="test-nonoverlap", enabled_tools=["vision_analyze", "browser_snapshot"], )) - self.assertEqual(result["status"], "success") - self.assertIn("fallback ok", result["output"]) + self.assertEqual(result["status"], "error", msg=result) + self.assertIn( + "ImportError", + result.get("error", "") + result.get("output", ""), + ) + + + @unittest.skipIf(sys.platform == "win32", "UDS not available on Windows") + def test_none_enabled_tools_legacy_default(self): + """``enabled_tools=None`` keeps the documented legacy default (every + sandbox tool) — the mode is explicit (``is None``), not derived from + truthiness, so the empty-list deny-all does not shadow it (#84271).""" + code = ( + "from hermes_tools import terminal\n" + "r = terminal('echo hi')\n" + "print(r.get('output', ''))\n" + ) + with patch("model_tools.handle_function_call", + side_effect=_mock_handle_function_call): + result = json.loads(execute_code( + code, task_id="test-none-default", + enabled_tools=None, + )) + self.assertEqual(result["status"], "success", msg=result) + self.assertIn("mock output for: echo hi", result["output"]) # --------------------------------------------------------------------------- diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index c7d77d23d5e96..d86c0039614e3 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -549,8 +549,13 @@ def _finish_remote_kernel_result(kernel_result: Dict[str, Any], *, def _sandbox_tools_for(enabled_tools: Optional[List[str]]) -> frozenset: - """Enabled ∩ SANDBOX_ALLOWED_TOOLS, or every sandbox tool when the intersection is empty.""" - return frozenset(SANDBOX_ALLOWED_TOOLS & set(enabled_tools or ())) or SANDBOX_ALLOWED_TOOLS + """Tri-state sandbox tool resolution. None → legacy default (every sandbox tool); an + explicit list (possibly empty) → exact intersection with SANDBOX_ALLOWED_TOOLS, so an + empty grant denies all rather than broadening to the default + (SECURITY-CLASS-faf9d60580300e16 / #84271).""" + if enabled_tools is None: + return SANDBOX_ALLOWED_TOOLS + return frozenset(SANDBOX_ALLOWED_TOOLS & set(enabled_tools)) def _run_remote_per_call(env, env_type: str, code: str, effective_task_id: str, From c6f8b1dff86a08af6c7c104dce55c85bc60fdd04 Mon Sep 17 00:00:00 2001 From: Christopher <210261288+Christopher-Schulze@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:40:35 +0200 Subject: [PATCH 2/8] fix(tools): share execute_code sandbox-tool tri-state helper Local UDS and remote file-RPC now resolve enabled_tools through one helper so the deny-all / legacy-default contract cannot drift. --- tests/tools/test_code_execution.py | 23 +++++++++++++++++++++++ tools/code_execution_tool.py | 12 ++++++++++++ 2 files changed, 35 insertions(+) diff --git a/tests/tools/test_code_execution.py b/tests/tools/test_code_execution.py index 55da8776f7ac2..38e400e79e96c 100644 --- a/tests/tools/test_code_execution.py +++ b/tests/tools/test_code_execution.py @@ -59,6 +59,7 @@ def _fresh_kernel_registry(): _TOOL_DOC_LINES, _execute_remote, _format_interrupted_output, + _resolve_sandbox_tools, ) from tools.registry import registry @@ -739,6 +740,28 @@ def test_none_enabled_tools_legacy_default(self): self.assertIn("mock output for: echo hi", result["output"]) +class TestResolveSandboxTools(unittest.TestCase): + """Shared helper used by both the local UDS and remote file-RPC paths.""" + + def test_none_is_legacy_default(self): + self.assertEqual(_resolve_sandbox_tools(None), SANDBOX_ALLOWED_TOOLS) + + def test_empty_list_is_deny_all(self): + self.assertEqual(_resolve_sandbox_tools([]), frozenset()) + + def test_nonoverlapping_is_deny_all(self): + self.assertEqual( + _resolve_sandbox_tools(["vision_analyze", "browser_snapshot"]), + frozenset(), + ) + + def test_intersection_keeps_only_sandbox_tools(self): + self.assertEqual( + _resolve_sandbox_tools(["terminal", "vision_analyze"]), + frozenset(["terminal"]), + ) + + # --------------------------------------------------------------------------- # _load_config # --------------------------------------------------------------------------- diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index d86c0039614e3..58240cc9cd84c 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -41,6 +41,18 @@ "web_search", "web_extract", "read_file", "write_file", "search_files", "patch", "terminal", ]) + +def _resolve_sandbox_tools(enabled_tools: Optional[List[str]]) -> frozenset: + """Tri-state sandbox capability grant (#84271). + + ``None`` keeps the legacy default (every sandbox tool). An explicit list, + including empty, is the exact intersection with ``SANDBOX_ALLOWED_TOOLS`` + — empty or non-overlapping means deny-all. + """ + if enabled_tools is None: + return SANDBOX_ALLOWED_TOOLS + return frozenset(SANDBOX_ALLOWED_TOOLS & set(enabled_tools)) + # Resource limit defaults (overridable via config.yaml → code_execution.*) DEFAULT_TIMEOUT = 300 # 5 minutes DEFAULT_MAX_TOOL_CALLS = 50 From f3a3cb7c7c70cfc2b3eeb8bb25405a68d904242d Mon Sep 17 00:00:00 2001 From: Christopher <210261288+Christopher-Schulze@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:57:57 +0200 Subject: [PATCH 3/8] test(agent): isolate plugin prompt cache from live git snapshot CI merge checkouts are detached HEAD; a second status probe can drop the branch line and fail first==rebuilt. Pin context cwd so this test only covers plugin-section caching. --- tests/agent/test_plugin_prompt_sections.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/agent/test_plugin_prompt_sections.py b/tests/agent/test_plugin_prompt_sections.py index da63bb37a40f1..7df718ca4e6f6 100644 --- a/tests/agent/test_plugin_prompt_sections.py +++ b/tests/agent/test_plugin_prompt_sections.py @@ -41,7 +41,9 @@ def _install_test_section(manager: PluginManager, content) -> None: ) -def test_real_aiagent_freezes_section_within_life_and_rerenders_on_invalidate(monkeypatch): +def test_real_aiagent_builds_section_once_and_keeps_it_out_of_static_prefix( + monkeypatch, tmp_path +): # Pin the workspace snapshot: build_coding_workspace_block shells out to # live `git status`/`git log` on every build, and a git call failing or # timing out under xdist contention makes the two builds differ in the @@ -61,6 +63,9 @@ def section(session_info): manager = PluginManager() _install_test_section(manager, section) monkeypatch.setattr(plugins, "_plugin_manager", manager) + # CI merge checkouts are detached HEAD; a second git probe can drop the + # branch line. This test is about plugin-section caching, not live git. + monkeypatch.setattr("agent.system_prompt.resolve_context_cwd", lambda: tmp_path) agent = _real_agent() first = build_system_prompt(agent) From 0a81f71a651f8c6e1a16039afe7fbfc8db4570bf Mon Sep 17 00:00:00 2001 From: Christopher <210261288+Christopher-Schulze@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:41:20 +0200 Subject: [PATCH 4/8] fix(tools): enforce sandbox grants at RPC boundaries Address the review follow-up by testing local and remote RPC authorization directly, keeping sandbox failure hints tri-state-aware, and proving partial grants cannot dispatch unapproved tools. --- tests/tools/test_code_execution.py | 280 ++++++++++++++++++++++++++++- tools/code_execution_tool.py | 17 +- 2 files changed, 278 insertions(+), 19 deletions(-) diff --git a/tests/tools/test_code_execution.py b/tests/tools/test_code_execution.py index 38e400e79e96c..ad22cfab3996c 100644 --- a/tests/tools/test_code_execution.py +++ b/tests/tools/test_code_execution.py @@ -12,12 +12,16 @@ or: python tests/test_code_execution.py """ +import base64 +import fnmatch import pytest # pytestmark removed — tests run fine (61 pass, ~99s) import json import os +import shlex import socket +import tempfile import time os.environ["TERMINAL_ENV"] = "local" @@ -59,7 +63,8 @@ def _fresh_kernel_registry(): _TOOL_DOC_LINES, _execute_remote, _format_interrupted_output, - _resolve_sandbox_tools, + _sandbox_tools_for, + _sandbox_failure_hint, ) from tools.registry import registry @@ -744,24 +749,289 @@ class TestResolveSandboxTools(unittest.TestCase): """Shared helper used by both the local UDS and remote file-RPC paths.""" def test_none_is_legacy_default(self): - self.assertEqual(_resolve_sandbox_tools(None), SANDBOX_ALLOWED_TOOLS) + self.assertEqual(_sandbox_tools_for(None), SANDBOX_ALLOWED_TOOLS) def test_empty_list_is_deny_all(self): - self.assertEqual(_resolve_sandbox_tools([]), frozenset()) + self.assertEqual(_sandbox_tools_for([]), frozenset()) def test_nonoverlapping_is_deny_all(self): self.assertEqual( - _resolve_sandbox_tools(["vision_analyze", "browser_snapshot"]), + _sandbox_tools_for(["vision_analyze", "browser_snapshot"]), frozenset(), ) def test_intersection_keeps_only_sandbox_tools(self): self.assertEqual( - _resolve_sandbox_tools(["terminal", "vision_analyze"]), + _sandbox_tools_for(["terminal", "vision_analyze"]), frozenset(["terminal"]), ) +class TestSandboxFailureHints(unittest.TestCase): + _IMPORT_ERROR = "ImportError: cannot import name 'terminal' from 'hermes_tools'" + + def test_empty_grant_does_not_advertise_sandbox_tools(self): + hint = _sandbox_failure_hint(self._IMPORT_ERROR, enabled_tools=[]) + + self.assertIn("Importable tools here: none.", hint) + + def test_partial_grant_advertises_only_the_intersection(self): + hint = _sandbox_failure_hint( + self._IMPORT_ERROR, + enabled_tools=["terminal", "vision_analyze"], + ) + + self.assertIn("Importable tools here: terminal.", hint) + self.assertNotIn("write_file", hint) + + +class _FakeFileRpcEnvironment: + """Map the remote poller's shell operations onto a local temp directory.""" + + def __init__(self, rpc_dir): + self.rpc_dir = rpc_dir + + def execute(self, command, cwd=None, timeout=None): + parts = shlex.split(command) + if parts[:2] == ["ls", "-1"]: + pattern = os.path.normpath(parts[2]) + directory, filename = os.path.split(pattern) + paths = sorted( + os.path.join(directory, name) + for name in os.listdir(directory) + if fnmatch.fnmatch(name, filename) + ) + return {"output": "\n".join(paths)} + + if parts[0] == "cat": + with open(parts[1], encoding="utf-8") as request_file: + return {"output": request_file.read()} + + if parts[:2] == ["rm", "-f"]: + try: + os.unlink(parts[2]) + except FileNotFoundError: + pass + return {"output": ""} + + if parts[0] == "echo" and "base64" in parts: + redirect_index = parts.index(">") + move_index = parts.index("mv") + temporary_path = parts[redirect_index + 1] + response_path = parts[move_index + 2] + with open(temporary_path, "wb") as response_file: + response_file.write(base64.b64decode(parts[1])) + os.replace(temporary_path, response_path) + return {"output": ""} + + raise AssertionError(f"Unexpected fake remote command: {command}") + + +class TestSandboxRpcAuthorization(unittest.TestCase): + """Exercise the real generated _call() authorization boundaries.""" + + _RPC_TOKEN = "test-rpc-token" + _REQUESTS = ( + ("terminal", {"command": "echo denied"}), + ("write_file", {"path": "blocked.txt", "content": "blocked"}), + ) + + @staticmethod + def _dispatch_recorder(dispatched): + def dispatch(function_name, function_args, task_id=None, user_task=None): + dispatched.append((function_name, function_args)) + return _mock_handle_function_call( + function_name, + function_args, + task_id=task_id, + user_task=user_task, + ) + + return dispatch + + def _run_uds_calls(self, enabled_tools, requests): + from tools.code_execution_tool import _rpc_server_loop + + allowed_tools = _sandbox_tools_for(enabled_tools) + dispatched = [] + tool_call_log = [] + tool_call_counter = [0] + stop_event = threading.Event() + + with tempfile.TemporaryDirectory(prefix="hermes-rpc-") as temp_dir: + socket_path = os.path.join(temp_dir, "rpc.sock") + server_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + server_sock.bind(socket_path) + server_sock.listen(1) + + def run_server(): + with patch( + "model_tools.handle_function_call", + side_effect=self._dispatch_recorder(dispatched), + ): + _rpc_server_loop( + server_sock, + "test-task", + tool_call_log, + tool_call_counter, + max_tool_calls=10, + allowed_tools=allowed_tools, + stop_event=stop_event, + rpc_token=self._RPC_TOKEN, + ) + + server_thread = threading.Thread(target=run_server, daemon=True) + server_thread.start() + namespace = {"__name__": "hermes_tools"} + try: + with patch.dict( + os.environ, + { + "HERMES_RPC_SOCKET": socket_path, + "HERMES_RPC_TOKEN": self._RPC_TOKEN, + }, + ): + exec( + generate_hermes_tools_module( + list(allowed_tools), + transport="uds", + ), + namespace, + ) + responses = [ + namespace["_call"](tool_name, args) + for tool_name, args in requests + ] + finally: + client_sock = namespace.get("_sock") + if client_sock is not None: + client_sock.close() + stop_event.set() + server_sock.close() + server_thread.join(timeout=5) + + self.assertFalse(server_thread.is_alive()) + return responses, dispatched + + def _run_file_calls(self, enabled_tools, requests): + from tools.code_execution_tool import _rpc_poll_loop + + allowed_tools = _sandbox_tools_for(enabled_tools) + dispatched = [] + tool_call_log = [] + tool_call_counter = [0] + stop_event = threading.Event() + + with tempfile.TemporaryDirectory(prefix="hermes-rpc-") as temp_dir: + rpc_dir = os.path.join(temp_dir, "rpc") + os.mkdir(rpc_dir) + env = _FakeFileRpcEnvironment(rpc_dir) + + def run_poller(): + with patch( + "model_tools.handle_function_call", + side_effect=self._dispatch_recorder(dispatched), + ): + _rpc_poll_loop( + env, + rpc_dir, + "test-task", + tool_call_log, + tool_call_counter, + max_tool_calls=10, + allowed_tools=allowed_tools, + stop_event=stop_event, + rpc_token=self._RPC_TOKEN, + ) + + poller_thread = threading.Thread(target=run_poller, daemon=True) + poller_thread.start() + namespace = {"__name__": "hermes_tools"} + try: + with patch.dict( + os.environ, + { + "HERMES_RPC_DIR": rpc_dir, + "HERMES_RPC_TOKEN": self._RPC_TOKEN, + }, + ): + exec( + generate_hermes_tools_module( + list(allowed_tools), + transport="file", + ), + namespace, + ) + responses = [ + namespace["_call"](tool_name, args) + for tool_name, args in requests + ] + finally: + stop_event.set() + poller_thread.join(timeout=5) + + self.assertFalse(poller_thread.is_alive()) + return responses, dispatched + + @unittest.skipIf(sys.platform == "win32", "UDS not available on Windows") + def test_uds_empty_and_nonoverlapping_grants_reject_both_tools(self): + for enabled_tools in ([], ["vision_analyze"]): + with self.subTest(enabled_tools=enabled_tools): + responses, dispatched = self._run_uds_calls( + enabled_tools, + self._REQUESTS, + ) + + for response, (tool_name, _) in zip(responses, self._REQUESTS): + self.assertIn( + f"Tool '{tool_name}' is not available", + response.get("error", ""), + ) + self.assertEqual(dispatched, []) + + @unittest.skipIf(sys.platform == "win32", "UDS not available on Windows") + def test_uds_partial_grant_dispatches_only_the_intersection(self): + responses, dispatched = self._run_uds_calls( + ["terminal", "vision_analyze"], + ( + ("terminal", {"command": "echo allowed"}), + ("write_file", {"path": "blocked.txt", "content": "blocked"}), + ), + ) + + self.assertIn("mock output for: echo allowed", responses[0].get("output", "")) + self.assertIn("Available: terminal", responses[1].get("error", "")) + self.assertEqual([name for name, _ in dispatched], ["terminal"]) + + def test_file_empty_and_nonoverlapping_grants_reject_both_tools(self): + for enabled_tools in ([], ["vision_analyze"]): + with self.subTest(enabled_tools=enabled_tools): + responses, dispatched = self._run_file_calls( + enabled_tools, + self._REQUESTS, + ) + + for response, (tool_name, _) in zip(responses, self._REQUESTS): + self.assertIn( + f"Tool '{tool_name}' is not available", + response.get("error", ""), + ) + self.assertEqual(dispatched, []) + + def test_file_partial_grant_dispatches_only_the_intersection(self): + responses, dispatched = self._run_file_calls( + ["terminal", "vision_analyze"], + ( + ("terminal", {"command": "echo allowed"}), + ("write_file", {"path": "blocked.txt", "content": "blocked"}), + ), + ) + + self.assertIn("mock output for: echo allowed", responses[0].get("output", "")) + self.assertIn("Available: terminal", responses[1].get("error", "")) + self.assertEqual([name for name, _ in dispatched], ["terminal"]) + + # --------------------------------------------------------------------------- # _load_config # --------------------------------------------------------------------------- diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index 58240cc9cd84c..b5483a565c2da 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -41,18 +41,6 @@ "web_search", "web_extract", "read_file", "write_file", "search_files", "patch", "terminal", ]) - -def _resolve_sandbox_tools(enabled_tools: Optional[List[str]]) -> frozenset: - """Tri-state sandbox capability grant (#84271). - - ``None`` keeps the legacy default (every sandbox tool). An explicit list, - including empty, is the exact intersection with ``SANDBOX_ALLOWED_TOOLS`` - — empty or non-overlapping means deny-all. - """ - if enabled_tools is None: - return SANDBOX_ALLOWED_TOOLS - return frozenset(SANDBOX_ALLOWED_TOOLS & set(enabled_tools)) - # Resource limit defaults (overridable via config.yaml → code_execution.*) DEFAULT_TIMEOUT = 300 # 5 minutes DEFAULT_MAX_TOOL_CALLS = 50 @@ -160,9 +148,10 @@ def _missing_hermes_tools_import_hint(m, enabled_tools) -> str: "If that import failed, the generated module may be stale or another " "hermes_tools may be first on sys.path. Check hermes_tools.__file__ " "and retry with reset=true.") - available = sorted(SANDBOX_ALLOWED_TOOLS & set(enabled_tools or SANDBOX_ALLOWED_TOOLS)) + available = sorted(_sandbox_tools_for(enabled_tools)) + available_text = ", ".join(available) or "none" return (f"'{missing}' is not available inside the execute_code sandbox. " - f"Importable tools here: {', '.join(available)}. For anything " + f"Importable tools here: {available_text}. For anything " "else, use the normal tool call instead of execute_code.") From c15f8acbc11a1936848b05807b1d20d141d8533c Mon Sep 17 00:00:00 2001 From: Christopher <210261288+Christopher-Schulze@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:45:21 +0200 Subject: [PATCH 5/8] test(tools): keep sandbox boundary proof type-clean Add narrow casts and assertions around dynamically executed RPC stubs so the new authorization tests introduce no Ty diagnostics. --- tests/tools/test_code_execution.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/tests/tools/test_code_execution.py b/tests/tools/test_code_execution.py index ad22cfab3996c..ac81e9184f242 100644 --- a/tests/tools/test_code_execution.py +++ b/tests/tools/test_code_execution.py @@ -23,6 +23,7 @@ import socket import tempfile import time +from typing import Any, Callable, cast os.environ["TERMINAL_ENV"] = "local" @@ -773,6 +774,7 @@ class TestSandboxFailureHints(unittest.TestCase): def test_empty_grant_does_not_advertise_sandbox_tools(self): hint = _sandbox_failure_hint(self._IMPORT_ERROR, enabled_tools=[]) + assert hint is not None self.assertIn("Importable tools here: none.", hint) def test_partial_grant_advertises_only_the_intersection(self): @@ -781,6 +783,7 @@ def test_partial_grant_advertises_only_the_intersection(self): enabled_tools=["terminal", "vision_analyze"], ) + assert hint is not None self.assertIn("Importable tools here: terminal.", hint) self.assertNotIn("write_file", hint) @@ -898,12 +901,16 @@ def run_server(): ), namespace, ) + call = cast( + Callable[[str, dict[str, str]], dict[str, Any]], + namespace["_call"], + ) responses = [ - namespace["_call"](tool_name, args) + call(tool_name, args) for tool_name, args in requests ] finally: - client_sock = namespace.get("_sock") + client_sock = cast(Any, namespace.get("_sock")) if client_sock is not None: client_sock.close() stop_event.set() @@ -962,8 +969,12 @@ def run_poller(): ), namespace, ) + call = cast( + Callable[[str, dict[str, str]], dict[str, Any]], + namespace["_call"], + ) responses = [ - namespace["_call"](tool_name, args) + call(tool_name, args) for tool_name, args in requests ] finally: From 97956fd8d8eaea2bf22e833bbb7abc5031690220 Mon Sep 17 00:00:00 2001 From: Christopher <210261288+Christopher-Schulze@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:00:25 +0200 Subject: [PATCH 6/8] fix(agent): pin resumed prompt workspace context in CI Keep fresh-process prompt bytes stable when GitHub merge checkouts are detached and workspace metadata changes between builds. --- tests/agent/test_plugin_prompt_sections.py | 3 +++ tests/tools/test_code_execution.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/agent/test_plugin_prompt_sections.py b/tests/agent/test_plugin_prompt_sections.py index 7df718ca4e6f6..9871cf0c3ddbf 100644 --- a/tests/agent/test_plugin_prompt_sections.py +++ b/tests/agent/test_plugin_prompt_sections.py @@ -173,10 +173,13 @@ def render(_session_info): ) outputs = [] + child_cwd = tmp_path / "child-workspace" + child_cwd.mkdir() for phase in ("first", "resume"): env = os.environ.copy() env.update( HERMES_HOME=str(tmp_path / "hermes-home"), + TERMINAL_CWD=str(child_cwd), TEST_DB=str(db_path), TEST_CALLS=str(calls_path), TEST_PHASE=phase, diff --git a/tests/tools/test_code_execution.py b/tests/tools/test_code_execution.py index ac81e9184f242..9cbc26100fee9 100644 --- a/tests/tools/test_code_execution.py +++ b/tests/tools/test_code_execution.py @@ -853,7 +853,7 @@ def dispatch(function_name, function_args, task_id=None, user_task=None): return dispatch def _run_uds_calls(self, enabled_tools, requests): - from tools.code_execution_tool import _rpc_server_loop + from tools.code_execution_rpc import _rpc_server_loop allowed_tools = _sandbox_tools_for(enabled_tools) dispatched = [] From 9c075c868a8c260b76550eeb82352bf19fef6fd9 Mon Sep 17 00:00:00 2001 From: Christopher <210261288+Christopher-Schulze@users.noreply.github.com> Date: Wed, 9 Sep 2026 02:14:01 +0200 Subject: [PATCH 7/8] test(tools): keep Unix RPC proof independent of checkout path length --- tests/tools/test_code_execution.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/tools/test_code_execution.py b/tests/tools/test_code_execution.py index 9cbc26100fee9..4955c8d045149 100644 --- a/tests/tools/test_code_execution.py +++ b/tests/tools/test_code_execution.py @@ -861,11 +861,12 @@ def _run_uds_calls(self, enabled_tools, requests): tool_call_counter = [0] stop_event = threading.Event() - with tempfile.TemporaryDirectory(prefix="hermes-rpc-") as temp_dir: - socket_path = os.path.join(temp_dir, "rpc.sock") - server_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - server_sock.bind(socket_path) - server_sock.listen(1) + # A real Unix socket pair avoids sockaddr_un path limits in deeply + # nested CI/base checkouts while preserving the RPC authorization loop. + server_connection, client_socket = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM) + with server_connection, client_socket: + server_sock = MagicMock(spec=socket.socket) + server_sock.accept.return_value = (server_connection, ("peer", 0)) def run_server(): with patch( @@ -890,7 +891,6 @@ def run_server(): with patch.dict( os.environ, { - "HERMES_RPC_SOCKET": socket_path, "HERMES_RPC_TOKEN": self._RPC_TOKEN, }, ): @@ -901,6 +901,7 @@ def run_server(): ), namespace, ) + namespace["_sock"] = client_socket call = cast( Callable[[str, dict[str, str]], dict[str, Any]], namespace["_call"], From b327243fd5df45022160f957fbbc0fa893564c55 Mon Sep 17 00:00:00 2001 From: Christopher <210261288+Christopher-Schulze@users.noreply.github.com> Date: Wed, 9 Sep 2026 02:26:25 +0200 Subject: [PATCH 8/8] test(tools): type generated RPC module namespace explicitly --- tests/tools/test_code_execution.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/tools/test_code_execution.py b/tests/tools/test_code_execution.py index 4955c8d045149..48f4bac462f2e 100644 --- a/tests/tools/test_code_execution.py +++ b/tests/tools/test_code_execution.py @@ -886,7 +886,7 @@ def run_server(): server_thread = threading.Thread(target=run_server, daemon=True) server_thread.start() - namespace = {"__name__": "hermes_tools"} + namespace: dict[str, object] = {"__name__": "hermes_tools"} try: with patch.dict( os.environ,