agentboxx 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentbox/__init__.py +5 -0
- agentbox/agent.py +138 -0
- agentbox/cli.py +252 -0
- agentbox/docker_backend.py +167 -0
- agentbox/error_signatures.py +46 -0
- agentbox/executor.py +102 -0
- agentbox/loop_detector.py +71 -0
- agentbox/recorder.py +73 -0
- agentbox/redact.py +71 -0
- agentbox/report.py +266 -0
- agentbox/sandbox_stats.py +80 -0
- agentbox/signer.py +148 -0
- agentbox/streaming.py +133 -0
- agentbox/workspace.py +83 -0
- agentboxx-0.2.1.dist-info/METADATA +265 -0
- agentboxx-0.2.1.dist-info/RECORD +20 -0
- agentboxx-0.2.1.dist-info/WHEEL +5 -0
- agentboxx-0.2.1.dist-info/entry_points.txt +2 -0
- agentboxx-0.2.1.dist-info/licenses/LICENSE +201 -0
- agentboxx-0.2.1.dist-info/top_level.txt +1 -0
agentbox/__init__.py
ADDED
agentbox/agent.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""A minimal ReAct-style agent loop wired to the recorder.
|
|
2
|
+
|
|
3
|
+
Loop: model proposes a command -> sandbox runs it -> recorder logs it ->
|
|
4
|
+
the observation (plus any loop-detector hint) goes back to the model.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
SYSTEM_PROMPT = (
|
|
9
|
+
"You are a coding agent working in a sandbox with a bash tool. "
|
|
10
|
+
"Reply with ONLY one JSON object, no other text. "
|
|
11
|
+
'To run a command: {"thought": "why", "command": "bash command"}. '
|
|
12
|
+
'When the goal is done: {"thought": "why", "final": "short answer"}. '
|
|
13
|
+
"If you receive an [agentbox hint], change your approach."
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ScriptedModel:
|
|
18
|
+
"""A fake model for demos and tests. Retries a failing command, then
|
|
19
|
+
changes approach once it receives an [agentbox hint]."""
|
|
20
|
+
|
|
21
|
+
def __init__(self):
|
|
22
|
+
self.plan = [
|
|
23
|
+
("Create a small script.", "printf 'x = 0\\nprint(10 / x)\\n' > main.py"),
|
|
24
|
+
("Run it.", "python3 main.py"),
|
|
25
|
+
]
|
|
26
|
+
self.recovery = [
|
|
27
|
+
("The hint is right: x = 0 causes the crash. Fix the value.", "sed -i 's/x = 0/x = 2/' main.py"),
|
|
28
|
+
("Run it again.", "python3 main.py"),
|
|
29
|
+
("DONE", None),
|
|
30
|
+
]
|
|
31
|
+
self.i, self.recovering = 0, False
|
|
32
|
+
|
|
33
|
+
def next_action(self, messages):
|
|
34
|
+
if not self.recovering and "[agentbox hint]" in messages[-1]["content"]:
|
|
35
|
+
self.recovering, self.i = True, 0
|
|
36
|
+
steps = self.recovery if self.recovering else self.plan
|
|
37
|
+
thought, cmd = steps[min(self.i, len(steps) - 1)]
|
|
38
|
+
self.i += 1
|
|
39
|
+
if cmd is None:
|
|
40
|
+
return {"thought": "The script runs now.", "final": "Fixed the divide-by-zero; script prints 5.0."}
|
|
41
|
+
return {"thought": thought, "command": cmd}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AnthropicModel:
|
|
45
|
+
"""Real model via the Anthropic API. Needs `pip install anthropic` and
|
|
46
|
+
the ANTHROPIC_API_KEY environment variable."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, model="claude-sonnet-5-5"):
|
|
49
|
+
import anthropic
|
|
50
|
+
self.client, self.model = anthropic.Anthropic(), model
|
|
51
|
+
|
|
52
|
+
def next_action(self, messages):
|
|
53
|
+
r = self.client.messages.create(model=self.model, max_tokens=1000,
|
|
54
|
+
system=SYSTEM_PROMPT, messages=messages)
|
|
55
|
+
text = r.content[0].text.strip()
|
|
56
|
+
if text.startswith("```"):
|
|
57
|
+
text = text.strip("`").removeprefix("json").strip()
|
|
58
|
+
try:
|
|
59
|
+
return json.loads(text)
|
|
60
|
+
except json.JSONDecodeError:
|
|
61
|
+
return {"thought": "unparseable reply", "final": text[:500]}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class GeminiModel:
|
|
66
|
+
"""Real model via the Google Gemini REST API (stdlib only, no SDK needed).
|
|
67
|
+
Needs the GEMINI_API_KEY environment variable."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, model="gemini-flash-lite-latest"):
|
|
70
|
+
import os
|
|
71
|
+
key = os.environ.get("GEMINI_API_KEY")
|
|
72
|
+
if not key:
|
|
73
|
+
raise RuntimeError("set the GEMINI_API_KEY environment variable")
|
|
74
|
+
self._model = model
|
|
75
|
+
self._url = (
|
|
76
|
+
f"https://generativelanguage.googleapis.com/v1beta/"
|
|
77
|
+
f"models/{model}:generateContent?key={key}"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def next_action(self, messages):
|
|
81
|
+
import urllib.request
|
|
82
|
+
contents = []
|
|
83
|
+
for m in messages:
|
|
84
|
+
role = "model" if m["role"] == "assistant" else "user"
|
|
85
|
+
contents.append({"role": role, "parts": [{"text": m["content"]}]})
|
|
86
|
+
body = json.dumps({
|
|
87
|
+
"system_instruction": {"parts": [{"text": SYSTEM_PROMPT}]},
|
|
88
|
+
"contents": contents,
|
|
89
|
+
}).encode("utf-8")
|
|
90
|
+
req = urllib.request.Request(
|
|
91
|
+
self._url, data=body,
|
|
92
|
+
headers={"Content-Type": "application/json"},
|
|
93
|
+
)
|
|
94
|
+
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
95
|
+
data = json.loads(resp.read())
|
|
96
|
+
text = data["candidates"][0]["content"]["parts"][0]["text"].strip()
|
|
97
|
+
if text.startswith("```"):
|
|
98
|
+
text = text.strip("`").removeprefix("json").strip()
|
|
99
|
+
try:
|
|
100
|
+
return json.loads(text)
|
|
101
|
+
except json.JSONDecodeError:
|
|
102
|
+
return {"thought": "unparseable reply", "final": text[:500]}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _format_observation(obs: dict) -> str:
|
|
106
|
+
return (f"exit_code={obs['exit_code']}\n"
|
|
107
|
+
f"stdout:\n{obs['stdout']['text'][-1500:]}\n"
|
|
108
|
+
f"stderr:\n{obs['stderr']['text'][-1500:]}")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def run_agent(goal, model, recorder, max_steps=15, verbose=True) -> dict:
|
|
112
|
+
messages = [{"role": "user", "content": f"Goal: {goal}"}]
|
|
113
|
+
reason, final = "max_steps", None
|
|
114
|
+
for _ in range(max_steps):
|
|
115
|
+
action = model.next_action(messages)
|
|
116
|
+
messages.append({"role": "assistant", "content": json.dumps(action)})
|
|
117
|
+
if action.get("final") is not None:
|
|
118
|
+
reason, final = "final_answer", action["final"]
|
|
119
|
+
break
|
|
120
|
+
cmd = action.get("command")
|
|
121
|
+
if not cmd:
|
|
122
|
+
messages.append({"role": "user", "content": "Reply with a command or a final answer."})
|
|
123
|
+
continue
|
|
124
|
+
result = recorder.run(cmd, thought=action.get("thought", ""))
|
|
125
|
+
obs, signals = result["observation"], result["signals"]
|
|
126
|
+
if verbose:
|
|
127
|
+
print(f"[{recorder.step_index}] {action.get('thought', '')}\n $ {cmd} -> exit {obs['exit_code']}")
|
|
128
|
+
for s in signals:
|
|
129
|
+
print(f" !! {s['severity']}: {s['kind']}")
|
|
130
|
+
critical = [s for s in signals if s["severity"] == "critical"]
|
|
131
|
+
if critical:
|
|
132
|
+
reason = f"terminated: {critical[0]['kind']}"
|
|
133
|
+
break
|
|
134
|
+
text = _format_observation(obs)
|
|
135
|
+
if signals:
|
|
136
|
+
text += "\n\n[agentbox hint] " + " ".join(sorted({s["hint"] for s in signals}))
|
|
137
|
+
messages.append({"role": "user", "content": text})
|
|
138
|
+
return {"reason": reason, "final": final}
|
agentbox/cli.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Command-line interface: verify, report, list and demo."""
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .signer import load_or_create_key, verify_log
|
|
13
|
+
from .report import build_report_data, render_text, render_json, render_html
|
|
14
|
+
|
|
15
|
+
EXIT_OK = 0
|
|
16
|
+
EXIT_TAMPERED = 1
|
|
17
|
+
EXIT_INCOMPLETE = 2
|
|
18
|
+
EXIT_ERROR = 3 # missing, empty or unreadable input (not a verdict on the log)
|
|
19
|
+
|
|
20
|
+
_RED, _YELLOW, _RESET = "\033[31m", "\033[33m", "\033[0m"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CliError(Exception):
|
|
24
|
+
"""A problem with the input that should be shown as a message, not a traceback."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _use_color(stream=None) -> bool:
|
|
28
|
+
"""Color only on a terminal, and never when NO_COLOR is set (https://no-color.org)."""
|
|
29
|
+
stream = stream if stream is not None else sys.stdout
|
|
30
|
+
if os.environ.get("NO_COLOR"):
|
|
31
|
+
return False
|
|
32
|
+
if os.environ.get("TERM") == "dumb":
|
|
33
|
+
return False
|
|
34
|
+
isatty = getattr(stream, "isatty", None)
|
|
35
|
+
return bool(isatty and isatty())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _paint(text: str, color: str, enabled: bool) -> str:
|
|
39
|
+
return f"{color}{text}{_RESET}" if enabled else text
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _check_readable(path) -> None:
|
|
43
|
+
p = Path(path)
|
|
44
|
+
if not p.exists():
|
|
45
|
+
raise CliError(f"file not found: {path}")
|
|
46
|
+
if p.is_dir():
|
|
47
|
+
raise CliError(f"{path} is a directory, not a log file")
|
|
48
|
+
try:
|
|
49
|
+
size = p.stat().st_size
|
|
50
|
+
except OSError as e:
|
|
51
|
+
raise CliError(f"cannot read {path}: {e.strerror or e}")
|
|
52
|
+
if size == 0:
|
|
53
|
+
raise CliError(f"log file is empty: {path}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _run_verify(path, key=None):
|
|
57
|
+
"""Return (problems, incomplete). Raises CliError for missing, empty or unreadable files."""
|
|
58
|
+
_check_readable(path) # before the key lookup, so a typo creates no key
|
|
59
|
+
key = key if key is not None else load_or_create_key()
|
|
60
|
+
try:
|
|
61
|
+
return verify_log(path, key)
|
|
62
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
63
|
+
raise CliError(f"cannot read {path}: {getattr(e, 'strerror', None) or e}")
|
|
64
|
+
except (KeyError, TypeError, AttributeError):
|
|
65
|
+
return ["a record is malformed (not a valid agentbox record)"], []
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _verify_and_print(path, key=None) -> int:
|
|
69
|
+
problems, incomplete = _run_verify(path, key)
|
|
70
|
+
if problems:
|
|
71
|
+
print("LOG HAS BEEN TAMPERED WITH OR IS CORRUPT:")
|
|
72
|
+
for p in problems:
|
|
73
|
+
print(" -", p)
|
|
74
|
+
return EXIT_TAMPERED
|
|
75
|
+
if incomplete:
|
|
76
|
+
print("INCOMPLETE LOG (chain and signatures are valid so far):")
|
|
77
|
+
for p in incomplete:
|
|
78
|
+
print(" -", p)
|
|
79
|
+
return EXIT_INCOMPLETE
|
|
80
|
+
print("OK: log is intact (chain and signatures valid).")
|
|
81
|
+
return EXIT_OK
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def cmd_verify(args) -> int:
|
|
85
|
+
return _verify_and_print(args.logfile)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _read_events(path) -> list:
|
|
89
|
+
_check_readable(path)
|
|
90
|
+
events = []
|
|
91
|
+
try:
|
|
92
|
+
with open(path, encoding="utf-8") as f:
|
|
93
|
+
for n, line in enumerate(f, 1):
|
|
94
|
+
if not line.strip():
|
|
95
|
+
continue
|
|
96
|
+
try:
|
|
97
|
+
ev = json.loads(line)["event"]
|
|
98
|
+
ev["event_type"]
|
|
99
|
+
except (ValueError, KeyError, TypeError):
|
|
100
|
+
raise CliError(f"{path}: line {n} is not a valid agentbox record")
|
|
101
|
+
events.append(ev)
|
|
102
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
103
|
+
raise CliError(f"cannot read {path}: {getattr(e, 'strerror', None) or e}")
|
|
104
|
+
return events
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def cmd_report(args) -> int:
|
|
108
|
+
events = _read_events(args.logfile)
|
|
109
|
+
fmt = getattr(args, "format", "text") or "text"
|
|
110
|
+
out_path = getattr(args, "output", None)
|
|
111
|
+
try:
|
|
112
|
+
data = build_report_data(events)
|
|
113
|
+
if fmt == "json":
|
|
114
|
+
rendered = render_json(data)
|
|
115
|
+
elif fmt == "html":
|
|
116
|
+
rendered = render_html(data)
|
|
117
|
+
else:
|
|
118
|
+
color = _use_color(stream=sys.stdout if not out_path else None)
|
|
119
|
+
rendered = render_text(data, color=color)
|
|
120
|
+
except (KeyError, TypeError) as e:
|
|
121
|
+
raise CliError(f"{args.logfile}: a record is missing the field {e}")
|
|
122
|
+
|
|
123
|
+
if out_path and out_path != "-":
|
|
124
|
+
try:
|
|
125
|
+
Path(out_path).write_text(rendered, encoding="utf-8")
|
|
126
|
+
except OSError as e:
|
|
127
|
+
raise CliError(f"cannot write to {out_path}: {getattr(e, 'strerror', None) or e}")
|
|
128
|
+
else:
|
|
129
|
+
print(rendered, end="")
|
|
130
|
+
return EXIT_OK
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _peek(path):
|
|
134
|
+
"""Cheap, tolerant read for `list`: (session_id, start timestamp, step count)."""
|
|
135
|
+
session, started, steps = Path(path).stem, None, 0
|
|
136
|
+
with open(path, encoding="utf-8", errors="replace") as f:
|
|
137
|
+
for line in f:
|
|
138
|
+
try:
|
|
139
|
+
ev = json.loads(line)["event"]
|
|
140
|
+
etype = ev["event_type"]
|
|
141
|
+
except (ValueError, KeyError, TypeError):
|
|
142
|
+
continue
|
|
143
|
+
if started is None and isinstance(ev.get("ts"), (int, float)):
|
|
144
|
+
started = ev["ts"]
|
|
145
|
+
data = ev.get("data")
|
|
146
|
+
if etype == "session.start" and isinstance(data, dict):
|
|
147
|
+
session = str(data.get("session_id", session))
|
|
148
|
+
elif etype == "step":
|
|
149
|
+
steps += 1
|
|
150
|
+
return session, started, steps
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def cmd_list(args) -> int:
|
|
154
|
+
root = Path(args.directory)
|
|
155
|
+
if not root.is_dir():
|
|
156
|
+
raise CliError(f"directory not found: {args.directory}")
|
|
157
|
+
logs = sorted(root.glob("*.ndjson"))
|
|
158
|
+
if not logs:
|
|
159
|
+
print(f"No logs found in {args.directory}")
|
|
160
|
+
return EXIT_OK
|
|
161
|
+
key = load_or_create_key()
|
|
162
|
+
rows = []
|
|
163
|
+
for path in logs:
|
|
164
|
+
try:
|
|
165
|
+
session, started, steps = _peek(path)
|
|
166
|
+
problems, incomplete = _run_verify(path, key)
|
|
167
|
+
status = "TAMPERED" if problems else "INCOMPLETE" if incomplete else "OK"
|
|
168
|
+
except CliError:
|
|
169
|
+
session, started, steps = path.stem, None, 0
|
|
170
|
+
status = "EMPTY" if path.stat().st_size == 0 else "UNREADABLE"
|
|
171
|
+
except OSError:
|
|
172
|
+
session, started, steps, status = path.stem, None, 0, "UNREADABLE"
|
|
173
|
+
when = (datetime.fromtimestamp(started).strftime("%Y-%m-%d %H:%M:%S")
|
|
174
|
+
if started is not None else "-")
|
|
175
|
+
rows.append((started if started is not None else float("inf"), session, when, str(steps), status))
|
|
176
|
+
rows.sort(key=lambda r: (r[0], r[1]))
|
|
177
|
+
table = [("SESSION", "STARTED", "STEPS", "VERIFIES")] + [r[1:] for r in rows]
|
|
178
|
+
widths = [max(len(r[i]) for r in table) for i in range(4)]
|
|
179
|
+
for r in table:
|
|
180
|
+
print(" ".join(c.ljust(widths[i]) for i, c in enumerate(r)).rstrip())
|
|
181
|
+
return EXIT_OK
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def cmd_demo(args) -> int:
|
|
185
|
+
"""Run the scripted stuck-agent scenario in a temp directory (local backend, no Docker)."""
|
|
186
|
+
from .agent import ScriptedModel, run_agent
|
|
187
|
+
from .recorder import Recorder
|
|
188
|
+
|
|
189
|
+
missing = [t for t in ("bash", "python3") if shutil.which(t) is None]
|
|
190
|
+
if missing:
|
|
191
|
+
raise CliError("the demo needs " + " and ".join(missing) + " on your PATH")
|
|
192
|
+
with tempfile.TemporaryDirectory(prefix="agentbox-demo-") as tmp:
|
|
193
|
+
tmp = Path(tmp)
|
|
194
|
+
key_path = tmp / "key" # throwaway key: the demo never touches ~/.agentbox
|
|
195
|
+
rec = Recorder("demo", workspace=str(tmp / "workspace"), log_dir=str(tmp / "runs"),
|
|
196
|
+
key_path=key_path)
|
|
197
|
+
print("agentbox demo: a scripted agent repeats a failing command, gets a hint, recovers.\n")
|
|
198
|
+
try:
|
|
199
|
+
out = run_agent("Make main.py run without errors.", ScriptedModel(), rec)
|
|
200
|
+
finally:
|
|
201
|
+
rec.close("demo finished") # every way a run stops is written to the log
|
|
202
|
+
print(f"\nResult: {out}\n")
|
|
203
|
+
print("--- agentbox report ---")
|
|
204
|
+
cmd_report(argparse.Namespace(logfile=rec.log_path))
|
|
205
|
+
print("\n--- agentbox verify ---")
|
|
206
|
+
code = _verify_and_print(rec.log_path, load_or_create_key(key_path))
|
|
207
|
+
print("\n(This ran in a temporary directory that is deleted when the command exits.)")
|
|
208
|
+
return code if code != EXIT_OK else (EXIT_OK if out["reason"] == "final_answer" else 1)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
212
|
+
p = argparse.ArgumentParser(prog="agentbox")
|
|
213
|
+
p.add_argument("--version", action="version", version=f"agentbox {__version__}")
|
|
214
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
215
|
+
v = sub.add_parser("verify", help="check a log for tampering")
|
|
216
|
+
v.add_argument("logfile")
|
|
217
|
+
v.set_defaults(fn=cmd_verify)
|
|
218
|
+
r = sub.add_parser("report", help="print a readable summary")
|
|
219
|
+
r.add_argument("logfile")
|
|
220
|
+
r.add_argument(
|
|
221
|
+
"--format", "-f",
|
|
222
|
+
choices=["text", "json", "html"],
|
|
223
|
+
default="text",
|
|
224
|
+
help="output format (text, json, html; default: text)",
|
|
225
|
+
)
|
|
226
|
+
r.add_argument(
|
|
227
|
+
"--output", "-o",
|
|
228
|
+
default=None,
|
|
229
|
+
help="write report to a file instead of stdout",
|
|
230
|
+
)
|
|
231
|
+
r.set_defaults(fn=cmd_report)
|
|
232
|
+
ls = sub.add_parser("list", help="list the logs in a directory (default: ./runs)")
|
|
233
|
+
ls.add_argument("directory", nargs="?", default="runs")
|
|
234
|
+
ls.set_defaults(fn=cmd_list)
|
|
235
|
+
d = sub.add_parser("demo", help="run a self-contained demo (no Docker, no API key)")
|
|
236
|
+
d.set_defaults(fn=cmd_demo)
|
|
237
|
+
return p
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def main(argv=None) -> int:
|
|
241
|
+
if argv is not None:
|
|
242
|
+
argv = [str(a) for a in argv]
|
|
243
|
+
args = build_parser().parse_args(argv)
|
|
244
|
+
try:
|
|
245
|
+
return args.fn(args)
|
|
246
|
+
except CliError as e:
|
|
247
|
+
print(f"agentbox: error: {e}", file=sys.stderr)
|
|
248
|
+
return EXIT_ERROR
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
if __name__ == "__main__":
|
|
252
|
+
sys.exit(main())
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Run agent commands inside an isolated Docker container (v0.2).
|
|
2
|
+
|
|
3
|
+
The signing key and log files stay on the HOST. The container only sees
|
|
4
|
+
the workspace folder, has no network by default, a read-only filesystem,
|
|
5
|
+
no Linux capabilities, and resource limits.
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
from .executor import build_observation
|
|
14
|
+
from .sandbox_stats import EventCounter, parse_state, parse_stats
|
|
15
|
+
from .streaming import DEFAULT_MAX_STREAM_BYTES, run_streaming
|
|
16
|
+
|
|
17
|
+
DEFAULT_IMAGE = "python:3.12-slim"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class DockerNotAvailable(RuntimeError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def container_user() -> str:
|
|
25
|
+
"""The uid:gid the container runs as.
|
|
26
|
+
|
|
27
|
+
Unix: the host user, so the mounted workspace stays writable.
|
|
28
|
+
Windows has no uid or gid. Docker Desktop makes bind mounts writable for any user,
|
|
29
|
+
so a fixed non-root user is used. The sandbox is never run as root by default.
|
|
30
|
+
"""
|
|
31
|
+
getuid, getgid = getattr(os, "getuid", None), getattr(os, "getgid", None)
|
|
32
|
+
if getuid and getgid:
|
|
33
|
+
return f"{getuid()}:{getgid()}"
|
|
34
|
+
return "1000:1000"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def docker_available() -> bool:
|
|
38
|
+
try:
|
|
39
|
+
r = subprocess.run(["docker", "info"], capture_output=True, timeout=20)
|
|
40
|
+
return r.returncode == 0
|
|
41
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DockerSandbox:
|
|
46
|
+
def __init__(self, workspace="./workspace-docker", image=DEFAULT_IMAGE,
|
|
47
|
+
network="none", memory="512m", cpus="1", pids=128, resource_stats=True, user=None):
|
|
48
|
+
self.workspace = os.path.abspath(workspace)
|
|
49
|
+
self.image, self.network = image, network
|
|
50
|
+
self.memory, self.cpus, self.pids = memory, cpus, pids
|
|
51
|
+
self.name = f"agentbox-{uuid.uuid4().hex[:8]}"
|
|
52
|
+
self.started = False
|
|
53
|
+
self.resource_stats = resource_stats # adds about a second per step
|
|
54
|
+
self.user = user or container_user() # "uid:gid"; override if needed
|
|
55
|
+
self.events = EventCounter()
|
|
56
|
+
self._events_proc = None
|
|
57
|
+
|
|
58
|
+
def describe(self) -> dict:
|
|
59
|
+
return {"backend": "docker", "image": self.image, "network": self.network,
|
|
60
|
+
"memory": self.memory, "cpus": self.cpus, "pids_limit": self.pids,
|
|
61
|
+
"read_only_rootfs": True, "cap_drop": "ALL", "resource_stats": self.resource_stats, "user": self.user}
|
|
62
|
+
|
|
63
|
+
def start(self):
|
|
64
|
+
if not docker_available():
|
|
65
|
+
raise DockerNotAvailable("Docker is not running or not installed.")
|
|
66
|
+
os.makedirs(self.workspace, exist_ok=True)
|
|
67
|
+
cmd = [
|
|
68
|
+
"docker", "run", "-d", "--rm", "--name", self.name,
|
|
69
|
+
"--network", self.network,
|
|
70
|
+
"--read-only",
|
|
71
|
+
"--tmpfs", "/tmp:rw,noexec,nosuid,size=64m",
|
|
72
|
+
"--cap-drop", "ALL",
|
|
73
|
+
"--security-opt", "no-new-privileges:true",
|
|
74
|
+
"--pids-limit", str(self.pids),
|
|
75
|
+
"--memory", self.memory, "--memory-swap", self.memory,
|
|
76
|
+
"--cpus", str(self.cpus),
|
|
77
|
+
"--user", self.user,
|
|
78
|
+
"-e", "HOME=/tmp",
|
|
79
|
+
"-v", f"{self.workspace}:/workspace",
|
|
80
|
+
"-w", "/workspace",
|
|
81
|
+
self.image, "sleep", "infinity",
|
|
82
|
+
]
|
|
83
|
+
r = subprocess.run(cmd, capture_output=True, text=True)
|
|
84
|
+
if r.returncode != 0:
|
|
85
|
+
raise DockerNotAvailable(f"Could not start container: {r.stderr.strip()}")
|
|
86
|
+
self.started = True
|
|
87
|
+
self._start_event_watcher()
|
|
88
|
+
|
|
89
|
+
def stop(self):
|
|
90
|
+
if self.started:
|
|
91
|
+
self._stop_event_watcher()
|
|
92
|
+
subprocess.run(["docker", "rm", "-f", self.name], capture_output=True)
|
|
93
|
+
self.started = False
|
|
94
|
+
|
|
95
|
+
def _start_event_watcher(self):
|
|
96
|
+
"""Follow `docker events` for this container (host side, so the agent cannot touch it)."""
|
|
97
|
+
since = str(int(time.time()) - 2) # --since avoids missing events during start-up
|
|
98
|
+
try:
|
|
99
|
+
proc = subprocess.Popen(
|
|
100
|
+
["docker", "events", "--since", since, "--filter", f"container={self.name}",
|
|
101
|
+
"--format", "{{json .}}"],
|
|
102
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
|
|
103
|
+
except OSError:
|
|
104
|
+
return
|
|
105
|
+
self._events_proc = proc
|
|
106
|
+
|
|
107
|
+
def pump():
|
|
108
|
+
for line in proc.stdout:
|
|
109
|
+
self.events.feed(line)
|
|
110
|
+
|
|
111
|
+
threading.Thread(target=pump, daemon=True).start()
|
|
112
|
+
|
|
113
|
+
def _stop_event_watcher(self):
|
|
114
|
+
if self._events_proc is not None:
|
|
115
|
+
self._events_proc.terminate()
|
|
116
|
+
self._events_proc = None
|
|
117
|
+
|
|
118
|
+
def _snapshot(self, oom_before: int, code: int, exceeded: bool) -> dict:
|
|
119
|
+
"""What Docker says about the container after a step. Never raises."""
|
|
120
|
+
snap: dict = {}
|
|
121
|
+
try:
|
|
122
|
+
r = subprocess.run(["docker", "inspect", "--format", "{{json .State}}", self.name],
|
|
123
|
+
capture_output=True, text=True, timeout=15)
|
|
124
|
+
snap.update(parse_state(r.stdout))
|
|
125
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
126
|
+
pass
|
|
127
|
+
if self.resource_stats and snap.get("running", True):
|
|
128
|
+
try:
|
|
129
|
+
r = subprocess.run(["docker", "stats", "--no-stream", "--format", "{{json .}}", self.name],
|
|
130
|
+
capture_output=True, text=True, timeout=30)
|
|
131
|
+
lines = r.stdout.strip().splitlines()
|
|
132
|
+
snap.update(parse_stats(lines[0] if lines else ""))
|
|
133
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
134
|
+
pass
|
|
135
|
+
if code == 137 and not exceeded: # a kill: give the OOM event a moment to arrive
|
|
136
|
+
deadline = time.monotonic() + 2.0
|
|
137
|
+
while self.events.count("oom") == oom_before and time.monotonic() < deadline:
|
|
138
|
+
time.sleep(0.1)
|
|
139
|
+
snap["oom_events"] = self.events.count("oom") - oom_before
|
|
140
|
+
return snap
|
|
141
|
+
|
|
142
|
+
def _kill_stragglers(self):
|
|
143
|
+
"""Kill everything in the container except PID 1 (the idle `sleep`)."""
|
|
144
|
+
try:
|
|
145
|
+
subprocess.run(["docker", "exec", self.name, "sh", "-c", 'for p in /proc/[0-9]*; do pid=${p#/proc/}; if [ "$pid" -gt 1 ] && [ "$pid" != "$$" ]; then kill -9 "$pid" 2>/dev/null; fi; done'],
|
|
146
|
+
capture_output=True, timeout=15)
|
|
147
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
def run(self, cmd: str, timeout_s: int = 60,
|
|
151
|
+
max_output_bytes: int = DEFAULT_MAX_STREAM_BYTES) -> dict:
|
|
152
|
+
if not self.started:
|
|
153
|
+
raise RuntimeError("Call start() before run().")
|
|
154
|
+
argv = ["docker", "exec", "-w", "/workspace", self.name,
|
|
155
|
+
"timeout", "-k", "2", str(timeout_s),
|
|
156
|
+
"bash", "--noprofile", "--norc", "-c", cmd]
|
|
157
|
+
oom_before = self.events.count("oom")
|
|
158
|
+
t0 = time.monotonic()
|
|
159
|
+
# The in-container `timeout` is the normal deadline (exit 124); the host-side
|
|
160
|
+
# deadline is a backstop if `docker exec` itself hangs.
|
|
161
|
+
code, out, err, timed_out, exceeded = run_streaming(
|
|
162
|
+
argv, cwd=None, timeout_s=timeout_s + 10, max_bytes=max_output_bytes,
|
|
163
|
+
cleanup=self._kill_stragglers)
|
|
164
|
+
obs = build_observation(cmd, code, out, err, timed_out or code == 124, exceeded,
|
|
165
|
+
self.workspace, int((time.monotonic() - t0) * 1000))
|
|
166
|
+
obs["sandbox"] = self._snapshot(oom_before, code, exceeded)
|
|
167
|
+
return obs
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Language-specific error extractors for error signatures.
|
|
2
|
+
|
|
3
|
+
Each extractor returns (error_class, key_message) or None when the text does not
|
|
4
|
+
look like that language's error output. Only the stable part of an error is
|
|
5
|
+
returned (class + message), never stack frames, so the same failure produces the
|
|
6
|
+
same signature even when line numbers, paths or versions change.
|
|
7
|
+
"""
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
# --- Go -------------------------------------------------------------------
|
|
11
|
+
_GO_PANIC = re.compile(r"^panic: (.+)$", re.M)
|
|
12
|
+
_GO_FATAL = re.compile(r"^fatal error: (.+)$", re.M)
|
|
13
|
+
_GO_COMPILE = re.compile(r"^\S*\.go:\d+(?::\d+)?: (.+)$", re.M)
|
|
14
|
+
|
|
15
|
+
# --- Node.js --------------------------------------------------------------
|
|
16
|
+
_NODE_HEADER = re.compile(
|
|
17
|
+
r"^(?:Uncaught\s+)?((?:[A-Za-z_$][\w$]*)?(?:Error|Exception))(?:\s+\[([A-Z0-9_]+)\])?:\s*(.*)$",
|
|
18
|
+
re.M,
|
|
19
|
+
)
|
|
20
|
+
_NODE_FRAME = re.compile(r"^\s+at \S", re.M)
|
|
21
|
+
_NODE_VERSION = re.compile(r"^Node\.js v\d+", re.M)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def extract_go(text: str):
|
|
25
|
+
m = _GO_PANIC.search(text)
|
|
26
|
+
if m:
|
|
27
|
+
return "GoPanic", m.group(1)
|
|
28
|
+
m = _GO_FATAL.search(text)
|
|
29
|
+
if m:
|
|
30
|
+
return "GoFatalError", m.group(1)
|
|
31
|
+
m = _GO_COMPILE.search(text)
|
|
32
|
+
if m:
|
|
33
|
+
return "GoCompileError", m.group(1)
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def extract_node(text: str):
|
|
38
|
+
# Require Node-style stack frames or the version footer, so a bare
|
|
39
|
+
# "Error: ..." line from some other tool is not misclassified.
|
|
40
|
+
if not (_NODE_FRAME.search(text) or _NODE_VERSION.search(text)):
|
|
41
|
+
return None
|
|
42
|
+
m = _NODE_HEADER.search(text)
|
|
43
|
+
if not m:
|
|
44
|
+
return None
|
|
45
|
+
cls, code, msg = m.groups()
|
|
46
|
+
return (f"{cls}[{code}]" if code else cls), msg
|