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/executor.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Runs a shell command and observes it from the outside."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from .error_signatures import extract_go, extract_node
|
|
8
|
+
from .redact import redact_all
|
|
9
|
+
from .streaming import DEFAULT_MAX_STREAM_BYTES, run_streaming
|
|
10
|
+
from .workspace import workspace_hash
|
|
11
|
+
|
|
12
|
+
_NORMALIZERS = [
|
|
13
|
+
(re.compile(r"0x[0-9a-fA-F]+"), "<HEX>"),
|
|
14
|
+
(re.compile(r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b"), "<UUID>"),
|
|
15
|
+
(re.compile(r"/tmp/\S+"), "/tmp/<TMP>"),
|
|
16
|
+
(re.compile(r"\d+"), "<N>"),
|
|
17
|
+
(re.compile(r"\s+"), " "),
|
|
18
|
+
]
|
|
19
|
+
_ERR_CLASS = re.compile(r"^([A-Za-z_][\w.]*(?:Error|Exception))\b", re.M)
|
|
20
|
+
MAX_KEEP = 4000
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def normalize(text: str) -> str:
|
|
24
|
+
for rx, rep in _NORMALIZERS:
|
|
25
|
+
text = rx.sub(rep, text)
|
|
26
|
+
return text.strip().lower()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def fingerprint(text: str) -> str:
|
|
30
|
+
return hashlib.sha256(normalize(text).encode()).hexdigest()[:16]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def error_signature(stderr: str, exit_code: int):
|
|
34
|
+
if exit_code == 0 or not stderr.strip():
|
|
35
|
+
return None, None
|
|
36
|
+
if "Traceback (most recent call last)" not in stderr:
|
|
37
|
+
found = extract_go(stderr) or extract_node(stderr)
|
|
38
|
+
if found:
|
|
39
|
+
cls, msg = found
|
|
40
|
+
return f"{cls}:{fingerprint(cls + ' ' + msg)}", cls
|
|
41
|
+
last = [ln for ln in stderr.strip().splitlines() if ln.strip()][-3:]
|
|
42
|
+
classes = _ERR_CLASS.findall(stderr)
|
|
43
|
+
cls = classes[-1] if classes else None
|
|
44
|
+
sig = fingerprint("\n".join(last))
|
|
45
|
+
return (f"{cls}:{sig}" if cls else sig), cls
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _clip(s: str) -> dict:
|
|
49
|
+
return {
|
|
50
|
+
"text": s if len(s) <= MAX_KEEP else s[: MAX_KEEP // 2] + "\n...[truncated]...\n" + s[-MAX_KEEP // 2 :],
|
|
51
|
+
"total_chars": len(s),
|
|
52
|
+
"sha256": hashlib.sha256(s.encode()).hexdigest(),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _txt(x) -> str:
|
|
57
|
+
if x is None:
|
|
58
|
+
return ""
|
|
59
|
+
return x.decode("utf-8", "replace") if isinstance(x, bytes) else x
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _stream_record(text: str, cap) -> dict:
|
|
63
|
+
# total_chars is kept for compatibility; it counts bytes seen on the stream.
|
|
64
|
+
return {
|
|
65
|
+
"text": text,
|
|
66
|
+
"total_chars": cap.total,
|
|
67
|
+
"total_bytes": cap.total,
|
|
68
|
+
"truncated": cap.truncated,
|
|
69
|
+
"sha256": hashlib.sha256(text.encode()).hexdigest(), # of the stored, redacted text
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def build_observation(cmd, code, out, err, timed_out, exceeded, workspace, duration_ms) -> dict:
|
|
74
|
+
"""Turn a finished command into a log record. Used by every backend."""
|
|
75
|
+
out_text, err_text = out.text(), err.text()
|
|
76
|
+
cmd, out_text, err_text, redactions = redact_all(cmd, out_text, err_text)
|
|
77
|
+
sig, cls = error_signature(err_text, code)
|
|
78
|
+
return {
|
|
79
|
+
"redactions": redactions,
|
|
80
|
+
"command": cmd,
|
|
81
|
+
"command_fingerprint": fingerprint(cmd),
|
|
82
|
+
"exit_code": code,
|
|
83
|
+
"timed_out": timed_out,
|
|
84
|
+
"output_limit_exceeded": exceeded,
|
|
85
|
+
"stdout": _stream_record(out_text, out),
|
|
86
|
+
"stderr": _stream_record(err_text, err),
|
|
87
|
+
"error_signature": sig,
|
|
88
|
+
"error_class": cls,
|
|
89
|
+
"workspace_hash": workspace_hash(workspace),
|
|
90
|
+
"duration_ms": duration_ms,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def run_command(cmd: str, workspace: str, timeout_s: int = 60,
|
|
95
|
+
max_output_bytes: int = DEFAULT_MAX_STREAM_BYTES) -> dict:
|
|
96
|
+
os.makedirs(workspace, exist_ok=True)
|
|
97
|
+
t0 = time.monotonic()
|
|
98
|
+
code, out, err, timed_out, exceeded = run_streaming(
|
|
99
|
+
["bash", "--noprofile", "--norc", "-c", cmd],
|
|
100
|
+
cwd=workspace, timeout_s=timeout_s, max_bytes=max_output_bytes)
|
|
101
|
+
return build_observation(cmd, code, out, err, timed_out, exceeded, workspace,
|
|
102
|
+
int((time.monotonic() - t0) * 1000))
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Detects an agent that is stuck."""
|
|
2
|
+
from collections import deque
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class LoopConfig:
|
|
8
|
+
window: int = 10
|
|
9
|
+
exact_warn: int = 3
|
|
10
|
+
exact_crit: int = 5
|
|
11
|
+
error_warn: int = 3
|
|
12
|
+
error_crit: int = 5
|
|
13
|
+
no_progress_warn: int = 4
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LoopDetector:
|
|
17
|
+
def __init__(self, cfg: LoopConfig | None = None):
|
|
18
|
+
self.cfg = cfg or LoopConfig()
|
|
19
|
+
self.win: deque = deque(maxlen=self.cfg.window)
|
|
20
|
+
|
|
21
|
+
def observe(self, step: dict) -> list[dict]:
|
|
22
|
+
self.win.append(step)
|
|
23
|
+
signals = []
|
|
24
|
+
signals += self._exact(step)
|
|
25
|
+
signals += self._error_repeat(step)
|
|
26
|
+
signals += self._no_progress()
|
|
27
|
+
signals += self._cycle()
|
|
28
|
+
return signals
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def _sig(kind, severity, count, hint):
|
|
32
|
+
return {"kind": kind, "severity": severity, "count": count, "hint": hint}
|
|
33
|
+
|
|
34
|
+
def _exact(self, s):
|
|
35
|
+
n = sum(1 for x in self.win if x["command_fingerprint"] == s["command_fingerprint"]
|
|
36
|
+
and x["workspace_hash"] == s["workspace_hash"]) # same command on unchanged files
|
|
37
|
+
if n >= self.cfg.exact_crit:
|
|
38
|
+
return [self._sig("loop.exact", "critical", n, "Same command repeated. Stop and change approach.")]
|
|
39
|
+
if n >= self.cfg.exact_warn:
|
|
40
|
+
return [self._sig("loop.exact", "warn", n, "Same command repeated. Try something different.")]
|
|
41
|
+
return []
|
|
42
|
+
|
|
43
|
+
def _error_repeat(self, s):
|
|
44
|
+
if not s.get("error_signature"):
|
|
45
|
+
return []
|
|
46
|
+
n = sum(1 for x in self.win if x.get("error_signature") == s["error_signature"])
|
|
47
|
+
if n >= self.cfg.error_crit:
|
|
48
|
+
return [self._sig("loop.error_repeat", "critical", n, "Same error again and again. Re-plan.")]
|
|
49
|
+
if n >= self.cfg.error_warn:
|
|
50
|
+
return [self._sig("loop.error_repeat", "warn", n, "Same error recurring. Form a new hypothesis.")]
|
|
51
|
+
return []
|
|
52
|
+
|
|
53
|
+
def _no_progress(self):
|
|
54
|
+
steps = list(self.win)
|
|
55
|
+
run = 0
|
|
56
|
+
for x in reversed(steps):
|
|
57
|
+
if x["workspace_hash"] == steps[-1]["workspace_hash"]:
|
|
58
|
+
run += 1
|
|
59
|
+
else:
|
|
60
|
+
break
|
|
61
|
+
failing = sum(1 for x in steps[-run:] if x["exit_code"] != 0)
|
|
62
|
+
if run >= self.cfg.no_progress_warn and failing >= max(1, int(run * 0.8)):
|
|
63
|
+
return [self._sig("loop.no_progress", "warn", run, "No files changed across several failing steps.")]
|
|
64
|
+
return []
|
|
65
|
+
|
|
66
|
+
def _cycle(self):
|
|
67
|
+
seq = [x["command_fingerprint"] for x in self.win]
|
|
68
|
+
for L in (2, 3):
|
|
69
|
+
if len(seq) >= 2 * L and seq[-L:] == seq[-2 * L : -L] and len(set(seq[-L:])) > 1:
|
|
70
|
+
return [self._sig("loop.cycle", "warn", 2, f"Repeating a cycle of {L} commands.")]
|
|
71
|
+
return []
|
agentbox/recorder.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""The main API. Wrap every action your agent takes in `recorder.run(...)`."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from .executor import run_command
|
|
5
|
+
from .loop_detector import LoopConfig, LoopDetector
|
|
6
|
+
from .redact import merge_counts, redact
|
|
7
|
+
from .signer import ChainWriter, load_or_create_key
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _crash_kind(sbx: dict):
|
|
11
|
+
if sbx.get("oom_events", 0) > 0 or sbx.get("oom_killed"):
|
|
12
|
+
return "oom_kill"
|
|
13
|
+
if sbx.get("running") is False:
|
|
14
|
+
return "container_stopped"
|
|
15
|
+
return None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Recorder:
|
|
19
|
+
def __init__(self, session_id: str, workspace: str = "./workspace",
|
|
20
|
+
log_dir: str = "./runs", key_path=None,
|
|
21
|
+
loop_cfg: LoopConfig | None = None, sandbox=None,
|
|
22
|
+
overwrite: bool = False, agent_id: str | None = None):
|
|
23
|
+
self.session_id, self.workspace, self.sandbox = session_id, workspace, sandbox
|
|
24
|
+
self.agent_id = agent_id
|
|
25
|
+
self.log_path = Path(log_dir) / f"{session_id}.ndjson"
|
|
26
|
+
self.writer = ChainWriter(self.log_path, load_or_create_key(key_path), overwrite=overwrite)
|
|
27
|
+
self.detector = LoopDetector(loop_cfg)
|
|
28
|
+
self.step_index = 0
|
|
29
|
+
isolation = sandbox.describe() if sandbox else {"backend": "local"}
|
|
30
|
+
start = {"session_id": session_id, "isolation": isolation}
|
|
31
|
+
if agent_id:
|
|
32
|
+
start["agent_id"] = agent_id
|
|
33
|
+
self.writer.append("session.start", start)
|
|
34
|
+
|
|
35
|
+
def run(self, command: str, thought: str = "", timeout_s: int = 60, *,
|
|
36
|
+
tool: str | None = None, parent_step: int | None = None,
|
|
37
|
+
usage: dict | None = None) -> dict:
|
|
38
|
+
self.step_index += 1
|
|
39
|
+
if self.sandbox:
|
|
40
|
+
obs = self.sandbox.run(command, timeout_s)
|
|
41
|
+
else:
|
|
42
|
+
obs = run_command(command, self.workspace, timeout_s)
|
|
43
|
+
sbx = obs.pop("sandbox", None) # reported as separate events, not inside the step
|
|
44
|
+
thought, t_counts = redact(thought)
|
|
45
|
+
redactions = merge_counts(obs.get("redactions", {}), t_counts)
|
|
46
|
+
step = {"session_id": self.session_id, "step_index": self.step_index,
|
|
47
|
+
"thought": thought, **obs, "redactions": redactions}
|
|
48
|
+
if self.agent_id:
|
|
49
|
+
step["agent_id"] = self.agent_id
|
|
50
|
+
if tool:
|
|
51
|
+
step["tool"] = tool
|
|
52
|
+
if parent_step is not None:
|
|
53
|
+
step["parent_step_index"] = int(parent_step)
|
|
54
|
+
if usage:
|
|
55
|
+
step["usage"] = {str(k): int(v) for k, v in usage.items()} # from the caller, never computed
|
|
56
|
+
self.writer.append("step", step)
|
|
57
|
+
if sbx:
|
|
58
|
+
metrics = {k: sbx.get(k) for k in ("mem_bytes", "mem_limit_bytes", "mem_pct", "cpu_pct", "pids")}
|
|
59
|
+
self.writer.append("sandbox.resource", {"step_index": self.step_index, **metrics})
|
|
60
|
+
kind = _crash_kind(sbx)
|
|
61
|
+
if kind:
|
|
62
|
+
self.writer.append("sandbox.crash", {"step_index": self.step_index, "kind": kind,
|
|
63
|
+
"exit_code": obs.get("exit_code"), **sbx})
|
|
64
|
+
signals = self.detector.observe(step)
|
|
65
|
+
for s in signals:
|
|
66
|
+
self.writer.append("analysis.signal", {"step_index": self.step_index, **s})
|
|
67
|
+
return {"observation": obs, "signals": signals, "sandbox": sbx}
|
|
68
|
+
|
|
69
|
+
def close(self, reason: str = "done"):
|
|
70
|
+
self.writer.append("session.end", {"reason": reason, "steps": self.step_index})
|
|
71
|
+
self.writer.write_manifest()
|
|
72
|
+
if self.sandbox:
|
|
73
|
+
self.sandbox.stop()
|
agentbox/redact.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Redact secrets from text before it is logged and signed.
|
|
2
|
+
|
|
3
|
+
Once a record is signed it cannot be edited, so secrets must be removed first.
|
|
4
|
+
Only a count per pattern is recorded, never the value. Patterns are heuristics:
|
|
5
|
+
they will miss some secrets and occasionally hide harmless values. Add your own
|
|
6
|
+
with add_pattern().
|
|
7
|
+
"""
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
_NAME = (r"[A-Za-z0-9_.-]*(?:password|passwd|secret|token|api[_-]?key|"
|
|
11
|
+
r"access[_-]?key|private[_-]?key)[A-Za-z0-9_.-]*")
|
|
12
|
+
_NOT_DONE = r"(?!\[REDACTED:)" # do not re-redact text a specific pattern already replaced
|
|
13
|
+
|
|
14
|
+
# (name, compiled regex, replacement). Specific token formats come first,
|
|
15
|
+
# context-based patterns (headers, URLs, KEY=value) last.
|
|
16
|
+
PATTERNS = [
|
|
17
|
+
("private_key", re.compile(
|
|
18
|
+
r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?(?:-----END [A-Z ]*PRIVATE KEY-----|\Z)", re.S),
|
|
19
|
+
"[REDACTED:private_key]"),
|
|
20
|
+
("aws_key", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"), "[REDACTED:aws_key]"),
|
|
21
|
+
("github_token", re.compile(
|
|
22
|
+
r"\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{20,})\b"),
|
|
23
|
+
"[REDACTED:github_token]"),
|
|
24
|
+
("api_key", re.compile(r"\bsk-[A-Za-z0-9_-]{20,}"), "[REDACTED:api_key]"),
|
|
25
|
+
("slack_token", re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}"), "[REDACTED:slack_token]"),
|
|
26
|
+
("google_key", re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"), "[REDACTED:google_key]"),
|
|
27
|
+
("jwt", re.compile(
|
|
28
|
+
r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"),
|
|
29
|
+
"[REDACTED:jwt]"),
|
|
30
|
+
("auth_header", re.compile(
|
|
31
|
+
r"(?i)(\bauthorization\s*[:=]\s*(?:bearer|basic|token)\s+)" + _NOT_DONE + r"[^\s'\"]+"),
|
|
32
|
+
r"\1[REDACTED:auth_header]"),
|
|
33
|
+
("url_password", re.compile(
|
|
34
|
+
r"(?i)\b([a-z][a-z0-9+.-]*://[^\s:/@]+:)" + _NOT_DONE + r"[^\s@/]+(@)"),
|
|
35
|
+
r"\1[REDACTED:url_password]\2"),
|
|
36
|
+
("secret_assignment", re.compile(
|
|
37
|
+
r"(?i)(\b" + _NAME + r"\s*[:=]\s*)(['\"]?)" + _NOT_DONE + r"[^\s'\";&]{6,}(\2)"),
|
|
38
|
+
r"\1\2[REDACTED:secret_assignment]\3"),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def add_pattern(name: str, regex: str, replacement: str | None = None):
|
|
43
|
+
"""Register an extra pattern, e.g. add_pattern('internal_id', r'INT-[0-9]{8}')."""
|
|
44
|
+
PATTERNS.append((name, re.compile(regex), replacement or f"[REDACTED:{name}]"))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def redact(text: str):
|
|
48
|
+
"""Return (clean_text, counts) where counts maps pattern name to matches."""
|
|
49
|
+
counts: dict[str, int] = {}
|
|
50
|
+
if not text:
|
|
51
|
+
return text, counts
|
|
52
|
+
for name, rx, repl in PATTERNS:
|
|
53
|
+
text, n = rx.subn(repl, text)
|
|
54
|
+
if n:
|
|
55
|
+
counts[name] = counts.get(name, 0) + n
|
|
56
|
+
return text, counts
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def merge_counts(*parts: dict) -> dict:
|
|
60
|
+
total: dict[str, int] = {}
|
|
61
|
+
for part in parts:
|
|
62
|
+
for k, v in part.items():
|
|
63
|
+
total[k] = total.get(k, 0) + v
|
|
64
|
+
return total
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def redact_all(cmd: str, out: str, err: str):
|
|
68
|
+
cmd, c1 = redact(cmd)
|
|
69
|
+
out, c2 = redact(out)
|
|
70
|
+
err, c3 = redact(err)
|
|
71
|
+
return cmd, out, err, merge_counts(c1, c2, c3)
|
agentbox/report.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Report generation for agentbox runs: text, structured JSON, and self-contained HTML."""
|
|
2
|
+
import html
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any, Dict, List
|
|
6
|
+
|
|
7
|
+
_RED = "\033[31m"
|
|
8
|
+
_YELLOW = "\033[33m"
|
|
9
|
+
_RESET = "\033[0m"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _paint(text: str, color: str, enabled: bool) -> str:
|
|
13
|
+
return f"{color}{text}{_RESET}" if enabled else text
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_report_data(events: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
17
|
+
"""Aggregate raw log events into a clean structured dictionary."""
|
|
18
|
+
session_info: Dict[str, Any] = {
|
|
19
|
+
"session_id": "unknown",
|
|
20
|
+
"isolation": {},
|
|
21
|
+
"agent_id": None,
|
|
22
|
+
"started_at": None,
|
|
23
|
+
"ended_at": None,
|
|
24
|
+
"duration_s": None,
|
|
25
|
+
"reason": None,
|
|
26
|
+
}
|
|
27
|
+
steps: List[Dict[str, Any]] = []
|
|
28
|
+
signals: List[Dict[str, Any]] = []
|
|
29
|
+
resources: List[Dict[str, Any]] = []
|
|
30
|
+
crashes: List[Dict[str, Any]] = []
|
|
31
|
+
total_redactions = 0
|
|
32
|
+
|
|
33
|
+
for ev in events:
|
|
34
|
+
etype = ev.get("event_type")
|
|
35
|
+
ts = ev.get("ts")
|
|
36
|
+
data = ev.get("data") or {}
|
|
37
|
+
|
|
38
|
+
if etype == "session.start":
|
|
39
|
+
session_info["session_id"] = data.get("session_id", "unknown")
|
|
40
|
+
session_info["isolation"] = data.get("isolation", {})
|
|
41
|
+
session_info["agent_id"] = data.get("agent_id")
|
|
42
|
+
session_info["started_at"] = ts
|
|
43
|
+
elif etype == "session.end":
|
|
44
|
+
session_info["ended_at"] = ts
|
|
45
|
+
session_info["reason"] = data.get("reason")
|
|
46
|
+
elif etype == "step":
|
|
47
|
+
steps.append(data)
|
|
48
|
+
red = data.get("redactions")
|
|
49
|
+
if isinstance(red, dict):
|
|
50
|
+
total_redactions += sum(red.values())
|
|
51
|
+
elif etype == "analysis.signal":
|
|
52
|
+
signals.append(data)
|
|
53
|
+
elif etype == "sandbox.resource":
|
|
54
|
+
resources.append(data)
|
|
55
|
+
elif etype == "sandbox.crash":
|
|
56
|
+
crashes.append(data)
|
|
57
|
+
|
|
58
|
+
if session_info["started_at"] is not None and session_info["ended_at"] is not None:
|
|
59
|
+
session_info["duration_s"] = round(session_info["ended_at"] - session_info["started_at"], 3)
|
|
60
|
+
|
|
61
|
+
successful = sum(1 for s in steps if s.get("exit_code") == 0)
|
|
62
|
+
failed = len(steps) - successful
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
"session": session_info,
|
|
66
|
+
"summary": {
|
|
67
|
+
"total_steps": len(steps),
|
|
68
|
+
"successful_steps": successful,
|
|
69
|
+
"failed_steps": failed,
|
|
70
|
+
"signals": len(signals),
|
|
71
|
+
"crashes": len(crashes),
|
|
72
|
+
"total_redactions": total_redactions,
|
|
73
|
+
},
|
|
74
|
+
"steps": steps,
|
|
75
|
+
"signals": signals,
|
|
76
|
+
"sandbox_resources": resources,
|
|
77
|
+
"sandbox_crashes": crashes,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def render_text(data: Dict[str, Any], color: bool = False) -> str:
|
|
82
|
+
"""Render the human-readable terminal report matching standard cli format."""
|
|
83
|
+
lines: List[str] = []
|
|
84
|
+
steps = data.get("steps", [])
|
|
85
|
+
signals = data.get("signals", [])
|
|
86
|
+
crashes = data.get("sandbox_crashes", [])
|
|
87
|
+
|
|
88
|
+
for d in steps:
|
|
89
|
+
ok = d.get("exit_code") == 0
|
|
90
|
+
mark = "ok " if ok else _paint("ERR", _RED, color)
|
|
91
|
+
cmd_str = (d.get("command") or "")[:70]
|
|
92
|
+
step_idx = d.get("step_index", 0)
|
|
93
|
+
exit_code = d.get("exit_code", -1)
|
|
94
|
+
lines.append(f"[{step_idx:>3}] {mark} exit={exit_code:<3} {cmd_str}")
|
|
95
|
+
|
|
96
|
+
lines.append(f"\n{len(steps)} steps, {len(signals)} signals")
|
|
97
|
+
|
|
98
|
+
for s in signals:
|
|
99
|
+
line = f" step {s['step_index']}: [{s['severity']}] {s['kind']} (x{s['count']}) - {s['hint']}"
|
|
100
|
+
if s.get("severity") == "critical":
|
|
101
|
+
line = _paint(line, _RED, color)
|
|
102
|
+
elif s.get("severity") == "warn":
|
|
103
|
+
line = _paint(line, _YELLOW, color)
|
|
104
|
+
lines.append(line)
|
|
105
|
+
|
|
106
|
+
for c in crashes:
|
|
107
|
+
line = f" step {c.get('step_index', '?')}: crash [{c.get('kind', 'unknown')}] exit={c.get('exit_code')} (oom={c.get('oom_killed')})"
|
|
108
|
+
line = _paint(line, _RED, color)
|
|
109
|
+
lines.append(line)
|
|
110
|
+
|
|
111
|
+
return "\n".join(lines) + "\n"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def render_json(data: Dict[str, Any]) -> str:
|
|
115
|
+
"""Render report as indented JSON."""
|
|
116
|
+
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
|
117
|
+
|
|
118
|
+
_HTML_CSS = """
|
|
119
|
+
:root {
|
|
120
|
+
--bg: #0d1117; --card-bg: #161b22; --border: #30363d;
|
|
121
|
+
--text: #c9d1d9; --text-muted: #8b949e; --accent: #58a6ff;
|
|
122
|
+
--green: #3fb950; --red: #f85149; --yellow: #d29922; --mono: monospace;
|
|
123
|
+
}
|
|
124
|
+
@media (prefers-color-scheme: light) {
|
|
125
|
+
:root {
|
|
126
|
+
--bg: #f6f8fa; --card-bg: #ffffff; --border: #d0d7de;
|
|
127
|
+
--text: #1f2328; --text-muted: #656d76; --accent: #0969da;
|
|
128
|
+
--green: #1a7f37; --red: #cf222e; --yellow: #9a6700;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
132
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
133
|
+
background: var(--bg); color: var(--text); padding: 2rem 1rem; line-height: 1.5; }
|
|
134
|
+
.container { max-width: 960px; margin: 0 auto; }
|
|
135
|
+
header { border-bottom: 1px solid var(--border); padding-bottom: 1.5rem; margin-bottom: 2rem; }
|
|
136
|
+
h1 { font-size: 1.75rem; margin-bottom: 0.5rem; }
|
|
137
|
+
.subtitle { color: var(--text-muted); font-size: 0.9rem; }
|
|
138
|
+
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
|
|
139
|
+
.card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; padding: 1rem; }
|
|
140
|
+
.card-label { font-size: 0.75rem; text-transform: uppercase; color: var(--text-muted); }
|
|
141
|
+
.card-val { font-size: 1.5rem; font-weight: bold; margin-top: 0.25rem; }
|
|
142
|
+
.badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
|
|
143
|
+
.badge-ok { background: rgba(63,185,80,0.15); color: var(--green); }
|
|
144
|
+
.badge-err { background: rgba(248,81,73,0.15); color: var(--red); }
|
|
145
|
+
.badge-warn { background: rgba(210,153,34,0.15); color: var(--yellow); }
|
|
146
|
+
section { margin-bottom: 2.5rem; }
|
|
147
|
+
h2 { font-size: 1.25rem; margin-bottom: 1rem; border-bottom: 1px solid var(--border); padding-bottom: 0.5rem; }
|
|
148
|
+
.timeline { display: flex; flex-direction: column; gap: 0.75rem; }
|
|
149
|
+
.step-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; padding: 0.75rem 1rem; }
|
|
150
|
+
.step-header { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; }
|
|
151
|
+
.step-cmd { font-family: var(--mono); font-size: 0.9rem; color: var(--accent); word-break: break-all; }
|
|
152
|
+
.step-meta { font-size: 0.8rem; color: var(--text-muted); white-space: nowrap; }
|
|
153
|
+
details { margin-top: 0.5rem; border-top: 1px dashed var(--border); padding-top: 0.5rem; font-size: 0.85rem; }
|
|
154
|
+
summary { cursor: pointer; color: var(--text-muted); user-select: none; }
|
|
155
|
+
summary:hover { color: var(--text); }
|
|
156
|
+
pre { font-family: var(--mono); background: rgba(0,0,0,0.15); padding: 0.5rem; border-radius: 4px; overflow-x: auto; margin-top: 0.5rem; }
|
|
157
|
+
.signal-box { background: rgba(210,153,34,0.1); border-left: 4px solid var(--yellow); padding: 0.75rem 1rem; margin-bottom: 0.75rem; border-radius: 0 4px 4px 0; }
|
|
158
|
+
.signal-box.critical { background: rgba(248,81,73,0.1); border-left-color: var(--red); }
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def render_html(data: Dict[str, Any]) -> str:
|
|
163
|
+
"""Render a single, completely self-contained HTML report with zero external assets."""
|
|
164
|
+
sess = data.get("session", {})
|
|
165
|
+
summary = data.get("summary", {})
|
|
166
|
+
steps = data.get("steps", [])
|
|
167
|
+
signals = data.get("signals", [])
|
|
168
|
+
crashes = data.get("sandbox_crashes", [])
|
|
169
|
+
|
|
170
|
+
started_str = (datetime.fromtimestamp(sess["started_at"]).strftime("%Y-%m-%d %H:%M:%S")
|
|
171
|
+
if sess.get("started_at") else "unknown")
|
|
172
|
+
iso = sess.get("isolation", {})
|
|
173
|
+
backend_str = iso.get("backend", "local")
|
|
174
|
+
if iso.get("image"):
|
|
175
|
+
backend_str += f" ({iso.get('image')})"
|
|
176
|
+
|
|
177
|
+
out = [
|
|
178
|
+
"<!DOCTYPE html>",
|
|
179
|
+
'<html lang="en">',
|
|
180
|
+
"<head>",
|
|
181
|
+
'<meta charset="utf-8">',
|
|
182
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1.0">',
|
|
183
|
+
f"<title>agentbox report - {html.escape(str(sess.get('session_id')))}</title>",
|
|
184
|
+
f"<style>{_HTML_CSS}</style>",
|
|
185
|
+
"</head>",
|
|
186
|
+
"<body>",
|
|
187
|
+
'<div class="container">',
|
|
188
|
+
"<header>",
|
|
189
|
+
"<h1>agentbox flight report</h1>",
|
|
190
|
+
f'<div class="subtitle">Session: <strong>{html.escape(str(sess.get("session_id")))}</strong> • '
|
|
191
|
+
f'Started: {html.escape(started_str)} • '
|
|
192
|
+
f'Backend: {html.escape(backend_str)}'
|
|
193
|
+
f'{(" • Agent: " + html.escape(str(sess["agent_id"]))) if sess.get("agent_id") else ""}</div>',
|
|
194
|
+
"</header>",
|
|
195
|
+
'<div class="cards">',
|
|
196
|
+
f'<div class="card"><div class="card-label">Steps</div><div class="card-val">{summary.get("total_steps", 0)}</div></div>',
|
|
197
|
+
f'<div class="card"><div class="card-label">Passed</div><div class="card-val">{summary.get("successful_steps", 0)}</div></div>',
|
|
198
|
+
f'<div class="card"><div class="card-label">Failed</div><div class="card-val">{summary.get("failed_steps", 0)}</div></div>',
|
|
199
|
+
f'<div class="card"><div class="card-label">Signals</div><div class="card-val">{summary.get("signals", 0)}</div></div>',
|
|
200
|
+
f'<div class="card"><div class="card-label">Duration</div><div class="card-val">{sess.get("duration_s", 0)}s</div></div>',
|
|
201
|
+
"</div>",
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
if signals:
|
|
205
|
+
out.append("<section><h2>Detector Signals</h2>")
|
|
206
|
+
for s in signals:
|
|
207
|
+
sev = s.get("severity", "warn")
|
|
208
|
+
cls = "critical" if sev == "critical" else "warn"
|
|
209
|
+
out.append(
|
|
210
|
+
f'<div class="signal-box {cls}">'
|
|
211
|
+
f'<strong>Step {s.get("step_index")}: [{html.escape(sev)}] {html.escape(s.get("kind", ""))}</strong> '
|
|
212
|
+
f'(fired {s.get("count")}x)<br>'
|
|
213
|
+
f'{html.escape(s.get("hint", ""))}'
|
|
214
|
+
f'</div>'
|
|
215
|
+
)
|
|
216
|
+
out.append("</section>")
|
|
217
|
+
|
|
218
|
+
if crashes:
|
|
219
|
+
out.append("<section><h2>Sandbox Crashes</h2>")
|
|
220
|
+
for c in crashes:
|
|
221
|
+
out.append(
|
|
222
|
+
f'<div class="signal-box critical">'
|
|
223
|
+
f'<strong>Step {c.get("step_index", "?")}: Crash - {html.escape(str(c.get("kind")))}</strong> '
|
|
224
|
+
f'(exit {c.get("exit_code")}, oom={c.get("oom_killed")})'
|
|
225
|
+
f'</div>'
|
|
226
|
+
)
|
|
227
|
+
out.append("</section>")
|
|
228
|
+
|
|
229
|
+
out.append("<section><h2>Step Timeline</h2><div class=\"timeline\">")
|
|
230
|
+
for s in steps:
|
|
231
|
+
ec = s.get("exit_code", 0)
|
|
232
|
+
badge_cls = "badge-ok" if ec == 0 else "badge-err"
|
|
233
|
+
badge_txt = "OK" if ec == 0 else f"ERR ({ec})"
|
|
234
|
+
dur = f"{s.get('duration_ms', 0)}ms"
|
|
235
|
+
cmd = s.get("command", "")
|
|
236
|
+
out.append(
|
|
237
|
+
f'<div class="step-card">'
|
|
238
|
+
f'<div class="step-header">'
|
|
239
|
+
f'<div><span class="badge {badge_cls}">{badge_txt}</span> '
|
|
240
|
+
f'<strong>#{s.get("step_index")}</strong> '
|
|
241
|
+
f'<span class="step-cmd">{html.escape(cmd)}</span></div>'
|
|
242
|
+
f'<span class="step-meta">{html.escape(dur)}</span>'
|
|
243
|
+
f'</div>'
|
|
244
|
+
)
|
|
245
|
+
details_items = []
|
|
246
|
+
if s.get("thought"):
|
|
247
|
+
details_items.append(f'<p><strong>Thought:</strong> {html.escape(s["thought"])}</p>')
|
|
248
|
+
if s.get("tool"):
|
|
249
|
+
details_items.append(f'<p><strong>Tool:</strong> {html.escape(str(s["tool"]))}</p>')
|
|
250
|
+
if s.get("error_class"):
|
|
251
|
+
details_items.append(f'<p><strong>Error Class:</strong> {html.escape(s["error_class"])}</p>')
|
|
252
|
+
if s.get("error_signature"):
|
|
253
|
+
details_items.append(f'<p><strong>Error Signature:</strong> <code>{html.escape(s["error_signature"])}</code></p>')
|
|
254
|
+
stdout_text = (s.get("stdout") or {}).get("text")
|
|
255
|
+
if stdout_text:
|
|
256
|
+
details_items.append(f'<p><strong>stdout:</strong><pre>{html.escape(stdout_text)}</pre></p>')
|
|
257
|
+
stderr_text = (s.get("stderr") or {}).get("text")
|
|
258
|
+
if stderr_text:
|
|
259
|
+
details_items.append(f'<p><strong>stderr:</strong><pre>{html.escape(stderr_text)}</pre></p>')
|
|
260
|
+
if details_items:
|
|
261
|
+
out.append(f'<details><summary>Details & Output</summary>{"".join(details_items)}</details>')
|
|
262
|
+
out.append("</div>")
|
|
263
|
+
|
|
264
|
+
out.append("</div></section>")
|
|
265
|
+
out.append("</div></body></html>\n")
|
|
266
|
+
return "\n".join(out)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Parse what Docker reports about a container: state, resource use and events.
|
|
2
|
+
|
|
3
|
+
These run on the HOST, so the sandboxed agent cannot influence them.
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import threading
|
|
8
|
+
|
|
9
|
+
_UNITS = {"b": 1, "kb": 1000, "mb": 1000 ** 2, "gb": 1000 ** 3, "tb": 1000 ** 4,
|
|
10
|
+
"kib": 1024, "mib": 1024 ** 2, "gib": 1024 ** 3, "tib": 1024 ** 4}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_size(text):
|
|
14
|
+
"""'12.5MiB' -> bytes. Returns None if it cannot be read."""
|
|
15
|
+
m = re.fullmatch(r"\s*([0-9]*\.?[0-9]+)\s*([A-Za-z]*)\s*", text or "")
|
|
16
|
+
if not m:
|
|
17
|
+
return None
|
|
18
|
+
unit = (m.group(2) or "b").lower()
|
|
19
|
+
if unit not in _UNITS:
|
|
20
|
+
return None
|
|
21
|
+
return int(float(m.group(1)) * _UNITS[unit])
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _pct(text):
|
|
25
|
+
try:
|
|
26
|
+
return float(str(text).strip().rstrip("%"))
|
|
27
|
+
except ValueError:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_stats(line: str) -> dict:
|
|
32
|
+
"""One line of `docker stats --no-stream --format '{{json .}}'`."""
|
|
33
|
+
try:
|
|
34
|
+
d = json.loads(line)
|
|
35
|
+
except ValueError:
|
|
36
|
+
return {}
|
|
37
|
+
used, _, limit = str(d.get("MemUsage", "")).partition("/")
|
|
38
|
+
try:
|
|
39
|
+
pids = int(d.get("PIDs"))
|
|
40
|
+
except (TypeError, ValueError):
|
|
41
|
+
pids = None
|
|
42
|
+
return {"mem_bytes": parse_size(used), "mem_limit_bytes": parse_size(limit),
|
|
43
|
+
"mem_pct": _pct(d.get("MemPerc")), "cpu_pct": _pct(d.get("CPUPerc")),
|
|
44
|
+
"pids": pids}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_state(text: str) -> dict:
|
|
48
|
+
"""Output of `docker inspect --format '{{json .State}}'`."""
|
|
49
|
+
try:
|
|
50
|
+
s = json.loads(text)
|
|
51
|
+
except ValueError:
|
|
52
|
+
return {}
|
|
53
|
+
if not isinstance(s, dict):
|
|
54
|
+
return {}
|
|
55
|
+
return {"running": bool(s.get("Running")), "oom_killed": bool(s.get("OOMKilled")),
|
|
56
|
+
"exit_code": s.get("ExitCode"), "error": s.get("Error") or None}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class EventCounter:
|
|
60
|
+
"""Counts container events (oom, die, ...) from `docker events --format '{{json .}}'`."""
|
|
61
|
+
|
|
62
|
+
def __init__(self):
|
|
63
|
+
self._lock = threading.Lock()
|
|
64
|
+
self._counts = {}
|
|
65
|
+
|
|
66
|
+
def feed(self, line: str):
|
|
67
|
+
try:
|
|
68
|
+
d = json.loads(line)
|
|
69
|
+
except ValueError:
|
|
70
|
+
return
|
|
71
|
+
if not isinstance(d, dict):
|
|
72
|
+
return
|
|
73
|
+
action = str(d.get("Action") or d.get("status") or "")
|
|
74
|
+
if action:
|
|
75
|
+
with self._lock:
|
|
76
|
+
self._counts[action] = self._counts.get(action, 0) + 1
|
|
77
|
+
|
|
78
|
+
def count(self, action: str) -> int:
|
|
79
|
+
with self._lock:
|
|
80
|
+
return self._counts.get(action, 0)
|