From ff3f3211206d14ce46470748dc7e771aaee6c6a0 Mon Sep 17 00:00:00 2001 From: Hyu Date: Wed, 30 Sep 2026 18:44:16 +0800 Subject: [PATCH] fix(tools): resolve the sandbox interpreter for workspace file scripts The workspace file tools (read/write/edit/glob/grep) ran a script through `python`, which a sandbox built from a host rootfs may not provide: those hosts ship `python3` only, and a read-only /usr prevents creating a `python` shim inside the jail. Every file tool then failed with `/bin/sh: 1: python: not found`. Resolve the interpreter with `command -v python3 || command -v python`, matching the interpreter the Box service already uses for its own scripts. --- .../pkg/provider/tools/loaders/native.py | 9 +++++- .../provider/test_tool_manager_native.py | 32 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/langbot/pkg/provider/tools/loaders/native.py b/src/langbot/pkg/provider/tools/loaders/native.py index 602c22f62..89a4de0b8 100644 --- a/src/langbot/pkg/provider/tools/loaders/native.py +++ b/src/langbot/pkg/provider/tools/loaders/native.py @@ -754,9 +754,16 @@ class NativeToolLoader(loader.ToolLoader): return f'{str(base).rstrip("/")}/{relative}' async def _run_workspace_file_script(self, script: str, query: pipeline_query.Query) -> dict: + # Sandbox images built from a host rootfs may only ship `python3`, and a + # read-only /usr prevents creating a `python` shim inside the jail, so + # resolve the interpreter instead of assuming its name. + command = ( + 'PYTHON_BIN=$(command -v python3 || command -v python); ' + f"\"$PYTHON_BIN\" - <<'PY'\n{script}\nPY" + ) result = await self.ap.box_service.execute_tool( { - 'command': f"python - <<'PY'\n{script}\nPY", + 'command': command, 'timeout_sec': 30, }, query, diff --git a/tests/unit_tests/provider/test_tool_manager_native.py b/tests/unit_tests/provider/test_tool_manager_native.py index 7dd09f32f..1940eaa7d 100644 --- a/tests/unit_tests/provider/test_tool_manager_native.py +++ b/tests/unit_tests/provider/test_tool_manager_native.py @@ -851,3 +851,35 @@ async def test_box_grep_script_serializes_optional_include_as_python(monkeypatch values[statement.targets[0].id] = ast.literal_eval(statement.value) assert values['include'] == include assert values['path'] == '/workspace' + + +@pytest.mark.asyncio +async def test_workspace_file_script_resolves_the_sandbox_interpreter(): + """Sandboxes built from a host rootfs may only ship `python3`.""" + + captured: dict = {} + + async def execute_tool(payload, query): + captured.update(payload) + return {'ok': True, 'stdout': '{"ok": true}'} + + box_service = SimpleNamespace( + available=True, + execute_tool=execute_tool, + require_workspace_sandbox=AsyncMock(), + ) + loader = NativeToolLoader(SimpleNamespace(box_service=box_service, logger=Mock())) + query = SimpleNamespace( + _execution_context=_CONTEXT, + bot_uuid=None, + pipeline_uuid=None, + query_uuid=None, + ) + query._box_binding = RunBoxBinding('run', 'box', {}, 'run') + + result = await loader._run_workspace_file_script('print("x")', query) + + assert result == {'ok': True} + command = captured['command'] + assert command.startswith('PYTHON_BIN=$(command -v python3 || command -v python)') + assert command.endswith(" - <<'PY'\nprint(\"x\")\nPY")