diff --git a/src/agent_tools/subprocess_tools.py b/src/agent_tools/subprocess_tools.py index 5b620cf5f..27ae30886 100644 --- a/src/agent_tools/subprocess_tools.py +++ b/src/agent_tools/subprocess_tools.py @@ -231,6 +231,7 @@ def _wrap_workspace_namespace( cwd: str, *, chdir: str = "/workspace", + interpreter_prefix: str | None = None, ) -> str | None: """Run a shell command with the active workspace mounted at /workspace. @@ -254,8 +255,27 @@ def _wrap_workspace_namespace( "--dir", "/tmp", "--tmpfs", "/tmp", "--dev-bind", "/dev", "/dev", "--proc", "/proc", "--dir", "/workspace", "--bind", cwd, "/workspace", - "--chdir", chdir, "/bin/bash", "-lc", content, ] + # setup-python installs interpreters under /opt, and local CI virtualenvs + # can live under /tmp. Those paths are hidden by the private root/tmpfs. + # Expose only the active interpreter environment, read-only, so Python + # tools keep their installed packages without exposing the host /tmp. + if interpreter_prefix: + prefix = os.path.abspath(interpreter_prefix) + mounted_roots = ("/usr", "/home", "/mnt") + if os.path.isdir(prefix) and not any( + prefix == root or prefix.startswith(root + os.sep) + for root in mounted_roots + ): + parents = [] + parent = os.path.dirname(prefix) + while parent not in ("/", "/tmp", "/etc", "/workspace", *mounted_roots): + parents.append(parent) + parent = os.path.dirname(parent) + for directory in reversed(parents): + args.extend(("--dir", directory)) + args.extend(("--ro-bind", prefix, prefix)) + args.extend(("--chdir", chdir, "/bin/bash", "-lc", content)) return shlex.join(args) @@ -940,6 +960,7 @@ class PythonTool: python_command, agent_cwd(), chdir="/workspace", + interpreter_prefix=sys.prefix, ) if needs_virtual_namespace else None diff --git a/tests/test_tool_path_confinement.py b/tests/test_tool_path_confinement.py index be4a75162..d8e1400fc 100644 --- a/tests/test_tool_path_confinement.py +++ b/tests/test_tool_path_confinement.py @@ -304,7 +304,7 @@ async def test_write_file_dispatch_rejects_empty_directory_like_workspace_path(m security_context=NO_TOOL_SECURITY_CONTEXT, ) assert desc == "write_file: /workspace/papers" - assert "refusing to create an empty file at a directory-like path" in ( + assert "content required; refusing to create an empty file" in ( result.get("error") or "" ) assert result.get("exit_code") == 1 diff --git a/tests/test_workspace_artifact_tool_floor.py b/tests/test_workspace_artifact_tool_floor.py index 20a5295e2..1d65ea9e3 100644 --- a/tests/test_workspace_artifact_tool_floor.py +++ b/tests/test_workspace_artifact_tool_floor.py @@ -1037,14 +1037,14 @@ def test_visual_text_extraction_is_distinct_from_speech_transcription(): def test_local_media_routes_select_dedicated_ocr_for_visual_text(): + import re + source = (Path(__file__).parents[1] / "src" / "agent_loop.py").read_text() assert source.count( "_ocr_requested = _visual_text_extraction_requested(_last_user)" ) == 3 - assert source.count( - '{"extract_text"}\n if _ocr_requested' - ) == 3 + assert len(re.findall(r'\{"extract_text"\}\s*\n\s*if _ocr_requested', source)) == 3 def test_workspace_paths_split_on_chinese_list_punctuation(): @@ -1656,7 +1656,8 @@ def test_local_media_is_exempt_from_pure_web_schema_and_round_clamps(): pure_web_start = source.index(" _local_media_turn = bool(") pure_web_end = source.index("\n if (\n _pure_web_turn", pure_web_start) assert "and not _local_media_turn" in source[pure_web_start:pure_web_end] - assert source.count("if _pure_web_turn:") >= 3 + assert source.count("if _pure_web_turn:") == 2 + assert 'if _pure_web_turn and tool_surface != "full":' in source def test_empty_local_media_round_nudges_export_instead_of_ending(): @@ -2189,7 +2190,7 @@ def test_python_emits_one_final_bare_expression_without_duplicating_print(): assert explicit["output"] == "once" -def test_python_loaded_code_sees_virtual_workspace_alias(monkeypatch): +def test_python_loaded_code_sees_virtual_workspace_alias(monkeypatch, tmp_path): """Absolute /workspace paths must work inside generated Python scripts.""" import asyncio import shutil @@ -2201,7 +2202,7 @@ def test_python_loaded_code_sees_virtual_workspace_alias(monkeypatch): from src.agent_tools import subprocess_tools from src import tool_execution - workspace = Path("/home/pewds/odysseus-tool-work") + workspace = tmp_path script = workspace / ".python-workspace-alias-test.py" output = workspace / ".python-workspace-alias-test.txt" script.write_text( @@ -2209,13 +2210,11 @@ def test_python_loaded_code_sees_virtual_workspace_alias(monkeypatch): ) monkeypatch.setattr(tool_execution, "agent_cwd", lambda: str(workspace)) result = asyncio.run(subprocess_tools.PythonTool().execute( - f"import runpy; runpy.run_path('{script}', run_name='__main__')", + "import runpy; runpy.run_path('/workspace/.python-workspace-alias-test.py', run_name='__main__')", {}, )) assert result["exit_code"] == 0, result assert output.read_text() == "ok" - script.unlink() - output.unlink() def test_workspace_namespace_preserves_the_64_bit_dynamic_loader(monkeypatch):