devilts/sandbox-e4bc4bbc
0
1"""Minimal FastAPI server for sandbox operations."""2import os, subprocess, pathlib, signal, threading, re, tempfile3from fastapi import FastAPI4from pydantic import BaseModel5from typing import Optional6import uvicorn7 8_ANSI_RE = re.compile(r'\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?\x07')9 10def _strip_ansi(text: str) -> str:11 return _ANSI_RE.sub('', text)12 13def _truncate_output(output: str, max_chars: int = 25000, head_ratio: float = 0.25) -> str:14 if len(output) <= max_chars:15 return output16 # Write full output to temp file so LLM can read specific sections17 spill_path = None18 try:19 with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', prefix='bash_output_', dir='/tmp', delete=False) as f:20 f.write(output)21 spill_path = f.name22 except Exception:23 pass24 head_budget = int(max_chars * head_ratio)25 tail_budget = max_chars - head_budget26 head = output[:head_budget]27 tail = output[-tail_budget:]28 total = len(output)29 omitted = total - max_chars30 meta = f"\n\n... ({omitted:,} of {total:,} chars omitted, showing first {head_budget:,} + last {tail_budget:,}) ...\n"31 if spill_path:32 meta += f"Full output saved to {spill_path} — use the read tool with offset/limit to inspect specific sections.\n"33 return head + meta + tail34 35def _atomic_write(path: pathlib.Path, content: str):36 """Write atomically: temp file + fsync + os.replace."""37 path.parent.mkdir(parents=True, exist_ok=True)38 fd = None39 tmp_path = None40 try:41 fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")42 os.write(fd, content.encode("utf-8"))43 os.fsync(fd)44 os.close(fd)45 fd = None46 os.replace(tmp_path, str(path))47 tmp_path = None48 finally:49 if fd is not None:50 os.close(fd)51 if tmp_path is not None:52 try:53 os.unlink(tmp_path)54 except OSError:55 pass56 57app = FastAPI()58 59# Track active bash processes so they can be killed on cancel60_active_procs = {} # pid -> subprocess.Popen61_proc_lock = threading.Lock()62 63class BashReq(BaseModel):64 command: str65 work_dir: str = "/app"66 timeout: int = 12067 68class ReadReq(BaseModel):69 path: str70 offset: Optional[int] = None71 limit: Optional[int] = 200072 73class WriteReq(BaseModel):74 path: str75 content: str76 77class EditReq(BaseModel):78 path: str79 old_str: str80 new_str: str81 replace_all: bool = False82 mode: str = "replace"83 84class ExistsReq(BaseModel):85 path: str86 87# ── Fuzzy matching & edit utilities (embedded) ──88 89UNICODE_MAP = {90 "\u2013": "-", "\u2014": "-", "\u2212": "-",91 "\u2018": "'", "\u2019": "'",92 "\u201c": '"', "\u201d": '"',93 "\u00a0": " ", "\u2003": " ", "\u2002": " ",94 "\u200b": "", "\ufeff": "",95}96 97def _normalize_unicode(s):98 return "".join(UNICODE_MAP.get(c, c) for c in s)99 100def _fuzzy_find_original(content, pattern):101 """Find the original text in content that matches pattern fuzzily."""102 if pattern in content:103 return pattern, None104 # Pass 2: right-trim105 c_lines = content.split("\n")106 c_rt = "\n".join(l.rstrip() for l in c_lines)107 p_rt = "\n".join(l.rstrip() for l in pattern.split("\n"))108 if p_rt in c_rt:109 idx = c_rt.index(p_rt)110 start_line = c_rt[:idx].count("\n")111 n_lines = p_rt.count("\n") + 1112 matched = "\n".join(c_lines[start_line:start_line + n_lines])113 return matched, "(matched after trimming trailing whitespace)"114 # Pass 3: both-sides trim115 c_st = "\n".join(l.strip() for l in c_lines)116 p_st = "\n".join(l.strip() for l in pattern.split("\n"))117 if p_st in c_st:118 idx = c_st.index(p_st)119 start_line = c_st[:idx].count("\n")120 n_lines = p_st.count("\n") + 1121 matched = "\n".join(c_lines[start_line:start_line + n_lines])122 return matched, "(matched after trimming whitespace)"123 # Pass 4: unicode normalization124 c_norm = _normalize_unicode(c_st)125 p_norm = _normalize_unicode(p_st)126 if p_norm in c_norm:127 idx = c_norm.index(p_norm)128 start_line = c_norm[:idx].count("\n")129 n_lines = p_norm.count("\n") + 1130 matched = "\n".join(c_lines[start_line:start_line + n_lines])131 return matched, "(matched after unicode normalization)"132 return None, None133 134def _apply_edit(content, old_str, new_str, mode="replace", replace_all=False):135 """Apply edit. Returns (new_content, count, fuzzy_note) or raises ValueError."""136 if mode == "replace_all":137 replace_all = True138 mode = "replace"139 fuzzy_note = None140 if old_str not in content:141 matched, fuzzy_note = _fuzzy_find_original(content, old_str)142 if matched is None:143 raise ValueError("old_str not found in file.")144 old_str = matched145 count = content.count(old_str)146 if mode == "replace":147 if count > 1 and not replace_all:148 raise ValueError(f"old_str appears {count} times. Use replace_all=true or provide more context.")149 if replace_all:150 return content.replace(old_str, new_str), count, fuzzy_note151 return content.replace(old_str, new_str, 1), 1, fuzzy_note152 elif mode == "append_after":153 if replace_all:154 return content.replace(old_str, old_str + new_str), count, fuzzy_note155 idx = content.index(old_str) + len(old_str)156 return content[:idx] + new_str + content[idx:], 1, fuzzy_note157 elif mode == "prepend_before":158 if replace_all:159 return content.replace(old_str, new_str + old_str), count, fuzzy_note160 idx = content.index(old_str)161 return content[:idx] + new_str + content[idx:], 1, fuzzy_note162 raise ValueError(f"Unknown mode: {mode}")163 164def _validate_python(content, path=""):165 """Validate Python: syntax, kwargs against real installed signatures, training heuristics.166 167 Runs inside the sandbox where packages are pip-installed, so we can actually168 import classes and inspect their __init__ signatures to catch kwarg mismatches169 before runtime.170 """171 import ast as _ast, inspect as _inspect, importlib as _il172 warnings = []173 174 # 1. Syntax check175 try:176 tree = _ast.parse(content)177 except SyntaxError as e:178 warnings.append(f"Python syntax error at line {e.lineno}: {e.msg}")179 return warnings180 181 # 2. Build import map: name -> module path (from the script's own imports)182 import_map = {}183 for node in _ast.walk(tree):184 if isinstance(node, _ast.ImportFrom) and node.module:185 for alias in (node.names or []):186 local_name = alias.asname or alias.name187 import_map[local_name] = (node.module, alias.name)188 elif isinstance(node, _ast.Import):189 for alias in (node.names or []):190 local_name = alias.asname or alias.name191 import_map[local_name] = (alias.name, None)192 193 # 3. For each Call node, resolve the callable and check kwargs against signature194 for node in _ast.walk(tree):195 if not isinstance(node, _ast.Call):196 continue197 # Skip calls with **kwargs unpacking — we can't statically know those keys198 if any(kw.arg is None for kw in node.keywords):199 continue200 call_kwargs = [kw.arg for kw in node.keywords if kw.arg]201 if not call_kwargs:202 continue203 204 # Resolve the callable name205 func_name = None206 if isinstance(node.func, _ast.Name):207 func_name = node.func.id208 elif isinstance(node.func, _ast.Attribute):209 func_name = node.func.attr210 if not func_name or func_name not in import_map:211 continue212 213 # Try to import and inspect the real callable214 module_path, attr_name = import_map[func_name]215 try:216 mod = _il.import_module(module_path)217 obj = getattr(mod, attr_name, None) if attr_name else mod218 if obj is None:219 continue220 sig = _inspect.signature(obj)221 params = sig.parameters222 # If **kwargs is in the signature, any kwarg is valid223 if any(p.kind == _inspect.Parameter.VAR_KEYWORD for p in params.values()):224 continue225 valid_names = set(params.keys())226 for kw_name in call_kwargs:227 if kw_name not in valid_names:228 warnings.append(229 f"Invalid kwarg: {func_name}({kw_name}=...) at line {node.lineno} "230 f"-- not accepted by {module_path}.{attr_name or func_name}()"231 )232 except Exception:233 pass # can't import/inspect — skip silently234 235 # 4. Training script heuristics236 if any(kw in content for kw in ("TrainingArguments", "SFTConfig", "DPOConfig", "GRPOConfig")):237 if "push_to_hub" not in content:238 warnings.append("Training script warning: no 'push_to_hub' found")239 if "hub_model_id" not in content:240 warnings.append("Training script warning: no 'hub_model_id' found")241 return warnings242 243@app.get("/api/health")244def health():245 return {"status": "ok"}246 247@app.post("/api/bash")248def bash(req: BashReq):249 try:250 proc = subprocess.Popen(251 req.command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE,252 text=True, cwd=req.work_dir, start_new_session=True,253 )254 with _proc_lock:255 _active_procs[proc.pid] = proc256 try:257 stdout, stderr = proc.communicate(timeout=req.timeout)258 output = _strip_ansi(stdout + stderr)259 output = _truncate_output(output)260 return {"success": proc.returncode == 0, "output": output, "error": "" if proc.returncode == 0 else f"Exit code {proc.returncode}"}261 except subprocess.TimeoutExpired:262 try:263 os.killpg(os.getpgid(proc.pid), signal.SIGKILL)264 except OSError:265 proc.kill()266 proc.wait()267 return {"success": False, "output": "", "error": f"Timeout after {req.timeout}s"}268 finally:269 with _proc_lock:270 _active_procs.pop(proc.pid, None)271 except Exception as e:272 return {"success": False, "output": "", "error": str(e)}273 274@app.post("/api/kill")275def kill_all():276 """Kill all active bash processes. Called when user cancels."""277 with _proc_lock:278 pids = list(_active_procs.keys())279 killed = []280 for pid in pids:281 try:282 os.killpg(os.getpgid(pid), signal.SIGTERM)283 killed.append(pid)284 except OSError:285 try:286 os.kill(pid, signal.SIGKILL)287 killed.append(pid)288 except OSError:289 pass290 return {"success": True, "output": f"Killed {len(killed)} process(es): {killed}", "error": ""}291 292@app.post("/api/read")293def read(req: ReadReq):294 try:295 p = pathlib.Path(req.path)296 if not p.exists():297 return {"success": False, "output": "", "error": f"File not found: {req.path}"}298 if p.is_dir():299 return {"success": False, "output": "", "error": f"Is a directory: {req.path}"}300 lines = p.read_text().splitlines()301 start = (req.offset or 1) - 1302 end = start + (req.limit or len(lines))303 selected = lines[start:end]304 numbered = "\n".join(f"{start + i + 1}\t{line}" for i, line in enumerate(selected))305 return {"success": True, "output": numbered, "error": ""}306 except Exception as e:307 return {"success": False, "output": "", "error": str(e)}308 309@app.post("/api/write")310def write(req: WriteReq):311 try:312 p = pathlib.Path(req.path)313 _atomic_write(p, req.content)314 msg = f"Wrote {len(req.content)} bytes to {req.path}"315 if p.suffix == ".py":316 warnings = _validate_python(req.content, req.path)317 if warnings:318 msg += "\n\nValidation warnings:\n" + "\n".join(f" ! {w}" for w in warnings)319 return {"success": True, "output": msg, "error": ""}320 except Exception as e:321 return {"success": False, "output": "", "error": str(e)}322 323@app.post("/api/edit")324def edit(req: EditReq):325 try:326 p = pathlib.Path(req.path)327 if not p.exists():328 return {"success": False, "output": "", "error": f"File not found: {req.path}"}329 content = p.read_text()330 if req.old_str == req.new_str:331 return {"success": False, "output": "", "error": "old_str and new_str must differ."}332 try:333 new_content, count, fuzzy_note = _apply_edit(334 content, req.old_str, req.new_str, mode=req.mode, replace_all=req.replace_all335 )336 except ValueError as e:337 return {"success": False, "output": "", "error": str(e)}338 _atomic_write(p, new_content)339 msg = f"Edited {req.path} ({count} replacement{'s' if count > 1 else ''})"340 if fuzzy_note:341 msg += f" {fuzzy_note}"342 if p.suffix == ".py":343 warnings = _validate_python(new_content, req.path)344 if warnings:345 msg += "\n\nValidation warnings:\n" + "\n".join(f" ! {w}" for w in warnings)346 return {"success": True, "output": msg, "error": ""}347 except Exception as e:348 return {"success": False, "output": "", "error": str(e)}349 350@app.post("/api/exists")351def exists(req: ExistsReq):352 return {"success": True, "output": str(pathlib.Path(req.path).exists()).lower(), "error": ""}353 354if __name__ == "__main__":355 uvicorn.run(app, host="0.0.0.0", port=7860)356 