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/__init__.py +24 -0
- quickstarted/_version.py +7 -0
- quickstarted/agents/__init__.py +4 -0
- quickstarted/agents/base.py +145 -0
- quickstarted/agents/claude.py +242 -0
- quickstarted/agents/gemini_agent.py +138 -0
- quickstarted/agents/openai_agent.py +167 -0
- quickstarted/agents/prompt.py +61 -0
- quickstarted/agents/registry.py +31 -0
- quickstarted/agents/replay.py +39 -0
- quickstarted/cli.py +250 -0
- quickstarted/docs.py +263 -0
- quickstarted/exec/__init__.py +89 -0
- quickstarted/exec/base.py +131 -0
- quickstarted/exec/docker.py +216 -0
- quickstarted/exec/local.py +16 -0
- quickstarted/exec/seatbelt.py +83 -0
- quickstarted/journey.py +182 -0
- quickstarted/net/__init__.py +5 -0
- quickstarted/net/proxy.py +274 -0
- quickstarted/net/proxy_main.py +53 -0
- quickstarted/pricing.py +93 -0
- quickstarted/report.py +206 -0
- quickstarted/results.py +166 -0
- quickstarted/run.py +240 -0
- quickstarted/sandbox.py +15 -0
- quickstarted/suite.py +203 -0
- quickstarted/trace.py +53 -0
- quickstarted/transport.py +88 -0
- quickstarted-0.2.0.dist-info/METADATA +256 -0
- quickstarted-0.2.0.dist-info/RECORD +34 -0
- quickstarted-0.2.0.dist-info/WHEEL +4 -0
- quickstarted-0.2.0.dist-info/entry_points.txt +3 -0
- quickstarted-0.2.0.dist-info/licenses/LICENSE +21 -0
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)
|