ecado commited on
Commit
76d5e8a
·
verified ·
1 Parent(s): bbb9941

Setup sandbox server

Browse files
Files changed (2) hide show
  1. Dockerfile +12 -12
  2. sandbox_server.py +355 -0
Dockerfile CHANGED
@@ -1,29 +1,29 @@
1
  FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
2
 
3
- # Install Dev Mode required packages + useful tools
4
  RUN apt-get update && \
5
  apt-get install -y \
6
- bash \
7
- git git-lfs \
8
- wget curl procps \
9
- htop vim nano \
10
- jq \
11
- tmux \
12
  build-essential && \
13
  rm -rf /var/lib/apt/lists/*
14
 
15
- # Set up user with uid 1000 (required for Dev Mode)
 
16
  RUN useradd -m -u 1000 user
17
  USER user
18
 
19
  ENV HOME=/home/user \
20
- PATH=/home/user/.local/bin:$PATH
21
-
 
 
 
 
 
22
 
23
  WORKDIR /app
24
  COPY --chown=user . /app
25
 
26
  EXPOSE 7860
27
 
28
- # Just keep the container alive - agent uses SSH to interact
29
- CMD ["python", "-m", "http.server", "7860"]
 
1
  FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
2
 
 
3
  RUN apt-get update && \
4
  apt-get install -y \
5
+ bash git git-lfs wget curl procps \
6
+ htop vim nano jq tmux \
 
 
 
 
7
  build-essential && \
8
  rm -rf /var/lib/apt/lists/*
9
 
10
+ RUN uv pip install --system fastapi uvicorn python-multipart
11
+
12
  RUN useradd -m -u 1000 user
13
  USER user
14
 
15
  ENV HOME=/home/user \
16
+ PATH=/home/user/.local/bin:$PATH \
17
+ PIP_USER=1 \
18
+ HF_HUB_DISABLE_PROGRESS_BARS=1 \
19
+ TQDM_DISABLE=1 \
20
+ HF_HUB_ENABLE_HF_TRANSFER=1 \
21
+ UV_NO_PROGRESS=1 \
22
+ PYTHONWARNINGS=ignore::DeprecationWarning
23
 
24
  WORKDIR /app
25
  COPY --chown=user . /app
26
 
27
  EXPOSE 7860
28
 
29
+ CMD ["python", "sandbox_server.py"]
 
sandbox_server.py ADDED
@@ -0,0 +1,355 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Minimal FastAPI server for sandbox operations."""
2
+ import os, subprocess, pathlib, signal, threading, re, tempfile
3
+ from fastapi import FastAPI
4
+ from pydantic import BaseModel
5
+ from typing import Optional
6
+ import uvicorn
7
+
8
+ _ANSI_RE = re.compile(r'\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?\x07')
9
+
10
+ def _strip_ansi(text: str) -> str:
11
+ return _ANSI_RE.sub('', text)
12
+
13
+ def _truncate_output(output: str, max_chars: int = 25000, head_ratio: float = 0.25) -> str:
14
+ if len(output) <= max_chars:
15
+ return output
16
+ # Write full output to temp file so LLM can read specific sections
17
+ spill_path = None
18
+ 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.name
22
+ except Exception:
23
+ pass
24
+ head_budget = int(max_chars * head_ratio)
25
+ tail_budget = max_chars - head_budget
26
+ head = output[:head_budget]
27
+ tail = output[-tail_budget:]
28
+ total = len(output)
29
+ omitted = total - max_chars
30
+ 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 + tail
34
+
35
+ def _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 = None
39
+ tmp_path = None
40
+ 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 = None
46
+ os.replace(tmp_path, str(path))
47
+ tmp_path = None
48
+ 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
+ pass
56
+
57
+ app = FastAPI()
58
+
59
+ # Track active bash processes so they can be killed on cancel
60
+ _active_procs = {} # pid -> subprocess.Popen
61
+ _proc_lock = threading.Lock()
62
+
63
+ class BashReq(BaseModel):
64
+ command: str
65
+ work_dir: str = "/app"
66
+ timeout: int = 120
67
+
68
+ class ReadReq(BaseModel):
69
+ path: str
70
+ offset: Optional[int] = None
71
+ limit: Optional[int] = 2000
72
+
73
+ class WriteReq(BaseModel):
74
+ path: str
75
+ content: str
76
+
77
+ class EditReq(BaseModel):
78
+ path: str
79
+ old_str: str
80
+ new_str: str
81
+ replace_all: bool = False
82
+ mode: str = "replace"
83
+
84
+ class ExistsReq(BaseModel):
85
+ path: str
86
+
87
+ # ── Fuzzy matching & edit utilities (embedded) ──
88
+
89
+ UNICODE_MAP = {
90
+ "\u2013": "-", "\u2014": "-", "\u2212": "-",
91
+ "\u2018": "'", "\u2019": "'",
92
+ "\u201c": '"', "\u201d": '"',
93
+ "\u00a0": " ", "\u2003": " ", "\u2002": " ",
94
+ "\u200b": "", "\ufeff": "",
95
+ }
96
+
97
+ def _normalize_unicode(s):
98
+ return "".join(UNICODE_MAP.get(c, c) for c in s)
99
+
100
+ def _fuzzy_find_original(content, pattern):
101
+ """Find the original text in content that matches pattern fuzzily."""
102
+ if pattern in content:
103
+ return pattern, None
104
+ # Pass 2: right-trim
105
+ 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") + 1
112
+ matched = "\n".join(c_lines[start_line:start_line + n_lines])
113
+ return matched, "(matched after trimming trailing whitespace)"
114
+ # Pass 3: both-sides trim
115
+ 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") + 1
121
+ matched = "\n".join(c_lines[start_line:start_line + n_lines])
122
+ return matched, "(matched after trimming whitespace)"
123
+ # Pass 4: unicode normalization
124
+ 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") + 1
130
+ matched = "\n".join(c_lines[start_line:start_line + n_lines])
131
+ return matched, "(matched after unicode normalization)"
132
+ return None, None
133
+
134
+ def _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 = True
138
+ mode = "replace"
139
+ fuzzy_note = None
140
+ 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 = matched
145
+ 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_note
151
+ return content.replace(old_str, new_str, 1), 1, fuzzy_note
152
+ elif mode == "append_after":
153
+ if replace_all:
154
+ return content.replace(old_str, old_str + new_str), count, fuzzy_note
155
+ idx = content.index(old_str) + len(old_str)
156
+ return content[:idx] + new_str + content[idx:], 1, fuzzy_note
157
+ elif mode == "prepend_before":
158
+ if replace_all:
159
+ return content.replace(old_str, new_str + old_str), count, fuzzy_note
160
+ idx = content.index(old_str)
161
+ return content[:idx] + new_str + content[idx:], 1, fuzzy_note
162
+ raise ValueError(f"Unknown mode: {mode}")
163
+
164
+ def _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 actually
168
+ import classes and inspect their __init__ signatures to catch kwarg mismatches
169
+ before runtime.
170
+ """
171
+ import ast as _ast, inspect as _inspect, importlib as _il
172
+ warnings = []
173
+
174
+ # 1. Syntax check
175
+ 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 warnings
180
+
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.name
187
+ 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.name
191
+ import_map[local_name] = (alias.name, None)
192
+
193
+ # 3. For each Call node, resolve the callable and check kwargs against signature
194
+ for node in _ast.walk(tree):
195
+ if not isinstance(node, _ast.Call):
196
+ continue
197
+ # Skip calls with **kwargs unpacking — we can't statically know those keys
198
+ if any(kw.arg is None for kw in node.keywords):
199
+ continue
200
+ call_kwargs = [kw.arg for kw in node.keywords if kw.arg]
201
+ if not call_kwargs:
202
+ continue
203
+
204
+ # Resolve the callable name
205
+ func_name = None
206
+ if isinstance(node.func, _ast.Name):
207
+ func_name = node.func.id
208
+ elif isinstance(node.func, _ast.Attribute):
209
+ func_name = node.func.attr
210
+ if not func_name or func_name not in import_map:
211
+ continue
212
+
213
+ # Try to import and inspect the real callable
214
+ 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 mod
218
+ if obj is None:
219
+ continue
220
+ sig = _inspect.signature(obj)
221
+ params = sig.parameters
222
+ # If **kwargs is in the signature, any kwarg is valid
223
+ if any(p.kind == _inspect.Parameter.VAR_KEYWORD for p in params.values()):
224
+ continue
225
+ 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 silently
234
+
235
+ # 4. Training script heuristics
236
+ 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 warnings
242
+
243
+ @app.get("/api/health")
244
+ def health():
245
+ return {"status": "ok"}
246
+
247
+ @app.post("/api/bash")
248
+ def 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] = proc
256
+ 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")
275
+ def 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
+ pass
290
+ return {"success": True, "output": f"Killed {len(killed)} process(es): {killed}", "error": ""}
291
+
292
+ @app.post("/api/read")
293
+ def 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) - 1
302
+ 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")
310
+ def 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")
324
+ def 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_all
335
+ )
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")
351
+ def exists(req: ExistsReq):
352
+ return {"success": True, "output": str(pathlib.Path(req.path).exists()).lower(), "error": ""}
353
+
354
+ if __name__ == "__main__":
355
+ uvicorn.run(app, host="0.0.0.0", port=7860)