Liuciba commited on
Commit
cb2e7d0
·
verified ·
1 Parent(s): bbb9941

Setup sandbox server

Browse files
Files changed (2) hide show
  1. Dockerfile +12 -12
  2. sandbox_server.py +371 -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,371 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Minimal FastAPI server for sandbox operations."""
2
+ import hmac, os, subprocess, pathlib, signal, threading, re, tempfile
3
+ from fastapi import Depends, FastAPI, HTTPException, Request
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
+ def _expected_api_token() -> str:
60
+ return os.environ.get("SANDBOX_API_TOKEN") or os.environ.get("HF_TOKEN") or ""
61
+
62
+ def _require_auth(request: Request) -> None:
63
+ expected = _expected_api_token()
64
+ if not expected:
65
+ raise HTTPException(status_code=503, detail="Sandbox API token not configured")
66
+ auth_header = request.headers.get("authorization", "")
67
+ scheme, _, supplied = auth_header.partition(" ")
68
+ if scheme.lower() != "bearer" or not supplied:
69
+ raise HTTPException(status_code=401, detail="Missing bearer token")
70
+ if not hmac.compare_digest(supplied, expected):
71
+ raise HTTPException(status_code=401, detail="Invalid bearer token")
72
+
73
+ _AUTH = [Depends(_require_auth)]
74
+
75
+ # Track active bash processes so they can be killed on cancel
76
+ _active_procs = {} # pid -> subprocess.Popen
77
+ _proc_lock = threading.Lock()
78
+
79
+ class BashReq(BaseModel):
80
+ command: str
81
+ work_dir: str = "/app"
82
+ timeout: int = 120
83
+
84
+ class ReadReq(BaseModel):
85
+ path: str
86
+ offset: Optional[int] = None
87
+ limit: Optional[int] = 2000
88
+
89
+ class WriteReq(BaseModel):
90
+ path: str
91
+ content: str
92
+
93
+ class EditReq(BaseModel):
94
+ path: str
95
+ old_str: str
96
+ new_str: str
97
+ replace_all: bool = False
98
+ mode: str = "replace"
99
+
100
+ class ExistsReq(BaseModel):
101
+ path: str
102
+
103
+ # ── Fuzzy matching & edit utilities (embedded) ──
104
+
105
+ UNICODE_MAP = {
106
+ "\u2013": "-", "\u2014": "-", "\u2212": "-",
107
+ "\u2018": "'", "\u2019": "'",
108
+ "\u201c": '"', "\u201d": '"',
109
+ "\u00a0": " ", "\u2003": " ", "\u2002": " ",
110
+ "\u200b": "", "\ufeff": "",
111
+ }
112
+
113
+ def _normalize_unicode(s):
114
+ return "".join(UNICODE_MAP.get(c, c) for c in s)
115
+
116
+ def _fuzzy_find_original(content, pattern):
117
+ """Find the original text in content that matches pattern fuzzily."""
118
+ if pattern in content:
119
+ return pattern, None
120
+ # Pass 2: right-trim
121
+ c_lines = content.split("\n")
122
+ c_rt = "\n".join(l.rstrip() for l in c_lines)
123
+ p_rt = "\n".join(l.rstrip() for l in pattern.split("\n"))
124
+ if p_rt in c_rt:
125
+ idx = c_rt.index(p_rt)
126
+ start_line = c_rt[:idx].count("\n")
127
+ n_lines = p_rt.count("\n") + 1
128
+ matched = "\n".join(c_lines[start_line:start_line + n_lines])
129
+ return matched, "(matched after trimming trailing whitespace)"
130
+ # Pass 3: both-sides trim
131
+ c_st = "\n".join(l.strip() for l in c_lines)
132
+ p_st = "\n".join(l.strip() for l in pattern.split("\n"))
133
+ if p_st in c_st:
134
+ idx = c_st.index(p_st)
135
+ start_line = c_st[:idx].count("\n")
136
+ n_lines = p_st.count("\n") + 1
137
+ matched = "\n".join(c_lines[start_line:start_line + n_lines])
138
+ return matched, "(matched after trimming whitespace)"
139
+ # Pass 4: unicode normalization
140
+ c_norm = _normalize_unicode(c_st)
141
+ p_norm = _normalize_unicode(p_st)
142
+ if p_norm in c_norm:
143
+ idx = c_norm.index(p_norm)
144
+ start_line = c_norm[:idx].count("\n")
145
+ n_lines = p_norm.count("\n") + 1
146
+ matched = "\n".join(c_lines[start_line:start_line + n_lines])
147
+ return matched, "(matched after unicode normalization)"
148
+ return None, None
149
+
150
+ def _apply_edit(content, old_str, new_str, mode="replace", replace_all=False):
151
+ """Apply edit. Returns (new_content, count, fuzzy_note) or raises ValueError."""
152
+ if mode == "replace_all":
153
+ replace_all = True
154
+ mode = "replace"
155
+ fuzzy_note = None
156
+ if old_str not in content:
157
+ matched, fuzzy_note = _fuzzy_find_original(content, old_str)
158
+ if matched is None:
159
+ raise ValueError("old_str not found in file.")
160
+ old_str = matched
161
+ count = content.count(old_str)
162
+ if mode == "replace":
163
+ if count > 1 and not replace_all:
164
+ raise ValueError(f"old_str appears {count} times. Use replace_all=true or provide more context.")
165
+ if replace_all:
166
+ return content.replace(old_str, new_str), count, fuzzy_note
167
+ return content.replace(old_str, new_str, 1), 1, fuzzy_note
168
+ elif mode == "append_after":
169
+ if replace_all:
170
+ return content.replace(old_str, old_str + new_str), count, fuzzy_note
171
+ idx = content.index(old_str) + len(old_str)
172
+ return content[:idx] + new_str + content[idx:], 1, fuzzy_note
173
+ elif mode == "prepend_before":
174
+ if replace_all:
175
+ return content.replace(old_str, new_str + old_str), count, fuzzy_note
176
+ idx = content.index(old_str)
177
+ return content[:idx] + new_str + content[idx:], 1, fuzzy_note
178
+ raise ValueError(f"Unknown mode: {mode}")
179
+
180
+ def _validate_python(content, path=""):
181
+ """Validate Python: syntax, kwargs against real installed signatures, training heuristics.
182
+
183
+ Runs inside the sandbox where packages are pip-installed, so we can actually
184
+ import classes and inspect their __init__ signatures to catch kwarg mismatches
185
+ before runtime.
186
+ """
187
+ import ast as _ast, inspect as _inspect, importlib as _il
188
+ warnings = []
189
+
190
+ # 1. Syntax check
191
+ try:
192
+ tree = _ast.parse(content)
193
+ except SyntaxError as e:
194
+ warnings.append(f"Python syntax error at line {e.lineno}: {e.msg}")
195
+ return warnings
196
+
197
+ # 2. Build import map: name -> module path (from the script's own imports)
198
+ import_map = {}
199
+ for node in _ast.walk(tree):
200
+ if isinstance(node, _ast.ImportFrom) and node.module:
201
+ for alias in (node.names or []):
202
+ local_name = alias.asname or alias.name
203
+ import_map[local_name] = (node.module, alias.name)
204
+ elif isinstance(node, _ast.Import):
205
+ for alias in (node.names or []):
206
+ local_name = alias.asname or alias.name
207
+ import_map[local_name] = (alias.name, None)
208
+
209
+ # 3. For each Call node, resolve the callable and check kwargs against signature
210
+ for node in _ast.walk(tree):
211
+ if not isinstance(node, _ast.Call):
212
+ continue
213
+ # Skip calls with **kwargs unpacking — we can't statically know those keys
214
+ if any(kw.arg is None for kw in node.keywords):
215
+ continue
216
+ call_kwargs = [kw.arg for kw in node.keywords if kw.arg]
217
+ if not call_kwargs:
218
+ continue
219
+
220
+ # Resolve the callable name
221
+ func_name = None
222
+ if isinstance(node.func, _ast.Name):
223
+ func_name = node.func.id
224
+ elif isinstance(node.func, _ast.Attribute):
225
+ func_name = node.func.attr
226
+ if not func_name or func_name not in import_map:
227
+ continue
228
+
229
+ # Try to import and inspect the real callable
230
+ module_path, attr_name = import_map[func_name]
231
+ try:
232
+ mod = _il.import_module(module_path)
233
+ obj = getattr(mod, attr_name, None) if attr_name else mod
234
+ if obj is None:
235
+ continue
236
+ sig = _inspect.signature(obj)
237
+ params = sig.parameters
238
+ # If **kwargs is in the signature, any kwarg is valid
239
+ if any(p.kind == _inspect.Parameter.VAR_KEYWORD for p in params.values()):
240
+ continue
241
+ valid_names = set(params.keys())
242
+ for kw_name in call_kwargs:
243
+ if kw_name not in valid_names:
244
+ warnings.append(
245
+ f"Invalid kwarg: {func_name}({kw_name}=...) at line {node.lineno} "
246
+ f"-- not accepted by {module_path}.{attr_name or func_name}()"
247
+ )
248
+ except Exception:
249
+ pass # can't import/inspect — skip silently
250
+
251
+ # 4. Training script heuristics
252
+ if any(kw in content for kw in ("TrainingArguments", "SFTConfig", "DPOConfig", "GRPOConfig")):
253
+ if "push_to_hub" not in content:
254
+ warnings.append("Training script warning: no 'push_to_hub' found")
255
+ if "hub_model_id" not in content:
256
+ warnings.append("Training script warning: no 'hub_model_id' found")
257
+ return warnings
258
+
259
+ @app.get("/api/health")
260
+ def health():
261
+ return {"status": "ok"}
262
+
263
+ @app.post("/api/bash", dependencies=_AUTH)
264
+ def bash(req: BashReq):
265
+ try:
266
+ proc = subprocess.Popen(
267
+ req.command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
268
+ text=True, cwd=req.work_dir, start_new_session=True,
269
+ )
270
+ with _proc_lock:
271
+ _active_procs[proc.pid] = proc
272
+ try:
273
+ stdout, stderr = proc.communicate(timeout=req.timeout)
274
+ output = _strip_ansi(stdout + stderr)
275
+ output = _truncate_output(output)
276
+ return {"success": proc.returncode == 0, "output": output, "error": "" if proc.returncode == 0 else f"Exit code {proc.returncode}"}
277
+ except subprocess.TimeoutExpired:
278
+ try:
279
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
280
+ except OSError:
281
+ proc.kill()
282
+ proc.wait()
283
+ return {"success": False, "output": "", "error": f"Timeout after {req.timeout}s"}
284
+ finally:
285
+ with _proc_lock:
286
+ _active_procs.pop(proc.pid, None)
287
+ except Exception as e:
288
+ return {"success": False, "output": "", "error": str(e)}
289
+
290
+ @app.post("/api/kill", dependencies=_AUTH)
291
+ def kill_all():
292
+ """Kill all active bash processes. Called when user cancels."""
293
+ with _proc_lock:
294
+ pids = list(_active_procs.keys())
295
+ killed = []
296
+ for pid in pids:
297
+ try:
298
+ os.killpg(os.getpgid(pid), signal.SIGTERM)
299
+ killed.append(pid)
300
+ except OSError:
301
+ try:
302
+ os.kill(pid, signal.SIGKILL)
303
+ killed.append(pid)
304
+ except OSError:
305
+ pass
306
+ return {"success": True, "output": f"Killed {len(killed)} process(es): {killed}", "error": ""}
307
+
308
+ @app.post("/api/read", dependencies=_AUTH)
309
+ def read(req: ReadReq):
310
+ try:
311
+ p = pathlib.Path(req.path)
312
+ if not p.exists():
313
+ return {"success": False, "output": "", "error": f"File not found: {req.path}"}
314
+ if p.is_dir():
315
+ return {"success": False, "output": "", "error": f"Is a directory: {req.path}"}
316
+ lines = p.read_text().splitlines()
317
+ start = (req.offset or 1) - 1
318
+ end = start + (req.limit or len(lines))
319
+ selected = lines[start:end]
320
+ numbered = "\n".join(f"{start + i + 1}\t{line}" for i, line in enumerate(selected))
321
+ return {"success": True, "output": numbered, "error": ""}
322
+ except Exception as e:
323
+ return {"success": False, "output": "", "error": str(e)}
324
+
325
+ @app.post("/api/write", dependencies=_AUTH)
326
+ def write(req: WriteReq):
327
+ try:
328
+ p = pathlib.Path(req.path)
329
+ _atomic_write(p, req.content)
330
+ msg = f"Wrote {len(req.content)} bytes to {req.path}"
331
+ if p.suffix == ".py":
332
+ warnings = _validate_python(req.content, req.path)
333
+ if warnings:
334
+ msg += "\n\nValidation warnings:\n" + "\n".join(f" ! {w}" for w in warnings)
335
+ return {"success": True, "output": msg, "error": ""}
336
+ except Exception as e:
337
+ return {"success": False, "output": "", "error": str(e)}
338
+
339
+ @app.post("/api/edit", dependencies=_AUTH)
340
+ def edit(req: EditReq):
341
+ try:
342
+ p = pathlib.Path(req.path)
343
+ if not p.exists():
344
+ return {"success": False, "output": "", "error": f"File not found: {req.path}"}
345
+ content = p.read_text()
346
+ if req.old_str == req.new_str:
347
+ return {"success": False, "output": "", "error": "old_str and new_str must differ."}
348
+ try:
349
+ new_content, count, fuzzy_note = _apply_edit(
350
+ content, req.old_str, req.new_str, mode=req.mode, replace_all=req.replace_all
351
+ )
352
+ except ValueError as e:
353
+ return {"success": False, "output": "", "error": str(e)}
354
+ _atomic_write(p, new_content)
355
+ msg = f"Edited {req.path} ({count} replacement{'s' if count > 1 else ''})"
356
+ if fuzzy_note:
357
+ msg += f" {fuzzy_note}"
358
+ if p.suffix == ".py":
359
+ warnings = _validate_python(new_content, req.path)
360
+ if warnings:
361
+ msg += "\n\nValidation warnings:\n" + "\n".join(f" ! {w}" for w in warnings)
362
+ return {"success": True, "output": msg, "error": ""}
363
+ except Exception as e:
364
+ return {"success": False, "output": "", "error": str(e)}
365
+
366
+ @app.post("/api/exists", dependencies=_AUTH)
367
+ def exists(req: ExistsReq):
368
+ return {"success": True, "output": str(pathlib.Path(req.path).exists()).lower(), "error": ""}
369
+
370
+ if __name__ == "__main__":
371
+ uvicorn.run(app, host="0.0.0.0", port=7860)