ReMe/reme/steps/common/python_execute.py
xyf2020 2767836b21
feat(sandbox): Add Home AutoBash plugin and path-scoped execution sandboxes (#603)
* Add sandboxed Python execution and home AutoBash plugin

* Harden read-only shell commands and filesystem sandboxes

* Group file sandbox backends in one package

* Fix macOS file sandbox isolation and runtime access

* Refine file sandbox limits and executor checks

* Harden AutoBash policy and macOS sandbox supervision

* fix(home): require ReMe release with file sandbox

* refactor(home): simplify sandbox calls and redundant tests

* fix(execution): bound output and clean up cancelled sandbox startup

* fix(tests): mock Seatbelt platform requirements on Linux

* fix(home): block Git help helpers and harden sandbox cleanup

* Fix sandbox runtime links and subprocess cleanup

* ci: cover Home plugin tests

* test: wait for pipeline descendants to exit after cancellation

* fix: align Home dependencies and preserve cleanup cancellation

Require ReMe 0.4.2.1 and verify isolated Home wheel installation in package CI. Share cancellation-safe cleanup with thread calls, retain AnyIO cancellation semantics, and record Bubblewrap mount targets during argument construction.
2026-10-27 18:08:26 +08:00

75 lines
2.9 KiB
Python

"""Execute Python code and return printed stdout."""
import sys
from typing import Any
from ...utils.local_backend import LocalProcessBackend, ProcessTimeout
from ..base_step import BaseStep
from ...components import R
from .file_sandbox import file_sandbox_for_step
DEFAULT_TIMEOUT = 60.0
@R.register("python_execute_step")
class PythonExecuteStep(BaseStep):
"""Run Python code and return stdout, limiting each output stream to 1 MiB."""
async def execute(self):
assert self.context is not None
code = self.context.get("code", "")
timeout, timeout_error = self._parse_timeout(
self.context.get("python_timeout", self.context.get("timeout", DEFAULT_TIMEOUT)),
)
if not isinstance(code, str) or not code.strip():
self.context.response.success = False
self.context.response.answer = "code is required"
return self.context.response
if timeout_error:
self.context.response.success = False
self.context.response.answer = timeout_error
return self.context.response
timed_out = False
try:
backend = file_sandbox_for_step(self)
result = await (backend or LocalProcessBackend()).exec_shell(
[sys.executable, "-c", code],
cwd=str(self.workspace_path),
timeout=timeout,
**({"raise_errors": True} if backend is None else {}),
)
except ProcessTimeout as exc:
result, timed_out = exc.result, True
except (TypeError, ValueError, OSError, RuntimeError) as exc:
self.context.response.success = False
self.context.response.answer = str(exc)
return self.context.response
timed_out = timed_out or (result.exit_code == -1 and result.stderr == b"timed out")
stdout = result.stdout.decode(errors="replace")
stderr = result.stderr.decode(errors="replace")
if timed_out:
self.context.response.success = False
self.context.response.answer = f"Python execution timed out after {timeout:g}s"
else:
self.context.response.success = result.exit_code == 0
self.context.response.answer = stdout if stdout or result.exit_code == 0 else stderr
self.context.response.metadata.update(
{
"returncode": result.exit_code,
"stderr": stderr,
"python_timeout": timeout,
},
)
return self.context.response
@staticmethod
def _parse_timeout(raw: Any) -> tuple[float, str]:
try:
timeout = float(raw)
except (TypeError, ValueError):
return DEFAULT_TIMEOUT, "python_timeout must be a positive number"
if timeout <= 0:
return DEFAULT_TIMEOUT, "python_timeout must be a positive number"
return timeout, ""