""" Anhang A · Ausführungsumgebung für Computer Use ================================================ Nimmt {session_id, language, code} entgegen, führt den Code in einem persistenten Namensraum aus und gibt Text- und Bildelemente zurück. Vertrag laut OpenAI-Dokumentation: - gleiche session_id -> gleiche Umgebung UND gleiche Variablen - Bilder mit detail="original" - Aufrufer authentifizieren, Zeitlimit, Ressourcen begrenzen NICHT produktionsreif. Lehrbeispiel für eine abgeschottete Testumgebung. Siehe Sicherheitshinweis im Vorspann. """ from __future__ import annotations import base64 import io import os import queue import secrets import threading import time import traceback from dataclasses import dataclass, field from flask import Flask, jsonify, request # -------------------------------------------------------------------------- # Konfiguration über Umgebungsvariablen # -------------------------------------------------------------------------- AUTH_TOKEN = os.environ.get("SANDBOX_TOKEN", "") MAX_SECONDS = float(os.environ.get("SANDBOX_TIMEOUT", "60")) MAX_OUTPUT_CHARS = int(os.environ.get("SANDBOX_MAX_CHARS", "20000")) SESSION_TTL = float(os.environ.get("SANDBOX_SESSION_TTL", "3600")) app = Flask(__name__) # -------------------------------------------------------------------------- # Sitzungen: je session_id ein persistenter Namensraum + ein eigener Thread # -------------------------------------------------------------------------- @dataclass class Session: namespace: dict = field(default_factory=dict) lock: threading.Lock = field(default_factory=threading.Lock) last_used: float = field(default_factory=time.time) _sessions: dict[str, Session] = {} _sessions_lock = threading.Lock() def get_session(session_id: str) -> Session: with _sessions_lock: _reap_expired() s = _sessions.get(session_id) if s is None: s = Session() s.namespace.update(_build_namespace(s)) _sessions[session_id] = s s.last_used = time.time() return s def _reap_expired() -> None: now = time.time() for sid in [k for k, v in _sessions.items() if now - v.last_used > SESSION_TTL]: _sessions.pop(sid, None) # -------------------------------------------------------------------------- # Der Namensraum, den das Modell vorfindet. # Was hier NICHT drinsteht, kann das Modell nicht benutzen — und muss deshalb # auch nicht in der Tool-Beschreibung stehen (Kapitel 2.10, Schritt 1). # -------------------------------------------------------------------------- def _build_namespace(session: Session) -> dict: outbox: list[dict] = [] session.namespace["__outbox__"] = outbox def log(value) -> None: """Textausgabe an das Modell.""" outbox.append({"type": "text", "text": str(value)}) def display(image) -> None: """Bildausgabe an das Modell. Immer detail='original'.""" buf = io.BytesIO() image.save(buf, format="PNG") b64 = base64.b64encode(buf.getvalue()).decode("ascii") outbox.append( { "type": "image", "image_url": f"data:image/png;base64,{b64}", "detail": "original", } ) ns = {"log": log, "display": display, "__outbox__": outbox} # Bibliotheken erst hier importieren: fehlt eine, läuft der Rest weiter. for name in ("pyautogui", "pdfplumber", "time", "json", "re", "pathlib"): try: ns[name] = __import__(name) except Exception: # noqa: BLE001 - fehlende Bibliothek ist kein Absturz pass if "pyautogui" in ns: ns["pyautogui"].FAILSAFE = True # Maus in die Ecke = Notaus return ns # -------------------------------------------------------------------------- # Ausführung mit hartem Zeitlimit # -------------------------------------------------------------------------- def run_code(session: Session, code: str, timeout: float) -> tuple[list[dict], str | None]: result: queue.Queue = queue.Queue(maxsize=1) def target() -> None: try: exec(compile(code, "", "exec"), session.namespace) # noqa: S102 result.put((None,)) except Exception: # noqa: BLE001 - Fehler geht als Text zurück result.put((traceback.format_exc(limit=6),)) session.namespace["__outbox__"].clear() worker = threading.Thread(target=target, daemon=True) worker.start() try: (err,) = result.get(timeout=timeout) except queue.Empty: # Der Thread läuft weiter — deshalb ist die Sitzung danach unbrauchbar. return list(session.namespace["__outbox__"]), ( f"Zeitlimit von {timeout:.0f}s überschritten. " "Sitzung verwerfen und neu beginnen." ) return list(session.namespace["__outbox__"]), err # -------------------------------------------------------------------------- # HTTP-Schnittstelle # -------------------------------------------------------------------------- @app.post("/exec") def exec_endpoint(): if AUTH_TOKEN: sent = request.headers.get("Authorization", "") if not secrets.compare_digest(sent, f"Bearer {AUTH_TOKEN}"): return jsonify({"error": "nicht autorisiert"}), 401 body = request.get_json(silent=True) or {} session_id = body.get("session_id") language = body.get("language", "python") code = body.get("code") if not session_id or not isinstance(code, str): return jsonify({"error": "session_id und code sind erforderlich"}), 400 if language != "python": return jsonify({"error": f"Sprache nicht unterstützt: {language}"}), 400 session = get_session(session_id) if not session.lock.acquire(blocking=False): return jsonify({"error": "Sitzung ist beschäftigt"}), 409 try: items, err = run_code(session, code, MAX_SECONDS) finally: session.lock.release() if err: items.append({"type": "text", "text": f"FEHLER:\n{err}"}) # Textmenge deckeln: ein versehentliches print(riesige_liste) sonst # sprengt den Kontext und damit die 272K-Schwelle (Kapitel 2.5, Falle 3). total = 0 trimmed: list[dict] = [] for item in items: if item["type"] == "text": room = MAX_OUTPUT_CHARS - total if room <= 0: trimmed.append({"type": "text", "text": "[…gekürzt…]"}) break item = {"type": "text", "text": item["text"][:room]} total += len(item["text"]) trimmed.append(item) return jsonify({"items": trimmed}) @app.get("/health") def health(): return jsonify({"ok": True, "sessions": len(_sessions)}) if __name__ == "__main__": app.run(host="127.0.0.1", port=8000)