doesitfire 0.1.2__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.
- doesitfire/__init__.py +3 -0
- doesitfire/agents.py +310 -0
- doesitfire/cli.py +263 -0
- doesitfire/config.py +251 -0
- doesitfire/data/demo-results.json +1393 -0
- doesitfire/docker/Dockerfile +24 -0
- doesitfire/docker/egress-proxy.mjs +113 -0
- doesitfire/docker/entrypoint.sh +14 -0
- doesitfire/errors.py +17 -0
- doesitfire/html.py +26 -0
- doesitfire/metrics.py +117 -0
- doesitfire/providers.py +121 -0
- doesitfire/report.py +175 -0
- doesitfire/runner.py +283 -0
- doesitfire/sandbox.py +276 -0
- doesitfire/templates/report.html +341 -0
- doesitfire-0.1.2.dist-info/METADATA +306 -0
- doesitfire-0.1.2.dist-info/RECORD +21 -0
- doesitfire-0.1.2.dist-info/WHEEL +4 -0
- doesitfire-0.1.2.dist-info/entry_points.txt +2 -0
- doesitfire-0.1.2.dist-info/licenses/LICENSE +21 -0
doesitfire/runner.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Run the activation matrix: skills x prompts x agents x repeats."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import platform
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .agents import CLI_VERSIONS, EXPERIMENTAL, Agent, Observation, make_agent
|
|
14
|
+
from .config import Config
|
|
15
|
+
from .errors import BudgetExceeded, DoesItFireError
|
|
16
|
+
from .providers import Provider, get_provider, resolve_key
|
|
17
|
+
from .sandbox import IMAGE, Sandbox, build_image, check_docker, image_exists, stage_skills
|
|
18
|
+
|
|
19
|
+
# Used for budgeting before the first real usage figure is known (doesitfire's plan
|
|
20
|
+
# assumed ~20k input tokens per CLI run).
|
|
21
|
+
EST_INPUT_TOKENS = 20_000
|
|
22
|
+
EST_OUTPUT_TOKENS = 1_000
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run_cost(provider: Provider, agent_cfg, obs: Observation) -> float:
|
|
26
|
+
if provider.local:
|
|
27
|
+
return 0.0
|
|
28
|
+
pin, pout = agent_cfg.price_input or 0.0, agent_cfg.price_output or 0.0
|
|
29
|
+
computed = obs.input_tokens * pin / 1e6 + obs.output_tokens * pout / 1e6
|
|
30
|
+
if not obs.usage_seen: # killed before reporting usage: charge the estimate, never zero
|
|
31
|
+
computed = max(computed, EST_INPUT_TOKENS * pin / 1e6 + EST_OUTPUT_TOKENS * pout / 1e6)
|
|
32
|
+
return round(max(computed, obs.reported_cost or 0.0), 6)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def preflight(cfg: Config) -> dict[str, Provider]:
|
|
36
|
+
"""Validate keys, prices and model availability before any container starts."""
|
|
37
|
+
providers: dict[str, Provider] = {}
|
|
38
|
+
for a in cfg.agents:
|
|
39
|
+
p = get_provider(a.provider)
|
|
40
|
+
resolve_key(a.name, p) # raises a clear error naming the missing variable
|
|
41
|
+
if p.paid and (a.price_input is None or a.price_output is None):
|
|
42
|
+
raise DoesItFireError(
|
|
43
|
+
f"Agent '{a.name}' uses paid provider '{p.name}' but has no price. Add "
|
|
44
|
+
"'price: {input: <USD per 1M tokens>, output: <USD per 1M tokens>}' to it in "
|
|
45
|
+
"doesitfire.yaml so --max-cost can be enforced."
|
|
46
|
+
)
|
|
47
|
+
if p.name == "ollama":
|
|
48
|
+
check_ollama_model(p, a.model)
|
|
49
|
+
providers[a.name] = p
|
|
50
|
+
return providers
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def check_ollama_model(p: Provider, model: str) -> None:
|
|
54
|
+
import openai
|
|
55
|
+
|
|
56
|
+
client = openai.OpenAI(base_url=p.openai_base, api_key="ollama", timeout=10, max_retries=0)
|
|
57
|
+
try:
|
|
58
|
+
ids = {m.id for m in client.models.list()}
|
|
59
|
+
except (openai.APIConnectionError, openai.APITimeoutError):
|
|
60
|
+
raise DoesItFireError(
|
|
61
|
+
f"Ollama is not reachable at {p.openai_base.removesuffix('/v1')}. Start it with 'ollama serve' "
|
|
62
|
+
"or set OLLAMA_HOST. On Linux, make it listen on all interfaces (OLLAMA_HOST=0.0.0.0) so "
|
|
63
|
+
"containers can reach it."
|
|
64
|
+
) from None
|
|
65
|
+
except openai.APIError as exc:
|
|
66
|
+
raise DoesItFireError(f"Ollama answered with an error while listing models: {exc}") from None
|
|
67
|
+
if model not in ids and f"{model}:latest" not in ids:
|
|
68
|
+
raise DoesItFireError(
|
|
69
|
+
f"Model '{model}' not found in Ollama. Available: {', '.join(sorted(ids)) or 'none'}. "
|
|
70
|
+
"Agent prompts need a 16k context; create the model with: "
|
|
71
|
+
"ollama pull qwen3:4b && ollama create qwen3:4b-16k -f ollama/Modelfile"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def hardware() -> str | None:
|
|
76
|
+
"""A short CPU and memory label, e.g. 'Apple M4, 16 GB'. Best effort, no network."""
|
|
77
|
+
import os
|
|
78
|
+
import subprocess
|
|
79
|
+
|
|
80
|
+
cpu, mem = None, None
|
|
81
|
+
try:
|
|
82
|
+
if platform.system() == "Darwin":
|
|
83
|
+
cpu = subprocess.run(
|
|
84
|
+
["sysctl", "-n", "machdep.cpu.brand_string"], capture_output=True, text=True, timeout=5
|
|
85
|
+
).stdout.strip()
|
|
86
|
+
mem = int(
|
|
87
|
+
subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, text=True, timeout=5).stdout.strip()
|
|
88
|
+
or 0
|
|
89
|
+
)
|
|
90
|
+
elif platform.system() == "Linux":
|
|
91
|
+
with open("/proc/cpuinfo", encoding="utf-8") as fh:
|
|
92
|
+
for line in fh:
|
|
93
|
+
if line.lower().startswith("model name"):
|
|
94
|
+
cpu = line.split(":", 1)[1].strip()
|
|
95
|
+
break
|
|
96
|
+
mem = os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES")
|
|
97
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
98
|
+
pass
|
|
99
|
+
parts = [p for p in (cpu, f"{round(mem / 2**30)} GB" if mem else None) if p]
|
|
100
|
+
return ", ".join(parts) or None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def host_info(docker_version: str | None) -> dict:
|
|
104
|
+
return {
|
|
105
|
+
"os": platform.system(),
|
|
106
|
+
"arch": platform.machine(),
|
|
107
|
+
"hardware": hardware(),
|
|
108
|
+
"python": platform.python_version(),
|
|
109
|
+
"docker": docker_version,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def run_matrix(
|
|
114
|
+
cfg: Config,
|
|
115
|
+
max_cost: float = 1.0,
|
|
116
|
+
log: Callable[[str], None] = print,
|
|
117
|
+
progress: Callable[[dict], None] | None = None,
|
|
118
|
+
checkpoint: Callable[[dict], None] | None = None,
|
|
119
|
+
logs_dir: Path | None = None,
|
|
120
|
+
resume: dict | None = None,
|
|
121
|
+
image: str = IMAGE,
|
|
122
|
+
rebuild: bool = False,
|
|
123
|
+
) -> dict:
|
|
124
|
+
providers = preflight(cfg)
|
|
125
|
+
docker_version = check_docker()
|
|
126
|
+
if rebuild or not image_exists(image):
|
|
127
|
+
build_image(image, log)
|
|
128
|
+
|
|
129
|
+
agents: list[Agent] = [make_agent(a, providers[a.name], cfg.max_turns) for a in cfg.agents]
|
|
130
|
+
skill_names = {s.name for s in cfg.skills}
|
|
131
|
+
started = dt.datetime.now(dt.UTC)
|
|
132
|
+
results: dict = {
|
|
133
|
+
"schema": 1,
|
|
134
|
+
"tool": "doesitfire",
|
|
135
|
+
"version": __version__,
|
|
136
|
+
"started_at": started.isoformat(timespec="seconds"),
|
|
137
|
+
"finished_at": None,
|
|
138
|
+
"duration_s": None,
|
|
139
|
+
"repeats": cfg.repeats,
|
|
140
|
+
"max_turns": cfg.max_turns,
|
|
141
|
+
"host": host_info(docker_version),
|
|
142
|
+
"agents": [
|
|
143
|
+
{
|
|
144
|
+
"name": a.cfg.name,
|
|
145
|
+
"cli_version": CLI_VERSIONS[a.cfg.name],
|
|
146
|
+
"provider": a.provider.name,
|
|
147
|
+
"model": a.cfg.model,
|
|
148
|
+
"experimental": a.cfg.name in EXPERIMENTAL,
|
|
149
|
+
"price": (
|
|
150
|
+
{"input": a.cfg.price_input, "output": a.cfg.price_output}
|
|
151
|
+
if a.cfg.price_input is not None
|
|
152
|
+
else None
|
|
153
|
+
),
|
|
154
|
+
}
|
|
155
|
+
for a in agents
|
|
156
|
+
],
|
|
157
|
+
"skills": [{"name": s.name, "description": s.description} for s in cfg.skills],
|
|
158
|
+
"trials": [],
|
|
159
|
+
"cost_usd": 0.0,
|
|
160
|
+
"partial": False,
|
|
161
|
+
"stop_reason": None,
|
|
162
|
+
"sessions": 1,
|
|
163
|
+
"recorded": False,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
plan = [
|
|
167
|
+
(rep, agent, skill, prompt, expect)
|
|
168
|
+
for rep in range(cfg.repeats)
|
|
169
|
+
for agent in agents
|
|
170
|
+
for skill in cfg.skills
|
|
171
|
+
for expect, prompts in (("fire", skill.should_fire), ("no_fire", skill.should_not_fire))
|
|
172
|
+
for prompt in prompts
|
|
173
|
+
]
|
|
174
|
+
previous_duration = 0.0
|
|
175
|
+
if resume:
|
|
176
|
+
# Keep finished trials of an interrupted run with the same plan, run only the rest.
|
|
177
|
+
keys = {(a.cfg.name, s.name, p, e, r) for r, a, s, p, e in plan}
|
|
178
|
+
kept = [
|
|
179
|
+
t
|
|
180
|
+
for t in resume.get("trials", [])
|
|
181
|
+
if (t["agent"], t["skill"], t["prompt"], t["expect"], t["repeat"]) in keys
|
|
182
|
+
]
|
|
183
|
+
done = {(t["agent"], t["skill"], t["prompt"], t["expect"], t["repeat"]) for t in kept}
|
|
184
|
+
plan = [x for x in plan if (x[1].cfg.name, x[2].name, x[3], x[4], x[0]) not in done]
|
|
185
|
+
results["trials"] = kept
|
|
186
|
+
results["cost_usd"] = round(sum(t.get("cost_usd") or 0.0 for t in kept), 6)
|
|
187
|
+
results["started_at"] = resume.get("started_at") or results["started_at"]
|
|
188
|
+
results["sessions"] = int(resume.get("sessions") or 1) + 1
|
|
189
|
+
previous_duration = float(resume.get("duration_s") or 0.0)
|
|
190
|
+
log(f"Resuming: {len(kept)} runs already done, {len(plan)} to go.")
|
|
191
|
+
max_seen: dict[str, float] = {}
|
|
192
|
+
t0 = time.monotonic()
|
|
193
|
+
with tempfile.TemporaryDirectory(prefix="doesitfire-") as tmp:
|
|
194
|
+
staged = stage_skills([(s.name, s.path) for s in cfg.skills], Path(tmp) / "skills")
|
|
195
|
+
allow = [u for a in agents for u in a.build("x").allow]
|
|
196
|
+
developer_as_system = any(a.cfg.name == "codex" and a.provider.name == "ollama" for a in agents)
|
|
197
|
+
with Sandbox(allow, image=image, developer_as_system=developer_as_system) as box:
|
|
198
|
+
for i, (rep, agent, skill, prompt, expect) in enumerate(plan, 1):
|
|
199
|
+
spent = results["cost_usd"]
|
|
200
|
+
if agent.provider.paid:
|
|
201
|
+
pin, pout = agent.cfg.price_input or 0, agent.cfg.price_output or 0
|
|
202
|
+
estimate = max_seen.get(
|
|
203
|
+
agent.cfg.name, EST_INPUT_TOKENS * pin / 1e6 + EST_OUTPUT_TOKENS * pout / 1e6
|
|
204
|
+
)
|
|
205
|
+
if spent + estimate > max_cost:
|
|
206
|
+
results["partial"] = True
|
|
207
|
+
results["stop_reason"] = (
|
|
208
|
+
f"budget: spent {spent:.4f} USD, next {agent.cfg.name} run would cost about "
|
|
209
|
+
f"{estimate:.4f} USD, --max-cost is {max_cost:.2f} USD"
|
|
210
|
+
)
|
|
211
|
+
break
|
|
212
|
+
trial = run_trial(box, agent, skill.name, prompt, expect, rep, staged, cfg, skill_names)
|
|
213
|
+
raw = trial.pop("_raw")
|
|
214
|
+
if logs_dir is not None:
|
|
215
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
216
|
+
n = len(results["trials"]) + 1
|
|
217
|
+
name = f"{n:04d}-{agent.cfg.name}-{skill.name}-{expect}-r{rep}.jsonl"
|
|
218
|
+
(logs_dir / name).write_text(raw, encoding="utf-8")
|
|
219
|
+
trial["log"] = f"logs/{name}"
|
|
220
|
+
cost = run_cost(agent.provider, agent.cfg, trial.pop("_obs"))
|
|
221
|
+
trial["cost_usd"] = cost
|
|
222
|
+
max_seen[agent.cfg.name] = max(max_seen.get(agent.cfg.name, 0.0), cost)
|
|
223
|
+
results["cost_usd"] = round(results["cost_usd"] + cost, 6)
|
|
224
|
+
results["trials"].append(trial)
|
|
225
|
+
if progress:
|
|
226
|
+
progress({"done": i, "total": len(plan), "trial": trial})
|
|
227
|
+
if checkpoint:
|
|
228
|
+
results["duration_s"] = round(previous_duration + time.monotonic() - t0, 1)
|
|
229
|
+
checkpoint(results)
|
|
230
|
+
denials = box.proxy_denials()
|
|
231
|
+
results["blocked_egress"] = sorted(set(denials))
|
|
232
|
+
finished = dt.datetime.now(dt.UTC)
|
|
233
|
+
results["finished_at"] = finished.isoformat(timespec="seconds")
|
|
234
|
+
results["duration_s"] = round(previous_duration + time.monotonic() - t0, 1)
|
|
235
|
+
if results["partial"]:
|
|
236
|
+
raise BudgetExceeded(results["stop_reason"], results)
|
|
237
|
+
return results
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def run_trial(
|
|
241
|
+
box: Sandbox,
|
|
242
|
+
agent: Agent,
|
|
243
|
+
skill: str,
|
|
244
|
+
prompt: str,
|
|
245
|
+
expect: str,
|
|
246
|
+
rep: int,
|
|
247
|
+
staged: Path,
|
|
248
|
+
cfg: Config,
|
|
249
|
+
skill_names: set[str],
|
|
250
|
+
) -> dict:
|
|
251
|
+
inv = agent.build(prompt)
|
|
252
|
+
obs = Observation()
|
|
253
|
+
|
|
254
|
+
def on_line(line: str) -> bool:
|
|
255
|
+
agent.feed(line, obs, skill_names)
|
|
256
|
+
return agent.should_stop(obs, skill_names)
|
|
257
|
+
|
|
258
|
+
out = box.run(inv, agent.skills_dir, staged, cfg.timeout, cfg.cpus, cfg.memory, on_line)
|
|
259
|
+
fired = skill in obs.activated
|
|
260
|
+
error = None
|
|
261
|
+
if not fired:
|
|
262
|
+
if out.timed_out:
|
|
263
|
+
error = f"timeout after {cfg.timeout}s"
|
|
264
|
+
elif obs.error:
|
|
265
|
+
error = obs.error
|
|
266
|
+
elif out.returncode not in (0,) and not out.stopped_early and not out.stdout.strip():
|
|
267
|
+
tail = out.stderr.strip().splitlines()[-3:]
|
|
268
|
+
error = f"exit {out.returncode}: {' | '.join(tail)[:300]}" if tail else f"exit {out.returncode}"
|
|
269
|
+
return {
|
|
270
|
+
"agent": agent.cfg.name,
|
|
271
|
+
"skill": skill,
|
|
272
|
+
"prompt": prompt,
|
|
273
|
+
"expect": expect,
|
|
274
|
+
"repeat": rep,
|
|
275
|
+
"fired": fired,
|
|
276
|
+
"activated": obs.activated,
|
|
277
|
+
"error": error,
|
|
278
|
+
"duration_s": out.duration_s,
|
|
279
|
+
"input_tokens": obs.input_tokens,
|
|
280
|
+
"output_tokens": obs.output_tokens,
|
|
281
|
+
"_obs": obs,
|
|
282
|
+
"_raw": out.stdout + (f"\n# stderr\n{out.stderr[-4000:]}" if out.stderr.strip() else ""),
|
|
283
|
+
}
|
doesitfire/sandbox.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Docker isolation: every agent run happens in a throwaway container.
|
|
2
|
+
|
|
3
|
+
Layout per `doesitfire run`:
|
|
4
|
+
|
|
5
|
+
[agent container] --internal network--> [egress proxy] --bridge--> model API only
|
|
6
|
+
|
|
7
|
+
- The internal network has no route out. The only peer is the egress proxy,
|
|
8
|
+
which allows exactly the model API host:port and returns 403 for anything else.
|
|
9
|
+
- Agent containers: non-root, read-only root filesystem, tmpfs home, all Linux
|
|
10
|
+
capabilities dropped, no-new-privileges, CPU/memory/pids limits, hard timeout.
|
|
11
|
+
- Skills are mounted read-only and copied into the agent's discovery directory.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
import uuid
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
from contextlib import ExitStack
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from importlib import resources
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from . import __version__
|
|
29
|
+
from .agents import Invocation
|
|
30
|
+
from .errors import DoesItFireError
|
|
31
|
+
from .providers import host_port
|
|
32
|
+
|
|
33
|
+
IMAGE = f"doesitfire-agents:{__version__}"
|
|
34
|
+
LABEL = "io.github.doesitfire.run"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _docker(*args: str, timeout: float = 120, check: bool = True) -> subprocess.CompletedProcess:
|
|
38
|
+
try:
|
|
39
|
+
proc = subprocess.run(["docker", *args], capture_output=True, text=True, timeout=timeout)
|
|
40
|
+
except FileNotFoundError:
|
|
41
|
+
raise DoesItFireError(
|
|
42
|
+
"Docker is not installed. doesitfire runs every agent inside a container and refuses to run "
|
|
43
|
+
"without one. Install Docker Desktop (macOS/Windows) or Docker Engine (Linux)."
|
|
44
|
+
) from None
|
|
45
|
+
except subprocess.TimeoutExpired:
|
|
46
|
+
raise DoesItFireError(
|
|
47
|
+
f"'docker {args[0]}' timed out after {timeout:.0f}s. Is the Docker daemon healthy?"
|
|
48
|
+
) from None
|
|
49
|
+
if check and proc.returncode != 0:
|
|
50
|
+
raise DoesItFireError(f"'docker {' '.join(args[:2])}' failed: {proc.stderr.strip()[:400]}")
|
|
51
|
+
return proc
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def check_docker() -> str:
|
|
55
|
+
"""Return the Docker server version, or raise a clear error if Docker is not usable."""
|
|
56
|
+
proc = _docker("info", "--format", "{{.ServerVersion}}", timeout=30, check=False)
|
|
57
|
+
if proc.returncode != 0 or not proc.stdout.strip():
|
|
58
|
+
raise DoesItFireError(
|
|
59
|
+
"Docker is not running. Start Docker Desktop (or the Docker daemon) and try again. "
|
|
60
|
+
"doesitfire runs every agent inside a container and will not run without it."
|
|
61
|
+
)
|
|
62
|
+
return proc.stdout.strip()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def image_exists(image: str = IMAGE) -> bool:
|
|
66
|
+
return _docker("image", "inspect", image, check=False).returncode == 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build_image(image: str = IMAGE, log: Callable[[str], None] = print) -> None:
|
|
70
|
+
log(f"Building the agent image {image} (first run only, a few minutes)...")
|
|
71
|
+
with ExitStack() as stack:
|
|
72
|
+
ctx = stack.enter_context(resources.as_file(resources.files("doesitfire") / "docker"))
|
|
73
|
+
proc = subprocess.run(["docker", "build", "-t", image, str(ctx)], capture_output=True, text=True)
|
|
74
|
+
if proc.returncode != 0:
|
|
75
|
+
raise DoesItFireError(f"Building {image} failed:\n{proc.stderr.strip()[-1500:]}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def stage_skills(skill_dirs: list[tuple[str, Path]], dest: Path) -> Path:
|
|
79
|
+
"""Copy each skill directory to dest/<name> (what gets mounted read-only at /skills)."""
|
|
80
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
for name, src in skill_dirs:
|
|
82
|
+
shutil.copytree(
|
|
83
|
+
src,
|
|
84
|
+
dest / name,
|
|
85
|
+
dirs_exist_ok=True,
|
|
86
|
+
ignore=shutil.ignore_patterns(".git", "node_modules", "__pycache__", ".venv"),
|
|
87
|
+
)
|
|
88
|
+
return dest
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class RunOutput:
|
|
93
|
+
stdout: str
|
|
94
|
+
stderr: str
|
|
95
|
+
returncode: int
|
|
96
|
+
timed_out: bool
|
|
97
|
+
stopped_early: bool
|
|
98
|
+
duration_s: float
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Sandbox:
|
|
102
|
+
"""An internal Docker network plus an egress proxy, for one `doesitfire run`."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, allow_urls: list[str], image: str = IMAGE, developer_as_system: bool = False):
|
|
105
|
+
self.run_id = uuid.uuid4().hex[:10]
|
|
106
|
+
self.image = image
|
|
107
|
+
self.network = f"doesitfire-{self.run_id}"
|
|
108
|
+
self.proxy = f"doesitfire-{self.run_id}-egress"
|
|
109
|
+
self.allow = sorted({host_port(u) for u in allow_urls})
|
|
110
|
+
self.developer_as_system = developer_as_system
|
|
111
|
+
|
|
112
|
+
def __enter__(self) -> Sandbox:
|
|
113
|
+
_docker("network", "create", "--internal", "--label", f"{LABEL}={self.run_id}", self.network)
|
|
114
|
+
try:
|
|
115
|
+
_docker(
|
|
116
|
+
"run",
|
|
117
|
+
"-d",
|
|
118
|
+
"--name",
|
|
119
|
+
self.proxy,
|
|
120
|
+
"--label",
|
|
121
|
+
f"{LABEL}={self.run_id}",
|
|
122
|
+
"--network",
|
|
123
|
+
"bridge",
|
|
124
|
+
"--add-host",
|
|
125
|
+
"host.docker.internal:host-gateway",
|
|
126
|
+
"--read-only",
|
|
127
|
+
"--cap-drop",
|
|
128
|
+
"ALL",
|
|
129
|
+
"--security-opt",
|
|
130
|
+
"no-new-privileges",
|
|
131
|
+
"--memory",
|
|
132
|
+
"128m",
|
|
133
|
+
"--cpus",
|
|
134
|
+
"0.5",
|
|
135
|
+
"--pids-limit",
|
|
136
|
+
"64",
|
|
137
|
+
"-e",
|
|
138
|
+
f"DOESITFIRE_ALLOW={','.join(self.allow)}",
|
|
139
|
+
"-e",
|
|
140
|
+
f"DOESITFIRE_DEVELOPER_AS_SYSTEM={'1' if self.developer_as_system else '0'}",
|
|
141
|
+
"--entrypoint",
|
|
142
|
+
"node",
|
|
143
|
+
self.image,
|
|
144
|
+
"/opt/doesitfire/egress-proxy.mjs",
|
|
145
|
+
)
|
|
146
|
+
_docker("network", "connect", "--alias", "egress", self.network, self.proxy)
|
|
147
|
+
deadline = time.monotonic() + 20
|
|
148
|
+
while time.monotonic() < deadline:
|
|
149
|
+
logs = _docker("logs", self.proxy, check=False)
|
|
150
|
+
if "egress proxy on" in logs.stderr + logs.stdout:
|
|
151
|
+
break
|
|
152
|
+
time.sleep(0.3)
|
|
153
|
+
else:
|
|
154
|
+
raise DoesItFireError("The egress proxy container did not start.")
|
|
155
|
+
except BaseException:
|
|
156
|
+
self.close()
|
|
157
|
+
raise
|
|
158
|
+
return self
|
|
159
|
+
|
|
160
|
+
def __exit__(self, *exc) -> None:
|
|
161
|
+
self.close()
|
|
162
|
+
|
|
163
|
+
def close(self) -> None:
|
|
164
|
+
ids = _docker("ps", "-aq", "--filter", f"label={LABEL}={self.run_id}", check=False).stdout.split()
|
|
165
|
+
if ids:
|
|
166
|
+
_docker("rm", "-f", *ids, check=False)
|
|
167
|
+
_docker("network", "rm", self.network, check=False)
|
|
168
|
+
|
|
169
|
+
def proxy_denials(self) -> list[str]:
|
|
170
|
+
logs = _docker("logs", self.proxy, check=False)
|
|
171
|
+
return [ln for ln in (logs.stderr + logs.stdout).splitlines() if ln.startswith("deny ")]
|
|
172
|
+
|
|
173
|
+
def run(
|
|
174
|
+
self,
|
|
175
|
+
inv: Invocation,
|
|
176
|
+
skills_dir: str,
|
|
177
|
+
staged: Path,
|
|
178
|
+
timeout: int,
|
|
179
|
+
cpus: float = 1.0,
|
|
180
|
+
memory: str = "1g",
|
|
181
|
+
on_line: Callable[[str], bool] | None = None,
|
|
182
|
+
) -> RunOutput:
|
|
183
|
+
name = f"doesitfire-{self.run_id}-{uuid.uuid4().hex[:8]}"
|
|
184
|
+
proxy = "http://egress:8888"
|
|
185
|
+
args = [
|
|
186
|
+
"docker",
|
|
187
|
+
"run",
|
|
188
|
+
"--rm",
|
|
189
|
+
"-i",
|
|
190
|
+
"--name",
|
|
191
|
+
name,
|
|
192
|
+
"--label",
|
|
193
|
+
f"{LABEL}={self.run_id}",
|
|
194
|
+
"--network",
|
|
195
|
+
self.network,
|
|
196
|
+
"--read-only",
|
|
197
|
+
"--tmpfs",
|
|
198
|
+
"/home/node:rw,exec,uid=1000,gid=1000,size=512m",
|
|
199
|
+
"--tmpfs",
|
|
200
|
+
"/tmp:rw,exec,size=256m",
|
|
201
|
+
"--cap-drop",
|
|
202
|
+
"ALL",
|
|
203
|
+
"--security-opt",
|
|
204
|
+
"no-new-privileges",
|
|
205
|
+
"--cpus",
|
|
206
|
+
str(cpus),
|
|
207
|
+
"--memory",
|
|
208
|
+
memory,
|
|
209
|
+
"--pids-limit",
|
|
210
|
+
"512",
|
|
211
|
+
"-v",
|
|
212
|
+
f"{staged}:/skills:ro",
|
|
213
|
+
"-e",
|
|
214
|
+
f"DOESITFIRE_SKILLS_DIR={skills_dir}",
|
|
215
|
+
]
|
|
216
|
+
for var in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
|
|
217
|
+
args += ["-e", f"{var}={proxy}"]
|
|
218
|
+
args += ["-e", "NO_PROXY=", "-e", "no_proxy="]
|
|
219
|
+
for key, value in inv.env.items():
|
|
220
|
+
args += ["-e", f"{key}={value}"]
|
|
221
|
+
child_env = dict(os.environ)
|
|
222
|
+
for key, value in inv.secret_env.items():
|
|
223
|
+
args += ["-e", key] # value travels through the environment, never argv
|
|
224
|
+
child_env[key] = value
|
|
225
|
+
args += [self.image, "timeout", "-s", "KILL", str(timeout + 5), *inv.argv]
|
|
226
|
+
|
|
227
|
+
start = time.monotonic()
|
|
228
|
+
proc = subprocess.Popen(
|
|
229
|
+
args,
|
|
230
|
+
stdin=subprocess.DEVNULL,
|
|
231
|
+
stdout=subprocess.PIPE,
|
|
232
|
+
stderr=subprocess.PIPE,
|
|
233
|
+
text=True,
|
|
234
|
+
encoding="utf-8",
|
|
235
|
+
errors="replace",
|
|
236
|
+
env=child_env,
|
|
237
|
+
)
|
|
238
|
+
out_lines: list[str] = []
|
|
239
|
+
err_chunks: list[str] = []
|
|
240
|
+
stopped = threading.Event()
|
|
241
|
+
|
|
242
|
+
def read_stderr() -> None:
|
|
243
|
+
assert proc.stderr is not None
|
|
244
|
+
err_chunks.append(proc.stderr.read())
|
|
245
|
+
|
|
246
|
+
def kill() -> None:
|
|
247
|
+
_docker("kill", name, check=False, timeout=30)
|
|
248
|
+
|
|
249
|
+
t_err = threading.Thread(target=read_stderr, daemon=True)
|
|
250
|
+
t_err.start()
|
|
251
|
+
timer = threading.Timer(timeout, kill)
|
|
252
|
+
timer.start()
|
|
253
|
+
try:
|
|
254
|
+
assert proc.stdout is not None
|
|
255
|
+
for line in proc.stdout:
|
|
256
|
+
out_lines.append(line)
|
|
257
|
+
if on_line and not stopped.is_set() and on_line(line):
|
|
258
|
+
stopped.set()
|
|
259
|
+
kill()
|
|
260
|
+
proc.wait()
|
|
261
|
+
except KeyboardInterrupt:
|
|
262
|
+
kill()
|
|
263
|
+
raise
|
|
264
|
+
finally:
|
|
265
|
+
timed_out = not timer.is_alive() and not stopped.is_set() and proc.returncode not in (0,)
|
|
266
|
+
timer.cancel()
|
|
267
|
+
t_err.join(timeout=5)
|
|
268
|
+
duration = time.monotonic() - start
|
|
269
|
+
return RunOutput(
|
|
270
|
+
stdout="".join(out_lines),
|
|
271
|
+
stderr="".join(err_chunks),
|
|
272
|
+
returncode=proc.returncode,
|
|
273
|
+
timed_out=timed_out and duration >= timeout,
|
|
274
|
+
stopped_early=stopped.is_set(),
|
|
275
|
+
duration_s=round(duration, 2),
|
|
276
|
+
)
|