Files
tai/tai_pkg/sandbox.py
T
retoor 8df5cf7a45 Split monolithic tai.py into tai_pkg package, keep tai-orig.py as original
tai.py (8643 lines) is now a thin facade over tai_pkg/ (33 feature modules, one class or feature per file). tai-orig.py preserves the pre-split single-file version byte for byte (sha256 verified against snapshot); import tai and ./tai.py behavior unchanged.

Equivalence proof, all run on this tree immediately pre-commit: differential battery tai-orig.py vs facade 3718 checks 0 failures (surface, pure fuzz, tool dispatch, store/audit, release ops, fake-HOME install roundtrip, CLI runs); test_tai.py 261 OK; test_seal.py 16 OK; --version/--help byte-identical; dir(tai) zero removals.

Fix included: opencode wire-protocol schema echo leaked into replies; opencode_parse now scans with JSONDecoder.raw_decode capped at 200 scans (13 OpencodeParseTests). Test count 261 = 237 HEAD + 8 pre-existing uncommitted ToolArgumentsTests (now committed with this change, disclosed) + 13 OpencodeParseTests + 3 EmbeddedStringsTests. Patch-target-only test migration (24 hunks), zero assertion changes. tai_pkg clean-regenerated after mid-verification tamper was found and reverted.
2026-10-10 19:25:02 +02:00

150 lines
7.1 KiB
Python

"""Container sandbox box and in-box command execution."""
import os
import shutil
import subprocess
import sys
import tempfile
from tai_pkg.consts import Consts
from tai_pkg.errors import BackendError
from tai_pkg.opencode import Opencode
from tai_pkg.render import Ansi
from tai_pkg.render import Render
from tai_pkg.text import Text
__all__ = ["Sandbox", "_BOX_PYTHON_CACHE", "_BOX_OPENCODE_CACHE"]
class Sandbox:
"""Sandbox."""
@staticmethod
def container_engine():
if shutil.which("podman"):
return "podman"
if shutil.which("docker"):
return "docker"
return ""
@staticmethod
def box_state(engine):
try:
done = subprocess.run([engine, "inspect", "-f", "{{.State.Running}}", Consts.BOX_NAME], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=15)
except (OSError, subprocess.SubprocessError):
return "missing"
if done.returncode != 0:
return "missing"
return "running" if done.stdout.strip() == "true" else "stopped"
@staticmethod
def ensure_box():
engine = Sandbox.container_engine()
if not engine:
raise BackendError("no container engine found, install podman")
state = Sandbox.box_state(engine)
if state == "running":
return engine
if state == "stopped":
done = subprocess.run([engine, "start", Consts.BOX_NAME], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=60)
if done.returncode != 0:
raise BackendError("cannot start box: " + (done.stderr or "").strip()[:200])
return engine
print("building sandbox image %s (one-time, takes minutes)..." % Consts.BOX_IMAGE)
with tempfile.TemporaryDirectory() as build:
for filename, content in (("Containerfile", Consts.BOX_CONTAINERFILE), ("stt.py", Consts.BOX_STT), ("tts.py", Consts.BOX_TTS)):
with open(os.path.join(build, filename), "w", encoding="utf-8") as handle:
handle.write(content)
done = subprocess.run([engine, "build", "-t", Consts.BOX_IMAGE, build], timeout=1800)
if done.returncode != 0:
raise BackendError("box image build failed")
done = subprocess.run([engine, "run", "-d", "--name", Consts.BOX_NAME, "--restart", "unless-stopped", Consts.BOX_IMAGE], stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, timeout=120)
if done.returncode != 0:
raise BackendError("cannot start box: " + (done.stderr or "").strip()[:200])
return engine
@staticmethod
def box_exec(engine, argv, extra=(), input_data=None, timeout=180):
return subprocess.run([engine, "exec", *extra, "-i", Consts.BOX_NAME, *argv], input=input_data, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout)
@staticmethod
def box_python(engine):
if engine not in _BOX_PYTHON_CACHE:
try:
probe = Sandbox.box_exec(engine, ["test", "-x", Consts.BOX_PYTHON], timeout=30)
_BOX_PYTHON_CACHE[engine] = Consts.BOX_PYTHON if probe.returncode == 0 else "python3"
except (OSError, subprocess.SubprocessError):
_BOX_PYTHON_CACHE[engine] = "python3"
return _BOX_PYTHON_CACHE[engine]
@staticmethod
def box_transcribe(engine, audio, timeout=300):
done = Sandbox.box_exec(engine, [Sandbox.box_python(engine), "/box/stt.py"], input_data=audio, timeout=timeout)
if done.returncode != 0:
raise BackendError("box stt failed: " + done.stderr.decode("utf-8", "replace")[:300])
return done.stdout.decode("utf-8", "replace").strip()
@staticmethod
def box_speak(engine, text, voice=""):
argv = [Sandbox.box_python(engine), "/box/tts.py"] + ([voice] if voice else [])
done = Sandbox.box_exec(engine, argv, input_data=text.encode("utf-8"), timeout=180)
if done.returncode != 0 or not done.stdout:
raise BackendError("box tts failed: " + done.stderr.decode("utf-8", "replace")[:300])
return done.stdout
@staticmethod
def box_opencode_binary(engine):
"""True once the opencode CLI is confirmed present inside the sandbox
box, installing Node.js + `npm install -g opencode-ai` into the box
first if it isn't there yet. Only ever called as a last resort (see
ChatClient.all_candidates()) and only against a box that is ALREADY
running -- this never creates the box itself; that stays the explicit,
approval-gated action it already is (ensure_box(), via --env sandbox or
the install tool). Cached per engine for the process lifetime so normal
turns that succeed through a real backend never pay this cost twice."""
if engine in _BOX_OPENCODE_CACHE:
return _BOX_OPENCODE_CACHE[engine]
try:
probe = Sandbox.box_exec(engine, ["which", "opencode"], timeout=10)
except (OSError, subprocess.SubprocessError):
_BOX_OPENCODE_CACHE[engine] = False
return False
if probe.returncode == 0:
_BOX_OPENCODE_CACHE[engine] = True
return True
print(Render.paint(
"opencode: no local CLI on this machine; installing it into the existing sandbox "
"box (container '%s') instead, as a last-resort backend. Nothing outside that "
"container is touched. Streaming the install below:" % Consts.BOX_NAME, Ansi.DIM
), file=sys.stderr)
try:
# Deliberately not box_exec(): this one streams straight to the
# real stdout/stderr instead of capturing, so the person watching
# sees the actual apt/curl/npm output live, not a silent pause.
done = subprocess.run([engine, "exec", Consts.BOX_NAME, "sh", "-c", Consts.BOX_OPENCODE_INSTALL_SCRIPT], timeout=600)
except (OSError, subprocess.SubprocessError) as exc:
print(Render.paint("opencode box install failed: %s" % Text.short_error(exc), Ansi.DIM), file=sys.stderr)
_BOX_OPENCODE_CACHE[engine] = False
return False
ok = done.returncode == 0
print(Render.paint("opencode box install %s" % ("succeeded" if ok else "failed (exit %d)" % done.returncode), Ansi.DIM), file=sys.stderr)
_BOX_OPENCODE_CACHE[engine] = ok
return ok
@staticmethod
def box_opencode_run(engine, model, prompt, timeout):
argv = ["opencode", "run", "--pure", "--agent", Consts.OPENCODE_AGENT, "--format", "json", "-m", "opencode/" + model]
try:
done = Sandbox.box_exec(engine, argv, input_data=prompt.encode("utf-8"), timeout=timeout)
except subprocess.TimeoutExpired:
raise BackendError("opencode (box) run timed out after %ds" % timeout)
except (OSError, subprocess.SubprocessError) as exc:
raise BackendError("opencode (box) run failed: %s" % Text.short_error(exc))
return Opencode.opencode_parse_events(done.stdout.decode("utf-8", "replace"), done.returncode, done.stderr.decode("utf-8", "replace"))
_BOX_PYTHON_CACHE = {}
_BOX_OPENCODE_CACHE = {}