sbxloop 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.
- sbxloop/__init__.py +26 -0
- sbxloop/_vendor/sbxloop_worker-0.2.0-py3-none-any.whl +0 -0
- sbxloop/cli/__init__.py +1 -0
- sbxloop/cli/app.py +365 -0
- sbxloop/cli/doctor.py +172 -0
- sbxloop/cli/tui.py +206 -0
- sbxloop/config.py +205 -0
- sbxloop/engine/__init__.py +26 -0
- sbxloop/engine/engine.py +383 -0
- sbxloop/engine/model.py +151 -0
- sbxloop/engine/phases.py +223 -0
- sbxloop/engine/prompts/__init__.py +31 -0
- sbxloop/engine/prompts/decompose.md +41 -0
- sbxloop/engine/prompts/execute.md +31 -0
- sbxloop/engine/prompts/plan.md +35 -0
- sbxloop/engine/prompts/scrutinize.md +43 -0
- sbxloop/engine/prompts/validate.md +38 -0
- sbxloop/engine/store.py +257 -0
- sbxloop/errors.py +73 -0
- sbxloop/events.py +87 -0
- sbxloop/gh/__init__.py +6 -0
- sbxloop/gh/ops.py +140 -0
- sbxloop/gh/reporter.py +75 -0
- sbxloop/ids.py +35 -0
- sbxloop/py.typed +0 -0
- sbxloop/sbx/__init__.py +6 -0
- sbxloop/sbx/cli.py +236 -0
- sbxloop/sbx/models.py +83 -0
- sbxloop/sbx/pair.py +125 -0
- sbxloop/sbx/parse.py +67 -0
- sbxloop/sbx/provision.py +361 -0
- sbxloop/sbx/sandbox.py +75 -0
- sbxloop/worker/__init__.py +6 -0
- sbxloop/worker/client.py +397 -0
- sbxloop/worker/wheel.py +73 -0
- sbxloop-0.2.0.dist-info/METADATA +33 -0
- sbxloop-0.2.0.dist-info/RECORD +39 -0
- sbxloop-0.2.0.dist-info/WHEEL +4 -0
- sbxloop-0.2.0.dist-info/entry_points.txt +2 -0
sbxloop/sbx/provision.py
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""Provision the per-run sandbox pair: create, network policy, secrets, dirs.
|
|
2
|
+
|
|
3
|
+
The credential split is enforced here:
|
|
4
|
+
|
|
5
|
+
- **agent sandbox** gets only ``COPILOT_GITHUB_TOKEN`` (read from the host
|
|
6
|
+
environment), injected via ``sbx secret set-custom`` bound to the
|
|
7
|
+
token-exchange host — the value never enters the VM under the default
|
|
8
|
+
``proxy`` strategy.
|
|
9
|
+
- **github sandbox** gets only ``GH_TOKEN`` via sbx's built-in ``github``
|
|
10
|
+
secret service (scoped to github.com hosts).
|
|
11
|
+
|
|
12
|
+
The ``plain-env`` fallback strategy writes tokens to ``~/.sbxloop/env.sh``
|
|
13
|
+
inside the sandbox (weaker: the value is visible in the VM) for environments
|
|
14
|
+
where the experimental ``set-custom`` proxy rewriting is unavailable.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import shlex
|
|
23
|
+
from collections.abc import Callable, Mapping
|
|
24
|
+
from functools import partial
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from sbxloop.config import Config
|
|
28
|
+
from sbxloop.errors import ProvisionError, SbxError
|
|
29
|
+
from sbxloop.events import EventBus
|
|
30
|
+
from sbxloop.sbx.cli import SbxCLI
|
|
31
|
+
from sbxloop.sbx.models import SandboxRole, SandboxSpec, SecretSpec
|
|
32
|
+
from sbxloop.sbx.pair import SandboxPair
|
|
33
|
+
from sbxloop.sbx.sandbox import ENV_FILE, EVENTS_DIR, JOBS_DIR, RESULTS_DIR, Sandbox
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
COPILOT_TOKEN_ENV = "COPILOT_GITHUB_TOKEN"
|
|
38
|
+
GH_TOKEN_ENVS = ("GH_TOKEN", "GITHUB_TOKEN")
|
|
39
|
+
|
|
40
|
+
# The PAT is exchanged for a Copilot API token at api.github.com; the
|
|
41
|
+
# exchanged token lives in SDK process memory, so the copilot API hosts only
|
|
42
|
+
# need network allows - never an env rewrite. One env var also cannot be
|
|
43
|
+
# registered twice: sbx keys custom secrets by env name, so binding the same
|
|
44
|
+
# env to two hosts fails with "already exists".
|
|
45
|
+
COPILOT_TOKEN_HOST = "api.github.com"
|
|
46
|
+
|
|
47
|
+
# Hosts the agent sandbox must be able to reach (doctor checks these).
|
|
48
|
+
AGENT_TOKEN_HOSTS = ("api.githubcopilot.com", "api.github.com")
|
|
49
|
+
|
|
50
|
+
AGENT_ALLOW_DOMAINS = (
|
|
51
|
+
"api.githubcopilot.com",
|
|
52
|
+
"*.githubcopilot.com",
|
|
53
|
+
"api.github.com",
|
|
54
|
+
"github.com",
|
|
55
|
+
"objects.githubusercontent.com", # copilot CLI runtime downloads
|
|
56
|
+
"raw.githubusercontent.com",
|
|
57
|
+
)
|
|
58
|
+
GITHUB_ALLOW_DOMAINS = (
|
|
59
|
+
"api.github.com",
|
|
60
|
+
"github.com",
|
|
61
|
+
"uploads.github.com",
|
|
62
|
+
"objects.githubusercontent.com",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
PostCreate = Callable[[Sandbox, SandboxRole], None]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def sandbox_name(run_id: str, role: SandboxRole) -> str:
|
|
69
|
+
return f"sbxloop-{run_id}-{role}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Provisioner:
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
cli: SbxCLI,
|
|
76
|
+
config: Config,
|
|
77
|
+
bus: EventBus | None = None,
|
|
78
|
+
*,
|
|
79
|
+
env: Mapping[str, str] | None = None,
|
|
80
|
+
post_create: PostCreate | None = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
self.cli = cli
|
|
83
|
+
self.config = config
|
|
84
|
+
self.bus = bus or EventBus()
|
|
85
|
+
self.env = os.environ if env is None else env
|
|
86
|
+
self.post_create = post_create
|
|
87
|
+
|
|
88
|
+
# -- spec construction -------------------------------------------------
|
|
89
|
+
|
|
90
|
+
def build_specs(self, run_id: str, workspace: Path) -> tuple[SandboxSpec, SandboxSpec]:
|
|
91
|
+
extra = tuple(self.config.sandbox.extra_allow_domains)
|
|
92
|
+
template = self.config.sandbox.template
|
|
93
|
+
agent = SandboxSpec(
|
|
94
|
+
name=sandbox_name(run_id, "agent"),
|
|
95
|
+
role="agent",
|
|
96
|
+
workspace=workspace,
|
|
97
|
+
template=template,
|
|
98
|
+
policy_allows=[*AGENT_ALLOW_DOMAINS, *extra],
|
|
99
|
+
secrets=[SecretSpec(kind="custom", host=COPILOT_TOKEN_HOST, env=COPILOT_TOKEN_ENV)],
|
|
100
|
+
)
|
|
101
|
+
github = SandboxSpec(
|
|
102
|
+
name=sandbox_name(run_id, "github"),
|
|
103
|
+
role="github",
|
|
104
|
+
workspace=workspace,
|
|
105
|
+
template=template,
|
|
106
|
+
policy_allows=[*GITHUB_ALLOW_DOMAINS, *extra],
|
|
107
|
+
secrets=[SecretSpec(kind="service", service="github")],
|
|
108
|
+
)
|
|
109
|
+
return agent, github
|
|
110
|
+
|
|
111
|
+
# -- tokens ------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def copilot_token(self) -> str:
|
|
114
|
+
token = self.env.get(COPILOT_TOKEN_ENV, "")
|
|
115
|
+
if not token:
|
|
116
|
+
raise ProvisionError(
|
|
117
|
+
f"{COPILOT_TOKEN_ENV} is not set on the host. Create a fine-grained PAT "
|
|
118
|
+
'with the "Copilot Requests" permission and export it.'
|
|
119
|
+
)
|
|
120
|
+
return token
|
|
121
|
+
|
|
122
|
+
def gh_token(self) -> str:
|
|
123
|
+
for name in GH_TOKEN_ENVS:
|
|
124
|
+
token = self.env.get(name, "")
|
|
125
|
+
if token:
|
|
126
|
+
return token
|
|
127
|
+
raise ProvisionError(
|
|
128
|
+
f"none of {', '.join(GH_TOKEN_ENVS)} are set on the host. Create a fine-grained "
|
|
129
|
+
"PAT with the repository permissions sbxloop should act with (e.g. issues:write, "
|
|
130
|
+
"contents:read) and export GH_TOKEN."
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# -- provisioning ------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
def ensure_pair(self, run_id: str, workspace: Path | None = None) -> SandboxPair:
|
|
136
|
+
workspace = workspace or self.config.sandbox.workspace
|
|
137
|
+
if workspace is None:
|
|
138
|
+
workspace = self.config.state_dir / "runs" / run_id / "workspace"
|
|
139
|
+
workspace = workspace.resolve()
|
|
140
|
+
workspace.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
|
|
142
|
+
# Fail fast on missing tokens before creating any microVM.
|
|
143
|
+
tokens: dict[SandboxRole, str] = {
|
|
144
|
+
"agent": self.copilot_token(),
|
|
145
|
+
"github": self.gh_token(),
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
agent_spec, github_spec = self.build_specs(run_id, workspace)
|
|
149
|
+
created: list[Sandbox] = []
|
|
150
|
+
try:
|
|
151
|
+
sandboxes: dict[SandboxRole, Sandbox] = {}
|
|
152
|
+
for spec in (agent_spec, github_spec):
|
|
153
|
+
self.bus.emit("sandbox.provision_start", run_id, name=spec.name, role=spec.role)
|
|
154
|
+
self.cli.create(spec)
|
|
155
|
+
sandbox = Sandbox(self.cli, spec.name)
|
|
156
|
+
created.append(sandbox)
|
|
157
|
+
self._apply_policy(spec)
|
|
158
|
+
self._apply_secrets(spec, sandbox, tokens[spec.role])
|
|
159
|
+
self._verify_secret_env(run_id, spec, sandbox, tokens[spec.role])
|
|
160
|
+
sandbox.mkdirs(JOBS_DIR, RESULTS_DIR, EVENTS_DIR)
|
|
161
|
+
if self.post_create is not None:
|
|
162
|
+
self.post_create(sandbox, spec.role)
|
|
163
|
+
sandboxes[spec.role] = sandbox
|
|
164
|
+
self.bus.emit("sandbox.ready", run_id, name=spec.name, role=spec.role)
|
|
165
|
+
return SandboxPair(
|
|
166
|
+
run_id,
|
|
167
|
+
agent=sandboxes["agent"],
|
|
168
|
+
github=sandboxes["github"],
|
|
169
|
+
keep=self.config.keep_sandboxes,
|
|
170
|
+
)
|
|
171
|
+
except Exception as exc:
|
|
172
|
+
for sandbox in created:
|
|
173
|
+
try:
|
|
174
|
+
sandbox.rm()
|
|
175
|
+
except SbxError:
|
|
176
|
+
logger.warning("rollback: failed to remove %s", sandbox.name, exc_info=True)
|
|
177
|
+
if isinstance(exc, ProvisionError):
|
|
178
|
+
raise
|
|
179
|
+
raise ProvisionError(f"provisioning run {run_id} failed: {exc}") from exc
|
|
180
|
+
|
|
181
|
+
def _apply_policy(self, spec: SandboxSpec) -> None:
|
|
182
|
+
for domain in spec.policy_allows:
|
|
183
|
+
self.cli.policy_allow(domain, sandbox=spec.name)
|
|
184
|
+
|
|
185
|
+
def _apply_secrets(self, spec: SandboxSpec, sandbox: Sandbox, token: str) -> None:
|
|
186
|
+
if self.config.secret_strategy == "plain-env":
|
|
187
|
+
self._apply_plain_env(spec, sandbox, token)
|
|
188
|
+
return
|
|
189
|
+
for secret in spec.secrets:
|
|
190
|
+
if secret.kind == "service":
|
|
191
|
+
assert secret.service is not None
|
|
192
|
+
service = secret.service
|
|
193
|
+
self._set_secret_replacing(
|
|
194
|
+
f"service {service} ({spec.name})",
|
|
195
|
+
set_fn=partial(self.cli.secret_set, service, sandbox=spec.name, token=token),
|
|
196
|
+
rm_candidates=partial(self._service_rm_candidates, service, spec.name),
|
|
197
|
+
)
|
|
198
|
+
else:
|
|
199
|
+
assert secret.host is not None and secret.env is not None
|
|
200
|
+
self._set_secret_replacing(
|
|
201
|
+
f"custom {secret.env}@{secret.host} ({spec.name})",
|
|
202
|
+
set_fn=partial(
|
|
203
|
+
self.cli.secret_set_custom,
|
|
204
|
+
host=secret.host,
|
|
205
|
+
env=secret.env,
|
|
206
|
+
value=token,
|
|
207
|
+
sandbox=spec.name,
|
|
208
|
+
),
|
|
209
|
+
rm_candidates=partial(
|
|
210
|
+
self._custom_rm_candidates, secret.host, secret.env, spec.name
|
|
211
|
+
),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
_SECRET_EXISTS_MARKERS = ("exist", "already")
|
|
215
|
+
# sbx reports the owner of a conflicting secret, e.g.
|
|
216
|
+
# ERROR: custom secret env "X" already exists in scope NAME with placeholder ...
|
|
217
|
+
_SCOPE_RE = re.compile(r'in scope "?([A-Za-z0-9._-]+)"?')
|
|
218
|
+
|
|
219
|
+
@classmethod
|
|
220
|
+
def _parsed_scope(cls, stderr: str) -> str | None:
|
|
221
|
+
"""The scope owning the conflicting secret, per sbx's error message.
|
|
222
|
+
|
|
223
|
+
Returns None when unparseable; the literal scopes "global"/"-g" map
|
|
224
|
+
to None-as-global in secret_rm terms via the callers below.
|
|
225
|
+
"""
|
|
226
|
+
match = cls._SCOPE_RE.search(stderr)
|
|
227
|
+
return match.group(1) if match else None
|
|
228
|
+
|
|
229
|
+
def _service_rm_candidates(
|
|
230
|
+
self, service: str, sandbox: str, stderr: str
|
|
231
|
+
) -> list[Callable[[], bool]]:
|
|
232
|
+
scopes: list[str | None] = []
|
|
233
|
+
parsed = self._parsed_scope(stderr)
|
|
234
|
+
if parsed:
|
|
235
|
+
scopes.append(None if parsed in ("global", "-g") else parsed)
|
|
236
|
+
scopes += [sandbox, None]
|
|
237
|
+
seen: list[str | None] = []
|
|
238
|
+
candidates: list[Callable[[], bool]] = []
|
|
239
|
+
for scope in scopes:
|
|
240
|
+
if scope in seen:
|
|
241
|
+
continue
|
|
242
|
+
seen.append(scope)
|
|
243
|
+
candidates.append(partial(self.cli.secret_rm, service=service, sandbox=scope))
|
|
244
|
+
return candidates
|
|
245
|
+
|
|
246
|
+
def _custom_rm_candidates(
|
|
247
|
+
self, host: str, env: str, sandbox: str, stderr: str
|
|
248
|
+
) -> list[Callable[[], bool]]:
|
|
249
|
+
scopes: list[str | None] = []
|
|
250
|
+
parsed = self._parsed_scope(stderr)
|
|
251
|
+
if parsed:
|
|
252
|
+
scopes.append(None if parsed in ("global", "-g") else parsed)
|
|
253
|
+
scopes += [sandbox, None]
|
|
254
|
+
seen: list[str | None] = []
|
|
255
|
+
candidates: list[Callable[[], bool]] = []
|
|
256
|
+
for scope in scopes:
|
|
257
|
+
if scope in seen:
|
|
258
|
+
continue
|
|
259
|
+
seen.append(scope)
|
|
260
|
+
# env+host first, then env-only: sbx keys custom secrets by env
|
|
261
|
+
# name, so the conflicting entry may carry a different host.
|
|
262
|
+
candidates.append(partial(self.cli.secret_rm, host=host, env=env, sandbox=scope))
|
|
263
|
+
candidates.append(partial(self.cli.secret_rm, env=env, sandbox=scope))
|
|
264
|
+
return candidates
|
|
265
|
+
|
|
266
|
+
def _set_secret_replacing(
|
|
267
|
+
self,
|
|
268
|
+
describe: str,
|
|
269
|
+
*,
|
|
270
|
+
set_fn: Callable[[], None],
|
|
271
|
+
rm_candidates: Callable[[str], list[Callable[[], bool]]],
|
|
272
|
+
) -> None:
|
|
273
|
+
"""Set a secret, replacing a leftover one from a previous run.
|
|
274
|
+
|
|
275
|
+
sbx refuses to overwrite an existing secret and keys custom secrets
|
|
276
|
+
by env name, with the conflicting entry possibly owned by another
|
|
277
|
+
scope (a previous run's sandbox). On an exists-error we parse the
|
|
278
|
+
owning scope out of sbx's stderr and try removal candidates from
|
|
279
|
+
most to least specific, retrying the set after each successful
|
|
280
|
+
removal. An exists-conflict NEVER fails provisioning: if nothing
|
|
281
|
+
can be replaced, the existing value is kept with a warning (it may
|
|
282
|
+
be stale if the token was rotated). Non-exists errors raise.
|
|
283
|
+
|
|
284
|
+
Only sbx's stderr is matched for exists-markers: the full exception
|
|
285
|
+
string embeds argv, and arbitrary paths can contain words like
|
|
286
|
+
"exists" (a pytest tmp dir did exactly that).
|
|
287
|
+
"""
|
|
288
|
+
try:
|
|
289
|
+
set_fn()
|
|
290
|
+
return
|
|
291
|
+
except SbxError as exc:
|
|
292
|
+
if not any(m in exc.stderr.lower() for m in self._SECRET_EXISTS_MARKERS):
|
|
293
|
+
raise
|
|
294
|
+
stderr = exc.stderr
|
|
295
|
+
for rm_fn in rm_candidates(stderr):
|
|
296
|
+
if not rm_fn():
|
|
297
|
+
continue
|
|
298
|
+
try:
|
|
299
|
+
set_fn()
|
|
300
|
+
return
|
|
301
|
+
except SbxError as exc:
|
|
302
|
+
if not any(m in exc.stderr.lower() for m in self._SECRET_EXISTS_MARKERS):
|
|
303
|
+
raise
|
|
304
|
+
logger.warning(
|
|
305
|
+
"secret %s already exists and could not be replaced; keeping the "
|
|
306
|
+
"existing value (it may be stale if the token was rotated)",
|
|
307
|
+
describe,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
def _verify_secret_env(
|
|
311
|
+
self, run_id: str, spec: SandboxSpec, sandbox: Sandbox, token: str
|
|
312
|
+
) -> None:
|
|
313
|
+
"""Verify the secret env is visible; auto-heal with plain-env if not.
|
|
314
|
+
|
|
315
|
+
Field-confirmed (2026-07-23): sbx's proxy secret injection feeds the
|
|
316
|
+
interactive agent sessions sbx launches, but NOT `sbx exec`
|
|
317
|
+
processes - not even login shells. When the proxy strategy leaves
|
|
318
|
+
the env invisible, provisioning falls back to writing the in-VM env
|
|
319
|
+
file for that sandbox so runs work, with a loud event about the
|
|
320
|
+
security tradeoff. If sbx later injects secrets into exec sessions,
|
|
321
|
+
this check passes and the token stays out of the VM.
|
|
322
|
+
"""
|
|
323
|
+
if self.config.secret_strategy != "proxy":
|
|
324
|
+
# plain-env: the worker loads the env file itself; a shell
|
|
325
|
+
# visibility check can never pass and would only produce noise.
|
|
326
|
+
return
|
|
327
|
+
env_name = COPILOT_TOKEN_ENV if spec.role == "agent" else "GH_TOKEN"
|
|
328
|
+
result = sandbox.exec(["sh", "-lc", f'test -n "${{{env_name}}}"'])
|
|
329
|
+
if result.ok:
|
|
330
|
+
return
|
|
331
|
+
# Auto-heal: fall back to the plain-env file for this sandbox. The
|
|
332
|
+
# token value becomes visible inside the microVM (which the agent
|
|
333
|
+
# already controls); egress remains bounded by the network policy.
|
|
334
|
+
message = (
|
|
335
|
+
f"{env_name}: sbx proxy secret invisible to exec — using in-VM env file "
|
|
336
|
+
f'(secret_strategy="plain-env" silences this)'
|
|
337
|
+
)
|
|
338
|
+
logger.info("%s: %s", spec.name, message)
|
|
339
|
+
self._apply_plain_env(spec, sandbox, token)
|
|
340
|
+
self.bus.emit(
|
|
341
|
+
"sandbox.secret_env_fallback",
|
|
342
|
+
run_id,
|
|
343
|
+
name=spec.name,
|
|
344
|
+
env=env_name,
|
|
345
|
+
message=message,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
def _apply_plain_env(self, spec: SandboxSpec, sandbox: Sandbox, token: str) -> None:
|
|
349
|
+
"""Weaker fallback: write tokens/env into ~/.sbxloop/env.sh in the VM."""
|
|
350
|
+
exports: dict[str, str] = dict(spec.persistent_env)
|
|
351
|
+
if spec.role == "agent":
|
|
352
|
+
exports[COPILOT_TOKEN_ENV] = token
|
|
353
|
+
else:
|
|
354
|
+
exports["GH_TOKEN"] = token
|
|
355
|
+
exports["GITHUB_TOKEN"] = token
|
|
356
|
+
lines = "".join(
|
|
357
|
+
f"export {key}={shlex.quote(value)}\n" for key, value in sorted(exports.items())
|
|
358
|
+
)
|
|
359
|
+
sandbox.exec(["mkdir", "-p", ENV_FILE.rsplit("/", 1)[0]])
|
|
360
|
+
sandbox.write_text(ENV_FILE, lines)
|
|
361
|
+
sandbox.exec(["chmod", "600", ENV_FILE])
|
sbxloop/sbx/sandbox.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""A handle to one live sandbox: exec, file transfer, lifecycle."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import tempfile
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from sbxloop.sbx.cli import SbxCLI
|
|
11
|
+
from sbxloop.sbx.models import ExecResult
|
|
12
|
+
|
|
13
|
+
# Canonical in-sandbox layout. The sandbox user is `agent` in the official
|
|
14
|
+
# Docker sandbox templates.
|
|
15
|
+
SANDBOX_HOME = "/home/agent"
|
|
16
|
+
SBXLOOP_DIR = f"{SANDBOX_HOME}/.sbxloop"
|
|
17
|
+
JOBS_DIR = f"{SBXLOOP_DIR}/jobs"
|
|
18
|
+
RESULTS_DIR = f"{SBXLOOP_DIR}/results"
|
|
19
|
+
EVENTS_DIR = f"{SBXLOOP_DIR}/events"
|
|
20
|
+
ENV_FILE = f"{SBXLOOP_DIR}/env.sh"
|
|
21
|
+
VENV_DIR = f"{SBXLOOP_DIR}/venv"
|
|
22
|
+
VENV_PYTHON = f"{VENV_DIR}/bin/python"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Sandbox:
|
|
26
|
+
"""Operations on one existing sandbox. Does not own creation."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, cli: SbxCLI, name: str) -> None:
|
|
29
|
+
self.cli = cli
|
|
30
|
+
self.name = name
|
|
31
|
+
|
|
32
|
+
def __repr__(self) -> str:
|
|
33
|
+
return f"Sandbox({self.name!r})"
|
|
34
|
+
|
|
35
|
+
def exec(self, cmd: Sequence[str], *, timeout: float | None = None) -> ExecResult:
|
|
36
|
+
return self.cli.exec(self.name, cmd, timeout=timeout)
|
|
37
|
+
|
|
38
|
+
def exec_stream(self, cmd: Sequence[str]) -> subprocess.Popen[str]:
|
|
39
|
+
"""Start a streaming exec; caller owns the returned process."""
|
|
40
|
+
return self.cli.popen("exec", self.name, *cmd)
|
|
41
|
+
|
|
42
|
+
def cp_in(self, host_path: Path, sb_path: str) -> None:
|
|
43
|
+
self.cli.cp(str(host_path), f"{self.name}:{sb_path}")
|
|
44
|
+
|
|
45
|
+
def cp_out(self, sb_path: str, host_path: Path) -> None:
|
|
46
|
+
self.cli.cp(f"{self.name}:{sb_path}", str(host_path))
|
|
47
|
+
|
|
48
|
+
def write_text(self, sb_path: str, text: str) -> None:
|
|
49
|
+
with tempfile.NamedTemporaryFile("w", suffix=".sbxloop", delete=False) as f:
|
|
50
|
+
f.write(text)
|
|
51
|
+
tmp = Path(f.name)
|
|
52
|
+
try:
|
|
53
|
+
# NamedTemporaryFile creates 0600 files and sbx cp preserves the
|
|
54
|
+
# mode into the VM, where the in-sandbox `agent` user (not the
|
|
55
|
+
# owner) must read them — job files were arriving unreadable
|
|
56
|
+
# (Errno 13). Everything staged in must be world-readable.
|
|
57
|
+
tmp.chmod(0o644)
|
|
58
|
+
self.cp_in(tmp, sb_path)
|
|
59
|
+
finally:
|
|
60
|
+
tmp.unlink(missing_ok=True)
|
|
61
|
+
|
|
62
|
+
def read_text(self, sb_path: str) -> str:
|
|
63
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
64
|
+
target = Path(tmpdir) / "out"
|
|
65
|
+
self.cp_out(sb_path, target)
|
|
66
|
+
return target.read_text()
|
|
67
|
+
|
|
68
|
+
def mkdirs(self, *sb_paths: str) -> None:
|
|
69
|
+
self.exec(["mkdir", "-p", *sb_paths])
|
|
70
|
+
|
|
71
|
+
def stop(self) -> None:
|
|
72
|
+
self.cli.stop(self.name)
|
|
73
|
+
|
|
74
|
+
def rm(self) -> None:
|
|
75
|
+
self.cli.rm(self.name, force=True)
|