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.
@@ -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)
@@ -0,0 +1,6 @@
1
+ """Host-side worker transport: wheel resolution and the WorkerClient."""
2
+
3
+ from sbxloop.worker.client import WorkerClient
4
+ from sbxloop.worker.wheel import resolve_worker_wheel
5
+
6
+ __all__ = ["WorkerClient", "resolve_worker_wheel"]