Files
loom/backend/app/integrations/script_executor.py
T
2026-04-24 17:46:03 +08:00

151 lines
4.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""脚本执行器:subprocess 沙箱版。
Phase 5 简化实现:在 worker 进程内直接 subprocess 执行脚本字符串。
- 支持 python / shell / node
- 超时自动 kill
- 每行 stdout/stderr 作为一条 TaskLog
(Celery worker 版本为未来优化项;当前流程在 FastAPI 后台任务里执行即可。)
"""
from __future__ import annotations
import asyncio
import json
import shutil
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, AsyncIterator
def _resolve_lang_cmd() -> dict[str, list[str]]:
"""启动时探测各语言解释器。Python 直接用当前 venv 的解释器,不依赖 PATH。"""
node = shutil.which("node") or "node"
bash = shutil.which("bash") or "/bin/bash"
return {
"python": [sys.executable, "-u"], # -u 保证 stdout 无缓冲
"node": [node],
"shell": [bash],
}
LANG_CMD: dict[str, list[str]] = _resolve_lang_cmd()
@dataclass
class ScriptChunk:
level: str # INFO / ERROR
message: str
@dataclass
class ScriptResult:
returncode: int
stdout: str
stderr: str
status: str # SUCCEEDED / FAILED / TIMEOUT
output: dict[str, Any]
async def run_script(
*,
language: str,
code: str,
params: dict[str, Any],
timeout_seconds: int = 60,
on_chunk: callable | None = None,
) -> ScriptResult:
if language not in LANG_CMD:
raise ValueError(f"不支持的脚本语言: {language}")
with tempfile.TemporaryDirectory() as tmpdir:
p = Path(tmpdir)
ext = {"python": ".py", "node": ".js", "shell": ".sh"}[language]
script_path = p / f"entry{ext}"
script_path.write_text(code, encoding="utf-8")
# 通过环境变量 LOOM_PARAMS 传入参数(JSON),脚本可 json.loads 读
env = {"LOOM_PARAMS": json.dumps(params, ensure_ascii=False, default=str)}
cmd = LANG_CMD[language] + [str(script_path)]
import os as _os
proc = await asyncio.create_subprocess_exec(
*cmd,
cwd=str(p),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env={
**env,
"PATH": _os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin"),
},
)
stdout_chunks: list[str] = []
stderr_chunks: list[str] = []
async def _pipe(stream, is_err: bool) -> None:
if stream is None:
return
async for raw in stream:
try:
line = raw.decode("utf-8", errors="replace").rstrip("\n")
except Exception: # noqa: BLE001
line = str(raw)
(stderr_chunks if is_err else stdout_chunks).append(line)
if on_chunk:
await on_chunk(ScriptChunk(level=("ERROR" if is_err else "INFO"), message=line))
try:
await asyncio.wait_for(
asyncio.gather(_pipe(proc.stdout, False), _pipe(proc.stderr, True)),
timeout=timeout_seconds,
)
returncode = await proc.wait()
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
return ScriptResult(
returncode=-1,
stdout="\n".join(stdout_chunks),
stderr="\n".join(stderr_chunks) + "\n[timeout killed]",
status="TIMEOUT",
output={"timeout": True, "timeout_seconds": timeout_seconds},
)
status = "SUCCEEDED" if returncode == 0 else "FAILED"
return ScriptResult(
returncode=returncode,
stdout="\n".join(stdout_chunks),
stderr="\n".join(stderr_chunks),
status=status,
output={
"returncode": returncode,
"stdout_tail": "\n".join(stdout_chunks[-20:]),
},
)
async def stream_lines(
*, language: str, code: str, params: dict[str, Any], timeout_seconds: int = 60
) -> AsyncIterator[tuple[str, str]]:
"""供外部需要逐行消费时使用(当前 run_script 已支持 callback,这个保留扩展位)。"""
chunks: asyncio.Queue[tuple[str, str] | None] = asyncio.Queue()
async def _on_chunk(c: ScriptChunk) -> None:
await chunks.put((c.level, c.message))
task = asyncio.create_task(
run_script(
language=language, code=code, params=params,
timeout_seconds=timeout_seconds, on_chunk=_on_chunk,
)
)
task.add_done_callback(lambda _: chunks.put_nowait(None))
while True:
item = await chunks.get()
if item is None:
break
yield item
await task