quickstarted 0.2.0__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.
quickstarted/docs.py ADDED
@@ -0,0 +1,263 @@
1
+ """The documentation client: caching, politeness, and affordance policy.
2
+
3
+ Three things live here that a benchmark cannot do without.
4
+
5
+ **Reproducibility.** Responses are cached by content hash, so a rerun reads
6
+ the same bytes the first run did. When a refresh sees different bytes, that is
7
+ recorded: "the docs changed under us" is a finding, not an inconvenience.
8
+
9
+ **Politeness.** Benchmarking fifty projects means fetching from fifty
10
+ companies who did not ask to be measured. A truthful User-Agent, one request
11
+ per host at a time, and robots.txt honoured by default are the minimum for
12
+ doing this at scale without being a nuisance.
13
+
14
+ **Affordance policy.** Whether a project ships `llms.txt` is a checklist item
15
+ anybody can curl, and scoring it would be exactly the proxy metric this tool
16
+ exists to replace. What nobody can currently answer is whether the file
17
+ *helps*. So affordances are never scored, only recorded, and they can be
18
+ withheld from the agent: run the same journey with `all` and with `none`, and
19
+ the difference in pass rate is a measurement of the affordance itself.
20
+
21
+ The prompt is identical under both conditions. Only availability changes,
22
+ which is what keeps the comparison honest.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import hashlib
28
+ import json
29
+ import threading
30
+ import time
31
+ import urllib.error
32
+ import urllib.robotparser
33
+ from dataclasses import dataclass
34
+ from pathlib import Path
35
+ from urllib.parse import urljoin, urlparse
36
+
37
+ from . import transport
38
+
39
+ #: URL shapes that exist for the benefit of language models rather than people.
40
+ AFFORDANCE_FILES = ("llms.txt", "llms-full.txt")
41
+ AFFORDANCE_POLICIES = ("all", "none")
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class FetchResult:
46
+ url: str
47
+ status: int
48
+ content_type: str
49
+ text: str
50
+ from_cache: bool = False
51
+ content_hash: str = ""
52
+ changed: bool = False
53
+ blocked_reason: str = ""
54
+
55
+ @property
56
+ def ok(self) -> bool:
57
+ return not self.blocked_reason and 200 <= self.status < 300
58
+
59
+
60
+ @dataclass
61
+ class Affordance:
62
+ url: str
63
+ present: bool
64
+ status: int = 0
65
+ bytes: int = 0
66
+ note: str = ""
67
+
68
+
69
+ def is_affordance_url(url: str) -> bool:
70
+ path = urlparse(url).path.lower()
71
+ name = path.rsplit("/", 1)[-1]
72
+ return name in AFFORDANCE_FILES or path.endswith(".md")
73
+
74
+
75
+ class DocsClient:
76
+ def __init__(
77
+ self,
78
+ cache_dir: str | None = None,
79
+ rate_limit_seconds: float = 1.0,
80
+ respect_robots: bool = True,
81
+ affordances: str = "all",
82
+ refresh: bool = False,
83
+ offline: bool = False,
84
+ timeout: int = 30,
85
+ ):
86
+ if affordances not in AFFORDANCE_POLICIES:
87
+ raise ValueError(
88
+ f"affordances must be one of {AFFORDANCE_POLICIES}, got {affordances!r}"
89
+ )
90
+ self.cache_dir = Path(cache_dir) if cache_dir else None
91
+ self.rate_limit_seconds = rate_limit_seconds
92
+ self.respect_robots = respect_robots
93
+ self.affordances = affordances
94
+ self.refresh = refresh
95
+ self.offline = offline
96
+ self.timeout = timeout
97
+ self._last_request: dict[str, float] = {}
98
+ self._robots: dict[str, urllib.robotparser.RobotFileParser | None] = {}
99
+ self._lock = threading.Lock()
100
+
101
+ # -- cache ----------------------------------------------------------
102
+ def _cache_path(self, url: str) -> Path | None:
103
+ if not self.cache_dir:
104
+ return None
105
+ digest = hashlib.sha256(url.encode("utf-8")).hexdigest()
106
+ return self.cache_dir / digest[:2] / f"{digest}.json"
107
+
108
+ def _read_cache(self, url: str) -> dict | None:
109
+ path = self._cache_path(url)
110
+ if not path or not path.is_file():
111
+ return None
112
+ try:
113
+ return json.loads(path.read_text(encoding="utf-8"))
114
+ except ValueError:
115
+ return None
116
+
117
+ def _write_cache(self, url: str, payload: dict) -> None:
118
+ path = self._cache_path(url)
119
+ if not path:
120
+ return
121
+ path.parent.mkdir(parents=True, exist_ok=True)
122
+ path.write_text(json.dumps(payload), encoding="utf-8")
123
+
124
+ # -- politeness -----------------------------------------------------
125
+ def _throttle(self, host: str) -> None:
126
+ if self.rate_limit_seconds <= 0:
127
+ return
128
+ with self._lock:
129
+ last = self._last_request.get(host, 0.0)
130
+ wait = self.rate_limit_seconds - (time.monotonic() - last)
131
+ if wait > 0:
132
+ time.sleep(wait)
133
+ self._last_request[host] = time.monotonic()
134
+
135
+ def robots_allows(self, url: str) -> bool:
136
+ if not self.respect_robots:
137
+ return True
138
+ parsed = urlparse(url)
139
+ origin = f"{parsed.scheme}://{parsed.netloc}"
140
+ with self._lock:
141
+ known = origin in self._robots
142
+ parser = self._robots.get(origin)
143
+ if not known:
144
+ parser = urllib.robotparser.RobotFileParser()
145
+ try:
146
+ response = transport.http_get(
147
+ urljoin(origin, "/robots.txt"), timeout=self.timeout
148
+ )
149
+ if 200 <= response.status < 300:
150
+ parser.parse(response.text.splitlines())
151
+ else:
152
+ parser = None
153
+ except Exception:
154
+ # No robots.txt, or unreachable: nothing to disallow.
155
+ parser = None
156
+ with self._lock:
157
+ self._robots[origin] = parser
158
+ if parser is None:
159
+ return True
160
+ return parser.can_fetch(transport.USER_AGENT, url)
161
+
162
+ # -- fetching -------------------------------------------------------
163
+ def get(self, url: str) -> FetchResult:
164
+ if self.affordances == "none" and is_affordance_url(url):
165
+ return FetchResult(
166
+ url, 0, "", "", blocked_reason="affordance_withheld"
167
+ )
168
+
169
+ cached = self._read_cache(url)
170
+ if cached and not self.refresh:
171
+ return FetchResult(
172
+ url=url,
173
+ status=cached["status"],
174
+ content_type=cached["content_type"],
175
+ text=cached["text"],
176
+ from_cache=True,
177
+ content_hash=cached["content_hash"],
178
+ )
179
+
180
+ if self.offline:
181
+ return FetchResult(url, 0, "", "", blocked_reason="offline_cache_miss")
182
+
183
+ if not self.robots_allows(url):
184
+ return FetchResult(url, 0, "", "", blocked_reason="robots_disallowed")
185
+
186
+ host = urlparse(url).hostname or ""
187
+ self._throttle(host)
188
+ response = transport.http_get(url, timeout=self.timeout)
189
+ text = response.text
190
+ if "html" in (response.content_type or "").lower():
191
+ text = transport.html_to_text(text)
192
+ content_hash = hashlib.sha256(text.encode("utf-8")).hexdigest()
193
+ changed = bool(cached and cached.get("content_hash") != content_hash)
194
+ self._write_cache(
195
+ url,
196
+ {
197
+ "url": url,
198
+ "status": response.status,
199
+ "content_type": response.content_type,
200
+ "text": text,
201
+ "content_hash": content_hash,
202
+ "fetched_at": time.time(),
203
+ },
204
+ )
205
+ return FetchResult(
206
+ url=url,
207
+ status=response.status,
208
+ content_type=response.content_type,
209
+ text=text,
210
+ content_hash=content_hash,
211
+ changed=changed,
212
+ )
213
+
214
+ # -- affordance probing ---------------------------------------------
215
+ def probe(self, entrypoint: str) -> dict[str, Affordance]:
216
+ """Record which machine-facing affordances exist. Never scored.
217
+
218
+ Presence is context for a human reading a failure, and a variable for
219
+ the ablation. It is not a grade: a 1.8 MB llms-full.txt is 'present'
220
+ and may still be useless to an agent with a context window.
221
+ """
222
+ parsed = urlparse(entrypoint)
223
+ origin = f"{parsed.scheme}://{parsed.netloc}"
224
+ found: dict[str, Affordance] = {}
225
+ candidates = {name: urljoin(origin + "/", name) for name in AFFORDANCE_FILES}
226
+ page = entrypoint.split("?")[0].rstrip("/")
227
+ if page and not page.endswith(".md"):
228
+ candidates["page.md"] = page + ".md"
229
+ for label, url in candidates.items():
230
+ try:
231
+ self._throttle(urlparse(url).hostname or "")
232
+ response = transport.http_get(url, timeout=self.timeout)
233
+ except urllib.error.HTTPError as exc:
234
+ found[label] = Affordance(url, False, status=exc.code)
235
+ continue
236
+ except Exception as exc:
237
+ found[label] = Affordance(url, False, note=str(exc)[:120])
238
+ continue
239
+ body = response.text or ""
240
+ # A soft 200 that serves the site's HTML shell is not an affordance.
241
+ looks_like_html = "html" in (response.content_type or "").lower()
242
+ present = 200 <= response.status < 300 and bool(body) and not looks_like_html
243
+ found[label] = Affordance(
244
+ url=url,
245
+ present=present,
246
+ status=response.status,
247
+ bytes=len(body.encode("utf-8")),
248
+ note="served HTML, not a machine-readable file" if looks_like_html else "",
249
+ )
250
+ return found
251
+
252
+
253
+ def affordance_summary(found: dict[str, Affordance]) -> dict[str, dict]:
254
+ return {
255
+ label: {
256
+ "url": a.url,
257
+ "present": a.present,
258
+ "status": a.status,
259
+ "bytes": a.bytes,
260
+ "note": a.note,
261
+ }
262
+ for label, a in found.items()
263
+ }
@@ -0,0 +1,89 @@
1
+ """Execution backends and the policy for choosing one.
2
+
3
+ `auto` prefers enforcement: Docker when a daemon is reachable, Seatbelt on
4
+ macOS, and local only as a last resort. Choosing an unenforced backend is
5
+ allowed but never silent.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import platform
11
+
12
+ from . import docker as _docker
13
+ from . import seatbelt as _seatbelt
14
+ from .base import CommandResult, Executor, ExecutorError, ProcessExecutor, truncate
15
+ from .docker import DockerExecutor
16
+ from .local import LocalExecutor
17
+ from .seatbelt import SeatbeltExecutor
18
+
19
+ BACKENDS = ("auto", "docker", "seatbelt", "local")
20
+
21
+
22
+ def available_backends() -> list[str]:
23
+ found = []
24
+ if _docker.available():
25
+ found.append("docker")
26
+ if _seatbelt.available():
27
+ found.append("seatbelt")
28
+ found.append("local")
29
+ return found
30
+
31
+
32
+ def resolve_backend(requested: str = "auto") -> str:
33
+ if requested not in BACKENDS:
34
+ raise ExecutorError(
35
+ f"unknown backend {requested!r}; choose from {', '.join(BACKENDS)}"
36
+ )
37
+ if requested != "auto":
38
+ return requested
39
+ for candidate in ("docker", "seatbelt"):
40
+ if candidate in available_backends():
41
+ return candidate
42
+ return "local"
43
+
44
+
45
+ def make_executor(
46
+ backend: str,
47
+ keep: bool = False,
48
+ proxy_url: str | None = None,
49
+ network_allow=(),
50
+ docs_hosts=(),
51
+ image: str | None = None,
52
+ trace=None,
53
+ ) -> Executor:
54
+ if backend == "docker":
55
+ return DockerExecutor(
56
+ keep=keep,
57
+ network_allow=network_allow,
58
+ docs_hosts=docs_hosts,
59
+ image=image or _docker.DEFAULT_IMAGE,
60
+ trace=trace,
61
+ )
62
+ if backend == "seatbelt":
63
+ return SeatbeltExecutor(keep=keep, proxy_url=proxy_url)
64
+ if backend == "local":
65
+ return LocalExecutor(keep=keep, proxy_url=proxy_url)
66
+ raise ExecutorError(f"unknown backend {backend!r}")
67
+
68
+
69
+ def needs_host_proxy(backend: str) -> bool:
70
+ """Docker runs its own proxy sidecar; process backends need one on the host."""
71
+ return backend in ("seatbelt", "local")
72
+
73
+
74
+ __all__ = [
75
+ "BACKENDS",
76
+ "CommandResult",
77
+ "DockerExecutor",
78
+ "Executor",
79
+ "ExecutorError",
80
+ "LocalExecutor",
81
+ "ProcessExecutor",
82
+ "SeatbeltExecutor",
83
+ "available_backends",
84
+ "make_executor",
85
+ "needs_host_proxy",
86
+ "platform",
87
+ "resolve_backend",
88
+ "truncate",
89
+ ]
@@ -0,0 +1,131 @@
1
+ """Execution backends for agent-issued commands.
2
+
3
+ An executor owns the workspace a journey runs in and decides what a command
4
+ can reach. Backends differ in one respect that matters: whether the journey's
5
+ network policy is *enforced* by the operating system or merely *requested*.
6
+
7
+ `enforced = False` means an agent that ignores the proxy environment can talk
8
+ to any host it likes, and every docs page it reads that way is invisible to
9
+ the trace. Since attribution of a failure to a documentation page is the whole
10
+ product, unenforced backends are for developing journeys against code you
11
+ trust, never for benchmarking third-party projects.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import shutil
18
+ import subprocess
19
+ import tempfile
20
+ import time
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Protocol
24
+
25
+ _TRUNCATION_NOTE = "\n[... output truncated by quickstarted ...]\n"
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class CommandResult:
30
+ exit_code: int
31
+ output: str
32
+ duration: float
33
+ timed_out: bool = False
34
+
35
+
36
+ def truncate(text: str, limit: int) -> str:
37
+ if len(text) <= limit:
38
+ return text
39
+ head = text[: limit // 2]
40
+ tail = text[-(limit // 2) :]
41
+ return head + _TRUNCATION_NOTE + tail
42
+
43
+
44
+ class ExecutorError(RuntimeError):
45
+ """Raised when a backend cannot be started on this machine."""
46
+
47
+
48
+ class Executor(Protocol):
49
+ name: str
50
+ enforced: bool
51
+ root: Path
52
+
53
+ def run(
54
+ self, command: str, timeout: int, max_output_chars: int = 20_000
55
+ ) -> CommandResult:
56
+ ...
57
+
58
+ def cleanup(self) -> None:
59
+ ...
60
+
61
+
62
+ class ProcessExecutor:
63
+ """Shared plumbing for backends that run commands as local processes."""
64
+
65
+ name = "process"
66
+ enforced = False
67
+
68
+ def __init__(self, keep: bool = False, proxy_url: str | None = None):
69
+ self.root = Path(tempfile.mkdtemp(prefix="quickstarted-"))
70
+ (self.root / "tmp").mkdir()
71
+ self.keep = keep
72
+ self.proxy_url = proxy_url
73
+
74
+ def env(self) -> dict[str, str]:
75
+ env = {
76
+ "PATH": os.environ.get("PATH", "/usr/bin:/bin"),
77
+ "HOME": str(self.root),
78
+ "TMPDIR": str(self.root / "tmp"),
79
+ "LANG": os.environ.get("LANG", "en_US.UTF-8"),
80
+ "LC_ALL": os.environ.get("LC_ALL", os.environ.get("LANG", "en_US.UTF-8")),
81
+ "TERM": "dumb",
82
+ "NO_COLOR": "1",
83
+ }
84
+ if self.proxy_url:
85
+ # Both cases: tools are inconsistent about which they read.
86
+ for key in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"):
87
+ env[key] = self.proxy_url
88
+ env[key.lower()] = self.proxy_url
89
+ # Loopback must not go through the proxy. Journeys routinely start a
90
+ # server and then ask it a question, and without this the request is
91
+ # sent to the proxy, which refuses it as an unlisted host.
92
+ for key in ("NO_PROXY", "no_proxy"):
93
+ env[key] = "localhost,127.0.0.1,::1"
94
+ return env
95
+
96
+ def argv(self, command: str) -> list[str]:
97
+ return ["bash", "-c", command]
98
+
99
+ def run(
100
+ self, command: str, timeout: int, max_output_chars: int = 20_000
101
+ ) -> CommandResult:
102
+ start = time.monotonic()
103
+ try:
104
+ proc = subprocess.run(
105
+ self.argv(command),
106
+ cwd=self.root,
107
+ env=self.env(),
108
+ stdout=subprocess.PIPE,
109
+ stderr=subprocess.STDOUT,
110
+ timeout=timeout,
111
+ text=True,
112
+ errors="replace",
113
+ )
114
+ output, exit_code, timed_out = proc.stdout or "", proc.returncode, False
115
+ except subprocess.TimeoutExpired as exc:
116
+ raw = exc.output or b""
117
+ if isinstance(raw, bytes):
118
+ raw = raw.decode("utf-8", errors="replace")
119
+ output = raw + f"\n[command timed out after {timeout}s]"
120
+ exit_code, timed_out = 124, True
121
+ duration = time.monotonic() - start
122
+ return CommandResult(
123
+ exit_code=exit_code,
124
+ output=truncate(output, max_output_chars),
125
+ duration=duration,
126
+ timed_out=timed_out,
127
+ )
128
+
129
+ def cleanup(self) -> None:
130
+ if not self.keep:
131
+ shutil.rmtree(self.root, ignore_errors=True)
@@ -0,0 +1,216 @@
1
+ """Enforced execution in containers: the portable backend, and what CI uses.
2
+
3
+ Topology, which is the whole reason this is enforced rather than requested:
4
+
5
+ [sandbox container] --internal network--> [proxy sidecar] --bridge--> internet
6
+
7
+ The sandbox container is attached *only* to a Docker network created with
8
+ `--internal`, which has no route off the host. The single reachable address is
9
+ the sidecar, which is attached to both that network and a normal bridge. So a
10
+ command cannot leave the machine except through the harness's policy, whether
11
+ or not it honours the proxy environment variables.
12
+
13
+ The sidecar writes its events to stdout; they are collected at teardown and
14
+ merged into the run trace.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import shutil
22
+ import subprocess
23
+ import tempfile
24
+ import time
25
+ import uuid
26
+ from pathlib import Path
27
+
28
+ from ..net.proxy import DEFAULT_NETWORK_ALLOW
29
+ from .base import CommandResult, ExecutorError, truncate
30
+
31
+ DEFAULT_IMAGE = "python:3.12-slim"
32
+ PROXY_IMAGE = "python:3.12-alpine"
33
+ PROXY_PORT = 8080
34
+ _NET_DIR = Path(__file__).resolve().parent.parent / "net"
35
+
36
+
37
+ def available() -> bool:
38
+ if not shutil.which("docker"):
39
+ return False
40
+ try:
41
+ return (
42
+ subprocess.run(
43
+ ["docker", "info"],
44
+ stdout=subprocess.DEVNULL,
45
+ stderr=subprocess.DEVNULL,
46
+ timeout=20,
47
+ ).returncode
48
+ == 0
49
+ )
50
+ except (OSError, subprocess.SubprocessError):
51
+ return False
52
+
53
+
54
+ def _docker(args: list[str], timeout: int = 120) -> subprocess.CompletedProcess:
55
+ return subprocess.run(
56
+ ["docker", *args],
57
+ stdout=subprocess.PIPE,
58
+ stderr=subprocess.STDOUT,
59
+ text=True,
60
+ errors="replace",
61
+ timeout=timeout,
62
+ )
63
+
64
+
65
+ class DockerExecutor:
66
+ name = "docker"
67
+ enforced = True
68
+
69
+ def __init__(
70
+ self,
71
+ keep: bool = False,
72
+ network_allow=DEFAULT_NETWORK_ALLOW,
73
+ docs_hosts=(),
74
+ image: str = DEFAULT_IMAGE,
75
+ trace=None,
76
+ ):
77
+ if not available():
78
+ raise ExecutorError(
79
+ "docker backend requires a running Docker daemon "
80
+ "(`docker info` must succeed)"
81
+ )
82
+ self.keep = keep
83
+ self.image = image
84
+ self.trace = trace
85
+ self.root = Path(tempfile.mkdtemp(prefix="quickstarted-"))
86
+ (self.root / "tmp").mkdir()
87
+ # mkdtemp gives 0700 owned by the invoking user. Where the daemon remaps
88
+ # container root to another uid (rootless Docker, userns-remap, some CI
89
+ # runners), that uid cannot write the bind mount and every journey dies
90
+ # in setup. Widen the throwaway workspace instead of forcing the
91
+ # container to a non-root user, because a quickstart is allowed to say
92
+ # `apt-get install`.
93
+ os.chmod(self.root, 0o777)
94
+ os.chmod(self.root / "tmp", 0o777)
95
+ token = uuid.uuid4().hex[:10]
96
+ self.network = f"quickstarted-net-{token}"
97
+ self.proxy_name = f"quickstarted-proxy-{token}"
98
+ self.container = f"quickstarted-sbx-{token}"
99
+ self._started = False
100
+ self._start(tuple(network_allow), tuple(docs_hosts))
101
+
102
+ # -- lifecycle ------------------------------------------------------
103
+ def _start(self, network_allow, docs_hosts) -> None:
104
+ created = _docker(["network", "create", "--internal", self.network])
105
+ if created.returncode != 0:
106
+ raise ExecutorError(f"could not create docker network: {created.stdout}")
107
+ self._started = True
108
+
109
+ proxy = _docker(
110
+ [
111
+ "run", "-d", "--name", self.proxy_name,
112
+ "--network", self.network,
113
+ "-v", f"{_NET_DIR}:/opt/quickstarted:ro",
114
+ "-e", f"QUICKSTARTED_PROXY_PORT={PROXY_PORT}",
115
+ "-e", "QUICKSTARTED_NETWORK_ALLOW=" + ",".join(network_allow),
116
+ "-e", "QUICKSTARTED_DOCS_HOSTS=" + ",".join(docs_hosts),
117
+ PROXY_IMAGE,
118
+ "python", "/opt/quickstarted/proxy_main.py",
119
+ ]
120
+ )
121
+ if proxy.returncode != 0:
122
+ self.cleanup()
123
+ raise ExecutorError(f"could not start proxy sidecar: {proxy.stdout}")
124
+
125
+ # Give the sidecar, and only the sidecar, a route to the internet.
126
+ bridged = _docker(["network", "connect", "bridge", self.proxy_name])
127
+ if bridged.returncode != 0:
128
+ self.cleanup()
129
+ raise ExecutorError(f"could not bridge proxy sidecar: {bridged.stdout}")
130
+
131
+ proxy_url = f"http://{self.proxy_name}:{PROXY_PORT}"
132
+ env_args: list[str] = []
133
+ for key in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"):
134
+ env_args += ["-e", f"{key}={proxy_url}", "-e", f"{key.lower()}={proxy_url}"]
135
+ # Loopback stays off the proxy; see ProcessExecutor.env for why.
136
+ for key in ("NO_PROXY", "no_proxy"):
137
+ env_args += ["-e", f"{key}=localhost,127.0.0.1,::1"]
138
+
139
+ sandbox = _docker(
140
+ [
141
+ "run", "-d", "--name", self.container,
142
+ "--network", self.network,
143
+ "--cap-drop", "ALL",
144
+ "--security-opt", "no-new-privileges",
145
+ "--pids-limit", "512",
146
+ "-v", f"{self.root}:/workspace",
147
+ "-w", "/workspace",
148
+ "-e", "HOME=/workspace",
149
+ "-e", "TMPDIR=/workspace/tmp",
150
+ "-e", "NO_COLOR=1",
151
+ "-e", "TERM=dumb",
152
+ *env_args,
153
+ self.image, "sleep", "infinity",
154
+ ]
155
+ )
156
+ if sandbox.returncode != 0:
157
+ self.cleanup()
158
+ raise ExecutorError(f"could not start sandbox container: {sandbox.stdout}")
159
+
160
+ def run(
161
+ self, command: str, timeout: int, max_output_chars: int = 20_000
162
+ ) -> CommandResult:
163
+ start = time.monotonic()
164
+ try:
165
+ proc = _docker(
166
+ ["exec", "-w", "/workspace", self.container, "bash", "-c", command],
167
+ timeout=timeout,
168
+ )
169
+ output, exit_code, timed_out = proc.stdout or "", proc.returncode, False
170
+ except subprocess.TimeoutExpired as exc:
171
+ raw = exc.output or ""
172
+ if isinstance(raw, bytes):
173
+ raw = raw.decode("utf-8", errors="replace")
174
+ # The exec is detached from our process; stop it inside the container.
175
+ _docker(["exec", self.container, "pkill", "-9", "-f", "bash -c"], timeout=30)
176
+ output = raw + f"\n[command timed out after {timeout}s]"
177
+ exit_code, timed_out = 124, True
178
+ return CommandResult(
179
+ exit_code=exit_code,
180
+ output=truncate(output, max_output_chars),
181
+ duration=time.monotonic() - start,
182
+ timed_out=timed_out,
183
+ )
184
+
185
+ def collect_proxy_events(self) -> int:
186
+ """Merge sidecar events into the trace. Returns how many were merged."""
187
+ if self.trace is None or not self._started:
188
+ return 0
189
+ logs = _docker(["logs", self.proxy_name], timeout=60)
190
+ if logs.returncode != 0:
191
+ return 0
192
+ merged = 0
193
+ for line in logs.stdout.splitlines():
194
+ line = line.strip()
195
+ if not line.startswith("{"):
196
+ continue
197
+ try:
198
+ event = json.loads(line)
199
+ except ValueError:
200
+ continue
201
+ kind = event.pop("type", "egress")
202
+ event.pop("ts", None)
203
+ self.trace.add(kind, **event)
204
+ merged += 1
205
+ return merged
206
+
207
+ def cleanup(self) -> None:
208
+ if not self._started:
209
+ return
210
+ self.collect_proxy_events()
211
+ for name in (self.container, self.proxy_name):
212
+ _docker(["rm", "-f", name], timeout=60)
213
+ _docker(["network", "rm", self.network], timeout=60)
214
+ self._started = False
215
+ if not self.keep:
216
+ shutil.rmtree(self.root, ignore_errors=True)