e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
355 lines
14 KiB
Python
355 lines
14 KiB
Python
"""Sandbox runner sidecar HTTP server (standard-library only).
|
|
|
|
This process runs *inside* the dedicated ``sandbox-runner`` container and is the
|
|
only place where untrusted shell commands are actually executed. The main app
|
|
never runs them itself; it submits work here over HTTP
|
|
(:class:`deeptutor.services.sandbox.backends.RunnerSidecarBackend`).
|
|
|
|
Design constraints:
|
|
* No third-party deps (no FastAPI/Flask): the runner image must stay tiny and
|
|
free of heavy frameworks. We use :mod:`http.server` directly.
|
|
* Defence in depth: the container already drops privileges (non-root
|
|
``runner`` user, ``cap_drop: ALL``, ``no-new-privileges``, read-only rootfs
|
|
— see ``Dockerfile.runner`` / ``docker-compose.yml``). On top of that we
|
|
apply per-command resource limits via :func:`resource.setrlimit`.
|
|
|
|
Wire contract (must match ``RunnerSidecarBackend``):
|
|
|
|
``GET /health`` -> 200, any body, means alive.
|
|
``POST /exec`` -> request/response JSON described by the dataclasses in
|
|
:mod:`deeptutor.services.sandbox.spec`. Request::
|
|
|
|
{
|
|
"command": "str",
|
|
"workdir": "str | null", # path inside the container
|
|
"env": {"K": "V"},
|
|
"mounts": [{"host_path": "...", # informational only (see below)
|
|
"sandbox_path": "...",
|
|
"read_only": true}],
|
|
"limits": {"timeout_s": 30, "memory_mb": 512,
|
|
"cpu_seconds": 30, "max_output_chars": 10000}
|
|
}
|
|
|
|
Response::
|
|
|
|
{"stdout": "...", "stderr": "...", "exit_code": 0,
|
|
"timed_out": false, "error": ""}
|
|
|
|
``error`` is non-empty *only* when the runner itself failed (bad JSON,
|
|
spawn error, ...), never merely because the command exited non-zero.
|
|
|
|
Mounts note:
|
|
This server does **not** perform any mounting. The runner container shares
|
|
the task-workspace subtrees with the main app at the *same* paths
|
|
(``/app/data/user/workspace`` for the admin scope, ``/app/data/users`` for
|
|
per-user scopes — via docker-compose). So when ``host_path == sandbox_path``
|
|
the directory is already visible here and no action is needed. We only
|
|
read/record the ``mounts`` field; what is visible is decided by the compose
|
|
volume layout, and ``workdir`` is validated against the same roots
|
|
(``DEEPTUTOR_RUNNER_ALLOWED_WORKDIRS``) as defence in depth.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
import json
|
|
import os
|
|
import resource
|
|
import subprocess
|
|
import sys
|
|
import traceback
|
|
from typing import Any
|
|
|
|
# Port to listen on inside the container; overridable for local testing.
|
|
DEFAULT_PORT = 8900
|
|
|
|
# Hard cap on the request body we are willing to read, to avoid a hostile or
|
|
# buggy caller exhausting memory before we even parse the command.
|
|
_MAX_REQUEST_BYTES = 4 * 1024 * 1024
|
|
|
|
# Fallback ceilings used when the caller omits a limit (mirrors
|
|
# ResourceLimits defaults in spec.py).
|
|
_DEFAULT_TIMEOUT_S = 30
|
|
_DEFAULT_MEMORY_MB = 512
|
|
_DEFAULT_CPU_SECONDS = 30
|
|
_DEFAULT_MAX_OUTPUT_CHARS = 10_000
|
|
|
|
# Generous file-descriptor ceiling: high enough for normal tooling (git, build
|
|
# steps), low enough to bound a runaway fd leak.
|
|
_RLIMIT_NOFILE = 4096
|
|
|
|
# POSIX-only: setrlimit / preexec_fn are not available on Windows. The runner
|
|
# always ships in a Linux container, but guard so the module stays importable
|
|
# (e.g. for syntax checks / unit tests) on any platform.
|
|
_POSIX = os.name == "posix"
|
|
|
|
|
|
def _truncate_head_tail(text: str, max_chars: int) -> str:
|
|
"""Cap *text* to *max_chars*, keeping the head and tail (eliding the middle).
|
|
|
|
Matches the head+tail style used by ``ExecResult.render`` so the most
|
|
useful context (start of output and final error lines) survives.
|
|
"""
|
|
if max_chars <= 0 or len(text) <= max_chars:
|
|
return text
|
|
half = max_chars // 2
|
|
dropped = len(text) - max_chars
|
|
return text[:half] + f"\n\n... ({dropped:,} chars truncated) ...\n\n" + text[-half:]
|
|
|
|
|
|
def _build_preexec_fn(memory_mb: int, cpu_seconds: int):
|
|
"""Return a ``preexec_fn`` that applies rlimits in the forked child (POSIX).
|
|
|
|
The closure runs after ``fork`` and before ``exec`` in the child process,
|
|
so the limits apply to the command and everything it spawns. Returns
|
|
``None`` on non-POSIX platforms (no rlimit support there).
|
|
|
|
Notes on portability:
|
|
* ``RLIMIT_AS`` (address space) is the most portable memory cap but it
|
|
bounds *virtual* memory, not RSS. Some runtimes (notably the JVM, and
|
|
occasionally glibc/threaded allocators) reserve large virtual ranges
|
|
and may fail under a tight ``RLIMIT_AS`` even with low real usage. The
|
|
compose ``mem_limit`` (cgroup-enforced RSS) is the authoritative
|
|
backstop; this rlimit is a cheap secondary guard.
|
|
* ``RLIMIT_CPU`` counts CPU seconds, not wall-clock; wall-clock is
|
|
enforced separately via ``subprocess`` ``timeout``.
|
|
"""
|
|
if not _POSIX:
|
|
return None
|
|
|
|
def _apply() -> None:
|
|
# Address space (bytes). Cap virtual memory as a secondary guard.
|
|
if memory_mb > 0:
|
|
mem_bytes = memory_mb * 1024 * 1024
|
|
try:
|
|
resource.setrlimit(resource.RLIMIT_AS, (mem_bytes, mem_bytes))
|
|
except (ValueError, OSError):
|
|
pass
|
|
# CPU time (seconds). SIGXCPU/SIGKILL the child if it burns this much CPU.
|
|
if cpu_seconds > 0:
|
|
try:
|
|
resource.setrlimit(resource.RLIMIT_CPU, (cpu_seconds, cpu_seconds))
|
|
except (ValueError, OSError):
|
|
pass
|
|
# Open file descriptors.
|
|
try:
|
|
resource.setrlimit(resource.RLIMIT_NOFILE, (_RLIMIT_NOFILE, _RLIMIT_NOFILE))
|
|
except (ValueError, OSError):
|
|
pass
|
|
|
|
return _apply
|
|
|
|
|
|
# Workdirs must stay inside the shared workspace volumes (defence in depth:
|
|
# the app only ever sends task-workspace paths; a request outside them means
|
|
# a bug or a forged request). Colon-separated, overridable per deployment.
|
|
_ALLOWED_WORKDIR_ROOTS = [
|
|
root
|
|
for root in os.environ.get(
|
|
"DEEPTUTOR_RUNNER_ALLOWED_WORKDIRS",
|
|
"/app/data/user/workspace:/app/data/users",
|
|
).split(":")
|
|
if root
|
|
]
|
|
|
|
|
|
def _workdir_violation(workdir: str) -> str:
|
|
"""Return a rejection reason, or '' when *workdir* is acceptable."""
|
|
resolved = os.path.realpath(workdir)
|
|
for root in _ALLOWED_WORKDIR_ROOTS:
|
|
root_real = os.path.realpath(root)
|
|
if resolved == root_real or resolved.startswith(root_real + os.sep):
|
|
return ""
|
|
return (
|
|
f"workdir {workdir!r} is outside the shared workspace roots "
|
|
f"({':'.join(_ALLOWED_WORKDIR_ROOTS)}); refusing to execute"
|
|
)
|
|
|
|
|
|
def execute(payload: dict[str, Any]) -> dict[str, Any]:
|
|
"""Run one command described by *payload* and return the response dict.
|
|
|
|
Never raises for command-level failures (those land in ``exit_code`` /
|
|
``stderr``); only the runner's own failures populate ``error``.
|
|
"""
|
|
command = payload.get("command")
|
|
if not isinstance(command, str) or not command:
|
|
return _error_result("missing or empty 'command'")
|
|
|
|
workdir = payload.get("workdir") or None
|
|
if workdir is not None and not isinstance(workdir, str):
|
|
return _error_result("'workdir' must be a string or null")
|
|
if workdir is not None:
|
|
reason = _workdir_violation(workdir)
|
|
if reason:
|
|
return _error_result(reason)
|
|
|
|
# Build the child environment. The caller's env fully replaces ours except
|
|
# for PATH, which we always provide so basic tooling resolves even if the
|
|
# caller sends an empty env.
|
|
raw_env = payload.get("env") or {}
|
|
if not isinstance(raw_env, dict):
|
|
return _error_result("'env' must be an object")
|
|
env: dict[str, str] = {"PATH": os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin")}
|
|
for key, value in raw_env.items():
|
|
env[str(key)] = str(value)
|
|
|
|
# Mounts are informational here — the compose volume layout does the real
|
|
# work (see module docstring). We only validate the shape so a malformed
|
|
# request fails loudly rather than silently.
|
|
mounts = payload.get("mounts") or []
|
|
if not isinstance(mounts, list):
|
|
return _error_result("'mounts' must be a list")
|
|
|
|
limits = payload.get("limits") or {}
|
|
if not isinstance(limits, dict):
|
|
return _error_result("'limits' must be an object")
|
|
timeout_s = _int(limits.get("timeout_s"), _DEFAULT_TIMEOUT_S)
|
|
memory_mb = _int(limits.get("memory_mb"), _DEFAULT_MEMORY_MB)
|
|
cpu_seconds = _int(limits.get("cpu_seconds"), _DEFAULT_CPU_SECONDS)
|
|
max_output_chars = _int(limits.get("max_output_chars"), _DEFAULT_MAX_OUTPUT_CHARS)
|
|
|
|
preexec_fn = _build_preexec_fn(memory_mb, cpu_seconds)
|
|
|
|
try:
|
|
completed = subprocess.run( # noqa: S602 - shell=True is the contract
|
|
command,
|
|
shell=True, # nosec B602 — the runner exists to execute shell commands in-sandbox
|
|
cwd=workdir,
|
|
env=env,
|
|
timeout=timeout_s,
|
|
capture_output=True,
|
|
text=True,
|
|
preexec_fn=preexec_fn, # POSIX-only; None elsewhere
|
|
)
|
|
except subprocess.TimeoutExpired as exc:
|
|
# Surface whatever was captured before the kill, head+tail capped.
|
|
stdout = _decode(exc.stdout)
|
|
stderr = _decode(exc.stderr)
|
|
return {
|
|
"stdout": _truncate_head_tail(stdout, max_output_chars),
|
|
"stderr": _truncate_head_tail(stderr, max_output_chars),
|
|
"exit_code": 124, # conventional "timed out" exit status
|
|
"timed_out": True,
|
|
"error": "",
|
|
}
|
|
except (OSError, ValueError) as exc:
|
|
# Spawn failure (bad cwd, exec error, ...) — a runner-level problem.
|
|
return _error_result(f"{type(exc).__name__}: {exc}")
|
|
|
|
return {
|
|
"stdout": _truncate_head_tail(completed.stdout or "", max_output_chars),
|
|
"stderr": _truncate_head_tail(completed.stderr or "", max_output_chars),
|
|
"exit_code": completed.returncode,
|
|
"timed_out": False,
|
|
"error": "",
|
|
}
|
|
|
|
|
|
def _decode(value: Any) -> str:
|
|
"""Coerce captured stream output (str | bytes | None) to str."""
|
|
if value is None:
|
|
return ""
|
|
if isinstance(value, bytes):
|
|
return value.decode("utf-8", errors="replace")
|
|
return str(value)
|
|
|
|
|
|
def _int(value: Any, default: int) -> int:
|
|
"""Best-effort int coercion with a fallback (never raises)."""
|
|
try:
|
|
result = int(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
return result if result > 0 else default
|
|
|
|
|
|
def _error_result(message: str) -> dict[str, Any]:
|
|
"""Build a response where only the runner-level ``error`` field is set."""
|
|
return {
|
|
"stdout": "",
|
|
"stderr": "",
|
|
"exit_code": 0,
|
|
"timed_out": False,
|
|
"error": message,
|
|
}
|
|
|
|
|
|
class _Handler(BaseHTTPRequestHandler):
|
|
"""Minimal request router for ``GET /health`` and ``POST /exec``."""
|
|
|
|
# Quiet the default per-request stderr logging; keep it terse and on stdout
|
|
# so container logs stay readable.
|
|
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
|
|
sys.stdout.write("runner: " + (format % args) + "\n")
|
|
|
|
def _send_json(self, status: int, body: dict[str, Any]) -> None:
|
|
data = json.dumps(body).encode("utf-8")
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(data)))
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
|
|
def do_GET(self) -> None: # noqa: N802 - http.server naming
|
|
if self.path.rstrip("/") == "/health" or self.path == "/":
|
|
body = b"ok"
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "text/plain")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
return
|
|
self._send_json(404, _error_result("not found"))
|
|
|
|
def do_POST(self) -> None: # noqa: N802 - http.server naming
|
|
if self.path.rstrip("/") != "/exec":
|
|
self._send_json(404, _error_result("not found"))
|
|
return
|
|
try:
|
|
length = int(self.headers.get("Content-Length", "0"))
|
|
except ValueError:
|
|
self._send_json(400, _error_result("invalid Content-Length"))
|
|
return
|
|
if length > _MAX_REQUEST_BYTES:
|
|
self._send_json(413, _error_result("request body too large"))
|
|
return
|
|
try:
|
|
raw = self.rfile.read(length) if length > 0 else b""
|
|
payload = json.loads(raw.decode("utf-8")) if raw else {}
|
|
if not isinstance(payload, dict):
|
|
raise ValueError("request body must be a JSON object")
|
|
except (ValueError, UnicodeDecodeError) as exc:
|
|
self._send_json(400, _error_result(f"invalid JSON: {exc}"))
|
|
return
|
|
|
|
try:
|
|
result = execute(payload)
|
|
except Exception as exc: # noqa: BLE001 - last-resort guard
|
|
# Any unexpected runner crash becomes a clean error response rather
|
|
# than a dropped connection, so the client degrades gracefully.
|
|
traceback.print_exc()
|
|
result = _error_result(f"runner crashed: {type(exc).__name__}: {exc}")
|
|
self._send_json(200, result)
|
|
|
|
|
|
def main() -> None:
|
|
"""Start the threaded HTTP server, binding 0.0.0.0:$RUNNER_PORT."""
|
|
try:
|
|
port = int(os.environ.get("RUNNER_PORT", "") or DEFAULT_PORT)
|
|
except ValueError:
|
|
port = DEFAULT_PORT
|
|
server = ThreadingHTTPServer(("0.0.0.0", port), _Handler)
|
|
sys.stdout.write(f"runner: listening on 0.0.0.0:{port}\n")
|
|
sys.stdout.flush()
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
server.server_close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|